Skip to content

fix(seeders): self-healing TTLs for military-bases version keys (#6845 item 1) - #6871

Merged
koala73 merged 5 commits into
koala73:mainfrom
yzxcj797:fix/military-bases-version-key-ttl-6845
Aug 18, 2026
Merged

fix(seeders): self-healing TTLs for military-bases version keys (#6845 item 1)#6871
koala73 merged 5 commits into
koala73:mainfrom
yzxcj797:fix/military-bases-version-key-ttl-6845

Conversation

@yzxcj797

Copy link
Copy Markdown
Contributor

First follow-up from #6845: a run killed before atomicSwitch leaks its version keys permanently.

Mechanism

The seeder writes military:bases:geo:<version> / meta:<version> with no TTL, and cleanupOldVersion only ever computes keys from the version that is currently active — a version that never published is swept by nothing, ever. The runner SIGTERMs at timeoutMs and SIGKILLs 10s later, so each such death leaks up to a 125,380-member zset plus a 125,380-field hash. The same hole leaks the superseded version when a run dies inside the 30s grace: the DELs never fire, and the next reseed deletes only the then-active version.

Fix — three cooperating halves

  1. Seeding arms a self-healing TTL on every batch. seedGeo/seedMeta piggyback EXPIRE key 1800 onto each pipeline: it arms from the first write (EXPIRE on a missing key is a no-op) and keeps refreshing while the run is alive — only a dead run lets it count down. 30 min is ~3× the 540s slot incl. the R2-cold worst case, so a slow-but-alive run can never expire its own data.
  2. atomicSwitch PERSISTs inside the publish EVAL. The version's own two keys are passed as KEYS[3]/KEYS[4] and PERSISTed atomically with the SETs — a published version can never expire, an unpublished one always can.
  3. armSupersededCleanup right after the switch. The old pair gets EXPIRE 150s (5× the reader grace) after atomicSwitch lands — deliberately not before, because until the switch they ARE the live data and seeding can outlast any superseded TTL. A kill inside the grace window now self-heals: Redis reaps them shortly after the grace would have ended.

Plus a best-effort start-of-run sweep (sweepLeakedVersionKeys) that heals historical leaks from pre-TTL runs: SCAN both key families; a key counts as leaked only when TTL == -1 and it is not the active version's. Live seeding runs and the active version can never match. The sweep never DELs — it only re-arms the superseded TTL and lets Redis reap, preserving a grace-sized window even if a concurrent publish flips active mid-scan. Transport failure logs a warning and moves on; the next run sweeps again.

Tests

Six new tests in tests/seed-military-bases-version-key-ttl.test.mjs:

  • TTL constants bracket their windows (version TTL ≥ 10 min > slot; superseded TTL ≥ 2× grace)
  • seedGeo/seedMeta arm + refresh the EXPIRE on every batch
  • atomicSwitch EVAL: numkeys 4, both version keys, PERSIST for both, payload last
  • armSupersededCleanup EXPIREs exactly the two old keys
  • the sweep re-arms only no-TTL non-active keys (TTL-armed and active keys untouched; active excluded by name before any TTL probe)

tests/bundle-runner.test.mjs's publish-contract test is updated for the 4-key EVAL shape and now also asserts the two PERSIST calls and the geo/meta key names. All green locally (6/6 new, 38/38 bundle-runner, 9/9 existing seeder tests).

…tion (koala73#6764)

The branch-contamination guard fetched origin on every push solely so the
>20-commits check would not false-positive against a stale tracking ref. A
fetch can only move origin/$BASE_REF forward and the count can only shrink,
so a cached count within budget is already a guaranteed pass — the common
path paid warm 2s / cold 72s of network for nothing (median push in the
Canada merge campaign: 45.6s).

Invert it: compute the count against the cached ref first; fetch only when
the cached count exceeds the budget (to disprove a stale-ref false positive)
or when the ref is not cached at all. The verdict is provably identical.

Regression tests cover all three shapes: within-budget skips the fetch
entirely, a stale-ref false positive fetches exactly once and then passes,
and a genuinely contaminated branch still errors after trying to disprove.
…a73#6845 item 1)

A run killed before atomicSwitch left military:bases:{geo,meta}:<version>
written with no TTL, and nothing ever swept it — cleanupOldVersion only names
the keys of the version that is currently active, so a version that never
published leaked up to a 125,380-member zset plus a 125,380-field hash. The
same mechanism leaked the superseded version when a run was killed inside the
30s post-publish grace: the DELs never ran and the next reseed deletes only
the then-active version.

Three cooperating halves:

- seedGeo/seedMeta piggyback an EXPIRE on every batch: the TTL arms from the
  first write (EXPIRE on a missing key is a no-op) and keeps refreshing while
  the run is alive. 30 minutes, ~3x the 540s slot including the R2-cold worst
  case.
- atomicSwitch PERSISTs both version keys inside the same EVAL that publishes,
  so a live version can never expire out from under readers and an
  unpublished one always can.
- armSupersededCleanup EXPIREs the old pair right after the switch (never
  before — until the switch lands they ARE the live data), so a kill inside
  the grace window still reaps them after a window 5x the reader grace.

A best-effort start-of-run sweep also re-arms TTLs on keys leaked by pre-TTL
runs: a key counts as leaked only when it has no TTL and is not the active
version's. The sweep never DELs directly — it lets Redis reap, preserving a
grace-sized window even against a concurrent publish.
@vercel

vercel Bot commented Aug 17, 2026

Copy link
Copy Markdown

@yzxcj797 is attempting to deploy a commit to the World Monitor Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added the trust:safe Brin: contributor trust score safe label Aug 17, 2026
@cursor

cursor Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Review — head 5f61ed00d

The seeder work in this PR is healthy. Both blockers come from a commit that belongs to a different PR.

Blocker 1 — this branch carries #6870's commit, and that is what reds unit

The branch is two commits, and the first is not yours-for-this-PR:

698e72707 perf(prepush): fetch only to disprove a suspected contamination violation (#6764)
5f61ed00d fix(seeders): self-healing TTLs for military-bases version keys (#6845 item 2)

That first commit is why .husky/pre-push, tests/prepush-hook-gate.test.mjs and tests/bundle-runner.test.mjs appear in a seeder PR's file list, and it is the source of the CI failure:

✖ a stale-ref false positive still fetches, disproves itself, and passes
  AssertionError: cached count 26 > 20 must fetch exactly once to recompute
    actual: 2, expected: 1
  at tests/prepush-hook-gate.test.mjs:606

Reproduced locally on an exported head tree. It is a harness defect, not a hook defect. The test's own setup at :599 runs a fetch through the fixture:

fixture.git(['fetch', '--quiet', 'origin', 'main:refs/heads/__remote-main']);

That lands in the same git-invocations.log that fetchCount(gitCalls) later counts, and run() only clears the log after reading it (:188-189), never before. So the assertion sees the setup fetch plus the hook's one real fetch. Clearing both logs at the top of run() (:172) fixes it:

const run = (extraEnv = {}) => {
  rmSync(log, { force: true });
  rmSync(gitLog, { force: true });
  

With that, the file is 21/21 green. #6870 is red on the same test for the same reason, so the fix belongs there.

Blocker 2 — the conflict is also from the stacked commit

git merge-tree origin/main <head> reports exactly one conflicted file: tests/prepush-hook-gate.test.mjs. Nothing in scripts/seed-military-bases.mjs conflicts.

Recommendation: drop 698e72707 from this branch. Both blockers disappear with it, this PR becomes a clean single-commit seeder change, and #6870 keeps its own timeline and its own fix. As a bonus, #6875 also edits tests/prepush-hook-gate.test.mjs, so keeping the prepush work in one place avoids a three-way tangle.

The seeder change itself is sound

  • tests/seed-military-bases-version-key-ttl.test.mjs6/6 pass, covering the four behaviours that matter: seedMeta arms and refreshes the TTL on every batch, atomicSwitch PERSISTs the version keys inside the publish EVAL, armSupersededCleanup EXPIREs both old keys for the superseded window, and the sweep re-arms TTLs only on no-TTL, non-active version keys.
  • Non-vacuous: swapping scripts/seed-military-bases.mjs back to its merge-base version reds the file immediately.
  • Doing the PERSIST inside the publish EVAL is the right call — it keeps "this version is now active" and "this version's keys no longer expire" in one atomic step, so a crash between them cannot leave the live version on a countdown.

Coordination note

scripts/seed-military-bases.mjs is being edited by three open PRs at once — this one (TTLs, atomicSwitch/seedMeta), #6872 (no-data fallback in main()), and #6873 (activation marker in maybeRepairMissingSeedMeta and main()). They touch adjacent regions and do not conflict yet, but whichever lands second and third will need a rebase. Worth deciding an order.

Verdict: blocked, but only on the stacked commit. Drop 698e72707 and the seeder work is mergeable.

@koala73 koala73 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (not ready)

CONFLICTING, and unit/gate are red. This head also looks contaminated with #6870 pre-push hunks.

Please rebase onto current main as a TTL-only change, drop unrelated pre-push edits, and re-run unit.

@koala73

koala73 commented Aug 18, 2026

Copy link
Copy Markdown
Owner

@yzxcj797 pls don't forget this one - you are churning a lot of PRs, but I want to close the older ones first

@koala73
koala73 dismissed their stale review August 18, 2026 14:13

Reviewed

@koala73
koala73 enabled auto-merge (squash) August 18, 2026 14:13
@koala73
koala73 merged commit 602d532 into koala73:main Aug 18, 2026
28 of 29 checks passed
koala73 added a commit to yzxcj797/worldmonitor that referenced this pull request Aug 19, 2026
koala73#6871 landed the version-key TTL work on scripts/seed-military-bases.mjs and
changed atomicSwitch's shape: it now takes the superseded version and returns
the cleanup info the caller schedules the grace period from. This branch had
added a writeActivationMarker call immediately after that same call, which is
the one conflict.

Resolved to main's signature, keeping the marker write after it. Everything
else auto-merged.

Claude-Session: https://claude.ai/code/session_01QBV6rYWJd62owNKBrkgZxa
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

trust:safe Brin: contributor trust score safe

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants