Skip to content

gdeltIntel STALE_SEED (882m>720m): cache-merge fallback silently dies (redisGet 5s/no-retry/silent-catch) at the 150s soft-budget boundary during GDELT brownout; skip-path writeFreshnessMetadata FATALs unretried #5437

Description

@koala73

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 (_loadPreviousverifySeedKeyredisGet) 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) → redisSetredisCommand (: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

  1. 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.
  2. Wrap writeFreshnessMetadata's redisSet (and the skip-path callers) in withRetry so a single Upstash blip can't exit-1 the run.
  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:seedersRailway seed scripts and Redis seed databugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions