diff --git a/scripts/seed-bundle-resilience.mjs b/scripts/seed-bundle-resilience.mjs index b7ae59b3e..c7bfb3f47 100644 --- a/scripts/seed-bundle-resilience.mjs +++ b/scripts/seed-bundle-resilience.mjs @@ -1,7 +1,14 @@ #!/usr/bin/env node import { runBundle, HOUR, DAY } from './_bundle-runner.mjs'; +// intervalMs note: the bundle runner skips sections whose seed-meta is newer +// than `intervalMs * 0.8`. The Resilience-Scores section must run more often +// than the ranking/score TTL (12h / 6h) so refreshRankingAggregate() can keep +// the ranking alive between Railway cron fires. A 2h interval → 96min skip +// window, so hourly Railway fires run this ~every 2h. The seeder is cheap on +// warm runs (~5-10s: intervals recompute + one /refresh=1 HTTP + 2 verify +// GETs); the expensive warm path only runs when scores are actually missing. await runBundle('resilience', [ - { label: 'Resilience-Scores', script: 'seed-resilience-scores.mjs', seedMetaKey: 'resilience:intervals', intervalMs: 6 * HOUR, timeoutMs: 600_000 }, + { label: 'Resilience-Scores', script: 'seed-resilience-scores.mjs', seedMetaKey: 'resilience:intervals', intervalMs: 2 * HOUR, timeoutMs: 600_000 }, { label: 'Resilience-Static', script: 'seed-resilience-static.mjs', seedMetaKey: 'resilience:static', intervalMs: 90 * DAY, timeoutMs: 900_000 }, ]); diff --git a/tests/resilience-scores-seed.test.mjs b/tests/resilience-scores-seed.test.mjs index 2cb1051cc..523e95e5e 100644 --- a/tests/resilience-scores-seed.test.mjs +++ b/tests/resilience-scores-seed.test.mjs @@ -217,6 +217,33 @@ describe('ensures ranking aggregate is present every cron, with truthful meta', }); }); +describe('seed-bundle-resilience section interval keeps refresh alive', () => { + // The bundle runner skips a section when its seed-meta is younger than + // intervalMs * 0.8. If intervalMs is too long (e.g. 6h), most Railway cron + // fires hit the skip branch → refreshRankingAggregate() never runs → + // ranking can expire between actual runs and create EMPTY_ON_DEMAND gaps. + // 2h is the tested trade-off: frequent enough for the 12h ranking TTL to + // stay well-refreshed, cheap enough per warm-path run (~5-10s). + it('Resilience-Scores section has intervalMs ≤ 2 hours', async () => { + const { readFileSync } = await import('node:fs'); + const { fileURLToPath } = await import('node:url'); + const { dirname, join } = await import('node:path'); + const dir = dirname(fileURLToPath(import.meta.url)); + const src = readFileSync( + join(dir, '..', 'scripts', 'seed-bundle-resilience.mjs'), + 'utf8', + ); + // Match the label + section line, then extract the intervalMs value. + const m = src.match(/label:\s*'Resilience-Scores'[\s\S]{0,400}?intervalMs:\s*(\d+)\s*\*\s*HOUR/); + assert.ok(m, 'Resilience-Scores section must set intervalMs in HOUR units'); + const hours = Number(m[1]); + assert.ok( + hours > 0 && hours <= 2, + `intervalMs must be ≤ 2 hours (found ${hours}) so refreshRankingAggregate runs frequently enough to keep the ranking key alive before its 12h TTL`, + ); + }); +}); + describe('handler warm pipeline is chunked', () => { // The 222-country pipeline SET payload (~600KB) exceeds the 5s pipeline // timeout on Vercel Edge → handler reports 0 persisted, ranking skipped.