Symptom
CI seed-freshness gate red on 2026-07-21 22:49 UTC:
Seed freshness alert: 1 seed(s) exceeded maxStaleMin.
- gdeltIntel: age=882m max=720m
Still stale at 2026-07-22 05:09 UTC (/api/health: gdeltIntel STALE_SEED seedAgeMin=1263). Last successful publish: 2026-07-21 08:06:36 UTC. Every cron tick since (10:01, 14:00, 22:25, 04:01) either no-write-skipped or crashed. Two ticks (14:00, 22:25) exited 1 with FATAL: The operation was aborted due to timeout → "Deploy Crashed!" alerts (deployments b7b9704d-13a0-4b67-865d-2ee9833d0212, 23c47764-912e-4c61-828d-9414cd4aa195).
Root-cause chain (three layers)
1. Environmental trigger — GDELT is in a hard brownout, and has been degrading for weeks.
Direct fetches from Railway hang to the 15s abort or fail; the Decodo proxy path gets curl: (35) SSL_ERROR_SYSCALL and curl: (56) CONNECT tunnel failed, response 522 on every attempt (522 = even Cloudflare can't reach the GDELT origin). From a residential connection api.gdeltproject.org answers 429 Too Many Requests after 12.8s. Per-topic fetchedAt in the current canonical proves weeks of coasting on the cache-merge fallback: nuclear/sanctions/intelligence/maritime last fresh 2026-07-01, cyber 07-12, military 07-20.
2. The actual regression mechanism — the cache-merge fallback silently dies at the soft-budget boundary.
The #4864 design: when GDELT yields nothing, fetchAllTopics merges the prior canonical snapshot (_loadPrevious → verifySeedKey → redisGet) and republishes it, keeping seed-meta fresh. That worked through 08:06 (the current canonical, published 08:06, carries backfilled topics with 07-01…07-20 fetchedAt). It has failed on every run since. The canonical key itself is healthy (probed: 23 KB, 130 ms GET, valid envelope, 6 topics × 10 articles — merge input is perfect).
Why it dies: redisGet (scripts/_seed-utils.mjs:283-296) has AbortSignal.timeout(5_000), no retry, returns null on any non-OK status, and the call site (scripts/seed-gdelt-intel.mjs:164) wraps it in a silent .catch(() => null). Under the current brownout the failure mode shifted from fast-429s to hangs, so topic[0] burns the whole 150s soft budget and the merge read lands at t≈150s — exactly when the two just-launched timeline direct fetches hang and the abandoned article-fetch chain churns curl retries. The Upstash GET blips, gets eaten as null, merge no-ops, validate() fails (0 populated topics), atomicPublish returns skipped, seed-meta mirrors the aging 08:06 canonical. Log timing corroborates: graceful run's === Done (159054ms, no write) === ≈ 150s budget + 5s dead GET timeout + meta ops. Notably readCanonicalEnvelopeMeta — the identical HTTP GET of the same key — succeeds seconds later in the same runs, so this is a transient blip converted into a deterministic-looking outage by the no-retry + silent-catch combination.
3. The exit-1 crashes — the skip path's meta write is also unretried.
On the skip path, writeFreshnessMetadata (scripts/_seed-utils.mjs:1797) → redisSet → redisCommand (:254, AbortSignal.timeout(15_000)) is called bare — no withRetry. When that call also blips, the TimeoutError propagates to the seeder's top-level catch → FATAL: The operation was aborted due to timeout → exit 1. writeExtraKey and atomicPublish already got the withRetry treatment for this exact seeder (comment at _seed-utils.mjs:674: "seed-gdelt-intel PUBLISH_TIMEOUT fix"); these two ops were missed.
Proposed fix
- Wrap the merge read in
withRetry (same tagging contract as redisCommand: permanent 4xx non-retryable, 429 honors Retry-After, timeout/5xx backoff), and log the failure in _loadPrevious's catch instead of silently nulling — a dead merge fallback must be visible in the run log.
- Wrap
writeFreshnessMetadata's redisSet (and the skip-path callers) in withRetry so a single Upstash blip can't exit-1 the run.
- Repro first per Bug Fix Protocol: unit-test
fetchAllTopics with an injected _loadPrevious that rejects/times out once then succeeds — assert merged publish still happens; and a runSeed-level test that a first-attempt timeout in the meta write does not propagate.
Separate concern worth its own decision
Even with the fix, the merge fallback re-publishes weeks-old articles with a fresh envelope fetchedAt — the freshness gate only fired because the fallback broke. 4 of 6 topics are ~3 weeks stale and no alert sees it. Consider opting seed-gdelt-intel into the content-age trio (maxContentAgeMin/newestItemAt) so health reports STALE_CONTENT while the envelope stays fresh.
Evidence
- Crashed run 22:25 UTC (deployment
23c47764…): direct+proxy exhausted → Extended TTL on 2 key(s) → FATAL: The operation was aborted due to timeout, no SKIPPED/Done line.
- Graceful run 10:01 UTC (deployment
b7b9704d…): same fetch failure → SKIPPED: validation failed (empty/partial fetch) — seed-meta mirrors canonical (fetchedAt=2026-07-21T08:06:36.406Z, recordCount=6) → === Done (159054ms, no write) ===.
- Canonical probe (2026-07-22 05:12 UTC): STRLEN 23714, GET 130ms, envelope
state:"OK", 6 topics × 10 articles, TTL 67685s; per-topic fetchedAt 07-01/07-01/07-01/07-01/07-12/07-20.
Related: #4864 (soft-budget design), #5140/#5256 (GDELT brownouts elsewhere in the fleet).
Symptom
CI seed-freshness gate red on 2026-07-21 22:49 UTC:
Still stale at 2026-07-22 05:09 UTC (
/api/health:gdeltIntel STALE_SEED seedAgeMin=1263). Last successful publish: 2026-07-21 08:06:36 UTC. Every cron tick since (10:01, 14:00, 22:25, 04:01) either no-write-skipped or crashed. Two ticks (14:00, 22:25) exited 1 withFATAL: The operation was aborted due to timeout→ "Deploy Crashed!" alerts (deploymentsb7b9704d-13a0-4b67-865d-2ee9833d0212,23c47764-912e-4c61-828d-9414cd4aa195).Root-cause chain (three layers)
1. Environmental trigger — GDELT is in a hard brownout, and has been degrading for weeks.
Direct fetches from Railway hang to the 15s abort or fail; the Decodo proxy path gets
curl: (35) SSL_ERROR_SYSCALLandcurl: (56) CONNECT tunnel failed, response 522on every attempt (522 = even Cloudflare can't reach the GDELT origin). From a residential connectionapi.gdeltproject.organswers429 Too Many Requestsafter 12.8s. Per-topicfetchedAtin the current canonical proves weeks of coasting on the cache-merge fallback: nuclear/sanctions/intelligence/maritime last fresh 2026-07-01, cyber 07-12, military 07-20.2. The actual regression mechanism — the cache-merge fallback silently dies at the soft-budget boundary.
The #4864 design: when GDELT yields nothing,
fetchAllTopicsmerges the prior canonical snapshot (_loadPrevious→verifySeedKey→redisGet) and republishes it, keeping seed-meta fresh. That worked through 08:06 (the current canonical, published 08:06, carries backfilled topics with 07-01…07-20 fetchedAt). It has failed on every run since. The canonical key itself is healthy (probed: 23 KB, 130 ms GET, valid envelope, 6 topics × 10 articles — merge input is perfect).Why it dies:
redisGet(scripts/_seed-utils.mjs:283-296) hasAbortSignal.timeout(5_000), no retry, returnsnullon any non-OK status, and the call site (scripts/seed-gdelt-intel.mjs:164) wraps it in a silent.catch(() => null). Under the current brownout the failure mode shifted from fast-429s to hangs, so topic[0] burns the whole 150s soft budget and the merge read lands at t≈150s — exactly when the two just-launched timeline direct fetches hang and the abandoned article-fetch chain churns curl retries. The Upstash GET blips, gets eaten asnull, merge no-ops,validate()fails (0 populated topics),atomicPublishreturnsskipped, seed-meta mirrors the aging 08:06 canonical. Log timing corroborates: graceful run's=== Done (159054ms, no write) ===≈ 150s budget + 5s dead GET timeout + meta ops. NotablyreadCanonicalEnvelopeMeta— the identical HTTP GET of the same key — succeeds seconds later in the same runs, so this is a transient blip converted into a deterministic-looking outage by the no-retry + silent-catch combination.3. The exit-1 crashes — the skip path's meta write is also unretried.
On the skip path,
writeFreshnessMetadata(scripts/_seed-utils.mjs:1797) →redisSet→redisCommand(:254,AbortSignal.timeout(15_000)) is called bare — nowithRetry. When that call also blips, the TimeoutError propagates to the seeder's top-level catch →FATAL: The operation was aborted due to timeout→ exit 1.writeExtraKeyandatomicPublishalready got thewithRetrytreatment for this exact seeder (comment at_seed-utils.mjs:674: "seed-gdelt-intel PUBLISH_TIMEOUT fix"); these two ops were missed.Proposed fix
withRetry(same tagging contract asredisCommand: permanent 4xx non-retryable, 429 honors Retry-After, timeout/5xx backoff), and log the failure in_loadPrevious's catch instead of silently nulling — a dead merge fallback must be visible in the run log.writeFreshnessMetadata'sredisSet(and the skip-path callers) inwithRetryso a single Upstash blip can't exit-1 the run.fetchAllTopicswith an injected_loadPreviousthat rejects/times out once then succeeds — assert merged publish still happens; and a runSeed-level test that a first-attempt timeout in the meta write does not propagate.Separate concern worth its own decision
Even with the fix, the merge fallback re-publishes weeks-old articles with a fresh envelope
fetchedAt— the freshness gate only fired because the fallback broke. 4 of 6 topics are ~3 weeks stale and no alert sees it. Consider opting seed-gdelt-intel into the content-age trio (maxContentAgeMin/newestItemAt) so health reports STALE_CONTENT while the envelope stays fresh.Evidence
23c47764…): direct+proxy exhausted →Extended TTL on 2 key(s)→FATAL: The operation was aborted due to timeout, no SKIPPED/Done line.b7b9704d…): same fetch failure →SKIPPED: validation failed (empty/partial fetch) — seed-meta mirrors canonical (fetchedAt=2026-07-21T08:06:36.406Z, recordCount=6)→=== Done (159054ms, no write) ===.state:"OK", 6 topics × 10 articles, TTL 67685s; per-topic fetchedAt 07-01/07-01/07-01/07-01/07-12/07-20.Related: #4864 (soft-budget design), #5140/#5256 (GDELT brownouts elsewhere in the fleet).