Back to Worldmonitor

A merged seeder fix is not live until its cron fires — backfill long-cron seeders by hand

docs/solutions/integration-issues/merged-is-not-ran-long-cron-seeders.md

2.10.07.0 KB
Original Source

A merged seeder fix is not live until its cron fires

Problem

defensePatents sat at EMPTY (crit) on /api/health with records: 0, and production overall was DEGRADEDafter the USPTO ODP migration (#5284) had merged and deployed. The seeder code was correct. It had simply never run.

The same shape appeared twice in one session, so it is a pattern, not an incident.

Symptoms

  • /api/health?compact=1 shows the domain as EMPTY, records: 0, and no seedAgeMin — the seed-meta key is absent entirely, not merely stale.
  • The Railway service looks perfectly healthy: no crashes, no red badge.
  • Nothing in the repo is wrong. Reading the seeder source proves nothing.

What Didn't Work

  • Assuming a regression. The obvious read of "fix shipped, data still missing" is that the fix is broken. It was not. A read-only probe of the new source path (importing only the pure scripts/_defense-patents-source.mjs, never the seeder entry point) returned 90 valid records and passed the seeder's own validateDefensePatents. The code was fine the whole time.
  • Reading Railway's deployment list as a run log. The service showed a long list of SUCCESS deployments. Nearly all of them were buildOnly: true — image builds triggered by pushes to main, which run nothing. In the last 100 deployments there was exactly one real run against 82 builds. A busy deployment list is not evidence that a cron ever fired.

Solution

Check the seeder's cron cadence against the fix's merge time before concluding anything:

bash
# The cron schedule lives in Railway service config, not the repo.
# seed-bundle-static-ref: "0 3 * * 0"  => Sunday 03:00 UTC, WEEKLY.
railway status --json   # then query the deployment's serviceManifest.deploy.cronSchedule

The timeline that explained everything:

whenwhat
Sun 2026-07-12 03:00 UTClast cron tick — before the fix existed
Mon 2026-07-13 18:07 UTC#5284 merged and the image rebuilt
Sun 2026-07-19 03:00 UTCnext cron tick — 5 days away

The fix was baked into the image and had never executed. The remedy is a Railway-side manual backfill, which also verifies the fix on the production network (publish, envelope dual-write, Redis verification) rather than only in a local probe.

Do not use railway run for this acceptance step. Railway documents that command as fetching service variables and executing the command locally. It can therefore pass or fail on the operator's network while proving nothing about the seeder's production egress.

Until the project has a dedicated one-off runner, use a controlled temporary cron execution:

  1. Capture the service's current deploy.startCommand and deploy.cronSchedule.
  2. Confirm no prior execution is Active; Railway skips overlapping cron runs.
  3. Temporarily set the cron to the next five-minute cadence (Railway's minimum). Add a bounded repair flag to the start command only when that seeder defines one.
  4. Wait for the Railway execution to reach a terminal state and verify logs, seed metadata, and compact health.
  5. Restore the exact captured command and schedule, then run node scripts/audit-railway-watch-paths.mjs to prove registry convergence.

See Railway's official cron job and railway run documentation for the network and scheduling semantics.

/api/health cleared on the next poll.

Why This Works

A Railway cron seeder has two independent lifecycles that are easy to conflate:

  1. The image rebuilds on every push to main (buildOnly: true deployments). This is what makes the service look active.
  2. The code inside it only executes when the cron schedule fires.

Merging a fix advances (1) immediately and (2) not at all. For an hourly seeder the gap is invisible. For a weekly seeder it is up to 7 days — long enough that the fix looks broken.

This is compounded by the fact that a seed-meta key does not resurrect itself: nothing re-seeds an absent key except a run of the seeder that owns it.

The general rule

Merged ≠ deployed ≠ ran. The only ground truth for seeder data is the seed-meta key in Redis, not the PR state, not CI, and not the Railway badge.

A corollary worth internalizing: shipping a health-key registration in the same PR as its seeder makes health go crit for exactly one cron period. The health endpoint starts grading a key before the seeder that populates it has ever run. Observed twice on the same day:

  • defensePatents — weekly cron → would have stayed crit for ~5 days.
  • chinaMacro / chinaReleaseCalendar (#5294) — daily cron (0 8 * * *) → crit for ~10 hours, then self-healed with no intervention.

The registration is at api/health.js:211 (data key) and api/health.js:403 (seed-meta key); the weekly section is scripts/seed-bundle-static-ref.mjs:6 (intervalMs: WEEK).

Prevention

  • Choose one cutover path before merging a new or repointed health probe. Complete a Railway-side pre-seed and verify compact health, or add an owner-bound acknowledgement to scripts/seed-freshness-baseline.json with an entry-level expiresAt bounded to the first scheduled cron window. The latter is only a temporary rollout bridge: at or after its expiry the still-live problem blocks again even if the baseline's root expiry is later. Never use an unbounded acknowledgement to suppress a cutover fault.
  • Before calling a seeder fix "shipped", check its cron cadence. If the next tick is far away, run a Railway-side manual backfill. This is not optional cleanup — it is the step that makes the fix real and proves it end-to-end.
  • Never use railway run as production-network evidence. It injects Railway variables into a local process. Use an actual scheduled execution or a dedicated Railway-side one-off runner.
  • When a health key goes crit right after a merge, check the cron before hunting for a regression. Compare the seeder's last real run to the merge time. railway status --jsonserviceManifest.deploy.cronSchedule.
  • Do not read SUCCESS deployments as runs. Filter on meta.buildOnlybuildOnly: true deployments build the image and execute nothing.
  • Never import a seeder entry point to smoke-test it. Import the pure source module instead (scripts/_defense-patents-source.mjs, not scripts/seed-defense-patents.mjs). This particular seeder happens to guard its runSeed behind an isMain check, but that is not a repo-wide guarantee — an unguarded seeder executes on import and writes production Redis.
  • Railway seeder watch paths can skip deployments — the sibling failure where the image never rebuilds. This doc is the opposite: the image rebuilt fine and the code never ran.
  • PR #5284 (USPTO ODP migration) — the fix that was correct but dormant.
  • PR #5294 — the China coverage PR that registered health keys ahead of the first seed-bundle-macro tick.