fix(seed): time-box seed-conflict-intel GDELT fallback sweep to fit the fetch deadline (#5140)#5141
Conversation
…he fetch deadline (#5140) The #5134 fallback swept 20 countries x ~75-92s of direct+proxy retries at concurrency 4 (~375s worst case) against runSeed's 240s fetch deadline, so a GDELT brownout crashed the seeder (exit 75) every ~30-min tick instead of reaching the caught coverage-floor -> aux-only -> exit 0 path. - GDELT_SWEEP_BUDGET_MS=75s wall-clock budget: stop launching batches once spent; 60s aux headroom + 75s budget + 95s in-flight batch = 230s <= 240s (92s in-flight observed live during the 2026-07-10 brownout). - Floor-unreachable breaker: stop sweeping when successes + remaining < 16. - Skipped countries land in failedCountries with a distinct reason so the floor error's failure sample says why. Live probe during the brownout: sweep now rejects in 92s (was 244s+ crash), degrading to the aux-only exit-0 path. Claude-Session: https://claude.ai/code/session_01LnV7XZ3FuoxC8MuD3gA2UU
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Greptile SummaryThis PR adds two early-exit guards to the GDELT fallback sweep in
Confidence Score: 4/5Safe to merge — the two guards are correctly placed before each batch launch, the arithmetic is sound (230s ≤ 240s), and all three new tests reproduce the failure modes and pin the contract. The core fix is straightforward and well-tested. The now() injection is correctly wired for both the budget calculation and the per-iteration check. The only gap is a cosmetic one in the reason string when both guards fire simultaneously, which does not affect the outcome. No files require special attention; the arithmetic test in tests/conflict-gdelt.test.mjs is the key regression guard to keep passing if timeout knobs are ever tuned. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A([fetchGdeltConflictEvents entry]) --> B[budgetSpentAt = now + 75s]
B --> C{i < 20?}
C -- No --> G[Loop done]
G --> H{successfulCountries >= 16?}
H -- Yes --> I([Return results])
H -- No --> J([Throw coverage-floor error])
C -- Yes --> D[remaining = CONFLICT_COUNTRIES.slice i]
D --> E{overBudget?\nnow >= budgetSpentAt}
E -- Yes --> K[Mark remaining as sweep budget exhausted]
K --> L[console.warn early stop]
L --> G
E -- No --> F{floorUnreachable?\nsuccessful + remaining < 16}
F -- Yes --> M[Mark remaining as coverage floor unreachable]
M --> L
F -- No --> N[batch = remaining.slice 0-4]
N --> O[await Promise.all fetchCountryEvents x4]
O --> P[Accumulate successes and failures]
P --> Q[await pace 500ms]
Q --> R[i += 4]
R --> C
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A([fetchGdeltConflictEvents entry]) --> B[budgetSpentAt = now + 75s]
B --> C{i < 20?}
C -- No --> G[Loop done]
G --> H{successfulCountries >= 16?}
H -- Yes --> I([Return results])
H -- No --> J([Throw coverage-floor error])
C -- Yes --> D[remaining = CONFLICT_COUNTRIES.slice i]
D --> E{overBudget?\nnow >= budgetSpentAt}
E -- Yes --> K[Mark remaining as sweep budget exhausted]
K --> L[console.warn early stop]
L --> G
E -- No --> F{floorUnreachable?\nsuccessful + remaining < 16}
F -- Yes --> M[Mark remaining as coverage floor unreachable]
M --> L
F -- No --> N[batch = remaining.slice 0-4]
N --> O[await Promise.all fetchCountryEvents x4]
O --> P[Accumulate successes and failures]
P --> Q[await pace 500ms]
Q --> R[i += 4]
R --> C
Reviews (1): Last reviewed commit: "fix(seed): time-box seed-conflict-intel ..." | Re-trigger Greptile |
…orst-case runtime (#5140) Adversarial review of the first commit found the 240s-fit arithmetic broken by four confirmed mechanisms; this hardens all of them: - Retry-After exposure: maxRetries 1->0 (a second direct attempt honors GDELT's Retry-After up to 60s, voiding any batch bound; the IP-rotating proxy is the designed 429 answer). - Serialized sync curls: proxyMaxAttempts 2->1 (curlFetch is execFileSync, so proxy attempts block the event loop one at a time -- each attempt adds 4x20s of worst case per batch). - Aux blindness: fetchAll anchors the sweep cutoff (120s) at fetch-phase START and passes an absolute deadlineAt, so slow aux feeds (HAPI sequential, ~306s worst) shrink the sweep window instead of stacking on it. - Knife-edge margin + lock invariant: lockTtlMs 120s->360s per the #4864 precedent -- worst attempt ~336s fits the lock (runSeed's "never outlive your lock" design) and the deadline becomes 480s. Also: combined stop reason when budget and floor trip together (Greptile), and the deadline contract moved to seed-fetch-deadline-budget-invariants.test.mjs deriving from exported constants instead of literal-vs-literal. Live probe during the ongoing brownout: rejects in 76s (floor-unreachable after 2 batches), degrading to aux-only exit 0. Claude-Session: https://claude.ai/code/session_01LnV7XZ3FuoxC8MuD3gA2UU
Fixes #5140.
Problem
Since #5134 deployed (2026-07-09 19:25 UTC),
seed-conflict-intelcrashed on ~6 of 7 cron ticks. The GDELT fallback sweeps 20 countries at concurrency 4; a full-brownout sweep (~375s+) breaches runSeed's fetch deadline →FETCH FAILED→ exit 75 → Railway "Crashed" every ~30 min. GDELT is currently rejecting Railway egress (resets/429s) and killing Decodo TLS handshakes (SSL_ERROR_SYSCALL). The existing caught coverage-floor → aux-only → exit 0 path was unreachable in time.Fix (commit 1 + adversarial-review hardening in commit 2)
Sweep guards — checked before each batch launch, both landing in the caught floor throw → aux-only publish → exit 0:
GDELT_SWEEP_BUDGET_MS = 120s):fetchAllanchors the clock at fetch-phase START and passes an absolutedeadlineAtdown, so slow aux feeds (HAPI is sequential, ~306s worst) shrink the sweep window instead of stacking on top of it.successes + remaining < 16— a total brownout stops after 2 batches. When both trip together the stop reason names both (Greptile).Worst-case bounds made real (adversarial review found the original arithmetic broken by four confirmed mechanisms):
maxRetries: 1 → 0— a second direct attempt honors GDELT'sRetry-After(≤60s sleep,MAX_RETRY_AFTER_MS), voiding any batch bound; the IP-rotating proxy is the designed 429 answer.proxyMaxAttempts: 2 → 1—curlFetchisexecFileSync(sync, ≤20s): proxy attempts SERIALIZE across the batch, so each attempt adds 4×20s of worst case. Worst batch is now 15s + 4×20s ≈ 95s (92s observed live).lockTtlMs: 120s → 360s(per the bug(seeders): gdelt-intel / grocery-basket / supply-chain-trade trip the #4786 240s fetch-deadline on legitimate slow-retry runs (retry-budget > deadline); supply-chain loses last-good data #4864 precedent, same failure class): worst single fetch attempt ≈ max(HAPI 306s, cutoff 120s + batch 95s) + writes ≈ 336s fits the lock — restoring runSeed's documented "a healthy seeder never outlives its own lock" invariant (a ~230s run against a 120s lock violated it) — and the deadline becomes lockTtl + 120s margin = 480s, replacing a 10s knife-edge with real margin. Cron is 30min, so a dangling lock costs ≤6 of those minutes.Known residual (accepted): runSeed wraps
fetchAllinwithRetry(3)inside ONE shared deadline (fleet-wide, pre-existing), so a transient mid-fetch rejection (e.g. Redis hiccup during extra-key writes) that forces a second full attempt can still overrun in a pathological double-brownout — that yields a one-off graceful exit 75 (self-heals), not the chronic per-tick crash this PR eliminates.Verification
tests/conflict-gdelt.test.mjs: cutoff stop, zero-launch when aux ate the phase, floor breaker, combined stop reason — 14/14.tests/seed-fetch-deadline-budget-invariants.test.mjs(the bug(seeders): gdelt-intel / grocery-basket / supply-chain-trade trip the #4786 240s fetch-deadline on legitimate slow-retry runs (retry-budget > deadline); supply-chain loses last-good data #4864 home for this class), deriving from the seeder's exported constants (GDELT_SWEEP_BUDGET_MS,GDELT_COUNTRY_FETCH_OPTS,ACLED_INTEL_LOCK_TTL_MS) instead of literal-vs-literal, and pinningmaxRetries === 0+ the lock-invariant inequality + thatrunSeedactually wires the lock const.acled-resolution-feed-seed7/7,forecast-resolution-eval24/24, invariants 4/4). Biome clean.Notes
acledIntelduring a brownout — the durable fix remains provisioning realACLED_*creds (seed-conflict-intel GRACEFUL·CHRONIC again: #5134 GDELT fallback worst case (~375s) exceeds 240s fetch deadline during GDELT brownout — crashes ~6/7 ticks, conflict:acled key EMPTY in prod #5140 closing note).Doneor the early-stop marker instead ofFETCH FAILED(service redeploy lagged ~1h on fix(seed): don't crash seed-conflict-intel when ACLED creds are absent #5126 —railway upunblocks).https://claude.ai/code/session_01LnV7XZ3FuoxC8MuD3gA2UU