mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
Live log 2026-04-16 09:25 showed the bundle runner SKIPPING Resilience-Scores (last seeded 203min ago, interval 360min -> 288min skip threshold). Every Railway cron fire within the 4.8h skip window bypassed the section entirely, so refreshRankingAggregate() -- the whole point of the Slice B work merged in #3124 -- never ran. Ranking could then silently expire in the gap. Lower intervalMs to 2h. The bundle runner skip threshold becomes 96min; hourly Railway fires run the section about every 2h. Well within the 12h ranking TTL, and cheap per warm-path run: - computeAndWriteIntervals (~100ms local CPU + one pipeline write) - refreshRankingAggregate -> /api/resilience/v1/get-resilience-ranking?refresh=1 (handler recompute + 2-SET pipeline, ~2-5s) - STRLEN + GET-meta verify in parallel (~200ms) Total ~5-10s per warm-scores run. The expensive 222-country warm still only runs when scores are actually missing. Structural test pins intervalMs <= 2 hours so this doesn't silently regress. Full resilience suite: 378/378.
274 lines
13 KiB
JavaScript
274 lines
13 KiB
JavaScript
import { before, describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import {
|
|
RESILIENCE_RANKING_CACHE_KEY,
|
|
RESILIENCE_RANKING_CACHE_TTL_SECONDS,
|
|
RESILIENCE_SCORE_CACHE_PREFIX,
|
|
RESILIENCE_STATIC_INDEX_KEY,
|
|
computeIntervals,
|
|
} from '../scripts/seed-resilience-scores.mjs';
|
|
|
|
describe('exported constants', () => {
|
|
it('RESILIENCE_RANKING_CACHE_KEY matches server-side key (v9)', () => {
|
|
assert.equal(RESILIENCE_RANKING_CACHE_KEY, 'resilience:ranking:v9');
|
|
});
|
|
|
|
it('RESILIENCE_SCORE_CACHE_PREFIX matches server-side prefix (v9)', () => {
|
|
assert.equal(RESILIENCE_SCORE_CACHE_PREFIX, 'resilience:score:v9:');
|
|
});
|
|
|
|
it('RESILIENCE_RANKING_CACHE_TTL_SECONDS is 12 hours (2x cron interval)', () => {
|
|
// TTL must exceed cron interval (6h) so a missed/slow cron doesn't create
|
|
// an EMPTY_ON_DEMAND gap. Seeder and handler must agree on the TTL.
|
|
assert.equal(RESILIENCE_RANKING_CACHE_TTL_SECONDS, 12 * 60 * 60);
|
|
});
|
|
|
|
it('RESILIENCE_STATIC_INDEX_KEY matches expected key', () => {
|
|
assert.equal(RESILIENCE_STATIC_INDEX_KEY, 'resilience:static:index:v1');
|
|
});
|
|
});
|
|
|
|
describe('seed script does not export tsx/esm helpers', () => {
|
|
it('ensureResilienceScoreCached is not exported', async () => {
|
|
const mod = await import('../scripts/seed-resilience-scores.mjs');
|
|
assert.equal(typeof mod.ensureResilienceScoreCached, 'undefined');
|
|
});
|
|
|
|
it('createMemoizedSeedReader is not exported', async () => {
|
|
const mod = await import('../scripts/seed-resilience-scores.mjs');
|
|
assert.equal(typeof mod.createMemoizedSeedReader, 'undefined');
|
|
});
|
|
|
|
it('buildRankingItem is not exported (ranking write removed)', async () => {
|
|
const mod = await import('../scripts/seed-resilience-scores.mjs');
|
|
assert.equal(typeof mod.buildRankingItem, 'undefined');
|
|
});
|
|
|
|
it('sortRankingItems is not exported (ranking write removed)', async () => {
|
|
const mod = await import('../scripts/seed-resilience-scores.mjs');
|
|
assert.equal(typeof mod.sortRankingItems, 'undefined');
|
|
});
|
|
|
|
it('buildRankingPayload is not exported (ranking write removed)', async () => {
|
|
const mod = await import('../scripts/seed-resilience-scores.mjs');
|
|
assert.equal(typeof mod.buildRankingPayload, 'undefined');
|
|
});
|
|
});
|
|
|
|
describe('computeIntervals', () => {
|
|
it('returns p05 <= p95', () => {
|
|
const domainScores = [65, 70, 55, 80, 60];
|
|
const weights = [0.22, 0.20, 0.15, 0.25, 0.18];
|
|
const result = computeIntervals(domainScores, weights, 200);
|
|
assert.ok(result.p05 <= result.p95, `p05 (${result.p05}) should be <= p95 (${result.p95})`);
|
|
});
|
|
|
|
it('returns values within the domain score range', () => {
|
|
const domainScores = [40, 60, 50, 70, 55];
|
|
const weights = [0.22, 0.20, 0.15, 0.25, 0.18];
|
|
const result = computeIntervals(domainScores, weights, 200);
|
|
assert.ok(result.p05 >= 30, `p05 (${result.p05}) should be >= 30`);
|
|
assert.ok(result.p95 <= 80, `p95 (${result.p95}) should be <= 80`);
|
|
});
|
|
|
|
it('returns identical p05/p95 for uniform domain scores', () => {
|
|
const domainScores = [50, 50, 50, 50, 50];
|
|
const weights = [0.22, 0.20, 0.15, 0.25, 0.18];
|
|
const result = computeIntervals(domainScores, weights, 100);
|
|
assert.equal(result.p05, 50);
|
|
assert.equal(result.p95, 50);
|
|
});
|
|
|
|
it('produces wider interval for more diverse domain scores', () => {
|
|
const uniform = [50, 50, 50, 50, 50];
|
|
const diverse = [20, 90, 30, 80, 40];
|
|
const weights = [0.22, 0.20, 0.15, 0.25, 0.18];
|
|
const uResult = computeIntervals(uniform, weights, 500);
|
|
const dResult = computeIntervals(diverse, weights, 500);
|
|
const uWidth = uResult.p95 - uResult.p05;
|
|
const dWidth = dResult.p95 - dResult.p05;
|
|
assert.ok(dWidth > uWidth, `Diverse width (${dWidth}) should be > uniform width (${uWidth})`);
|
|
});
|
|
});
|
|
|
|
describe('script is self-contained .mjs', () => {
|
|
it('does not import from ../server/', 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-resilience-scores.mjs'), 'utf8');
|
|
assert.equal(src.includes('../server/'), false, 'Must not import from ../server/');
|
|
assert.equal(src.includes('tsx/esm'), false, 'Must not reference tsx/esm');
|
|
});
|
|
|
|
it('all imports are local ./ relative paths', 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-resilience-scores.mjs'), 'utf8');
|
|
const imports = [...src.matchAll(/from\s+['"]([^'"]+)['"]/g)].map((m) => m[1]);
|
|
for (const imp of imports) {
|
|
assert.ok(imp.startsWith('./'), `Import "${imp}" must be a local ./ relative path`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('ensures ranking aggregate is present every cron, with truthful meta', () => {
|
|
// The ranking aggregate has the same 6h TTL as the per-country scores. If we
|
|
// only check + rebuild it inside the missing-scores branch, a cron tick that
|
|
// finds all scores still warm will skip the probe entirely — and the ranking
|
|
// can expire mid-cycle without anyone noticing until the NEXT cold-start
|
|
// cron. The probe + rebuild path must run on every cron, regardless of
|
|
// whether per-country warm was needed. The seed-meta write must be gated on
|
|
// post-rebuild verification so it never claims freshness over a missing key.
|
|
let src;
|
|
before(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));
|
|
src = readFileSync(join(dir, '..', 'scripts', 'seed-resilience-scores.mjs'), 'utf8');
|
|
});
|
|
|
|
it('extracts refreshRankingAggregate helper used by both warm and skip-warm branches', () => {
|
|
assert.match(src, /async function refreshRankingAggregate\b/, 'helper must be defined');
|
|
const calls = [...src.matchAll(/await\s+refreshRankingAggregate\s*\(/g)];
|
|
assert.ok(
|
|
calls.length >= 2,
|
|
`refreshRankingAggregate must be called from both branches (missing>0 and missing===0); found ${calls.length} call sites`,
|
|
);
|
|
});
|
|
|
|
it('always triggers the rebuild HTTP call — never short-circuits on "key still present"', () => {
|
|
// Skipping rebuild when the key exists recreates a timing hole: the key
|
|
// can be alive at probe time but expire a few minutes later, leaving a
|
|
// multi-hour gap until the NEXT cron where the key happens to be gone at
|
|
// probe time. Always rebuilding is one cheap HTTP per cron.
|
|
assert.doesNotMatch(
|
|
src,
|
|
/if\s*\(\s*rankingExists\s*!=\s*null[^)]*\)\s*return\s+true/,
|
|
'refreshRankingAggregate must not early-return when the ranking key is still present',
|
|
);
|
|
// The HTTP rebuild call itself must be unconditional (not gated on a probe).
|
|
assert.match(
|
|
src,
|
|
/async function refreshRankingAggregate[\s\S]*?\/api\/resilience\/v1\/get-resilience-ranking/,
|
|
'rebuild HTTP call must be in the body of refreshRankingAggregate unconditionally',
|
|
);
|
|
});
|
|
|
|
it('verifies the ranking key after the rebuild attempt for observability', () => {
|
|
assert.match(
|
|
src,
|
|
/\/strlen\/\$\{encodeURIComponent\(RESILIENCE_RANKING_CACHE_KEY\)\}/,
|
|
'STRLEN verify after rebuild surfaces when handler skipped the SET (coverage gate or partial pipeline)',
|
|
);
|
|
});
|
|
|
|
it('does NOT DEL the ranking before rebuild — uses ?refresh=1 instead', () => {
|
|
// The old flow (DEL + rebuild HTTP) created a brief absence window: if
|
|
// the rebuild request failed transiently, the ranking stayed absent
|
|
// until the next cron. We now send ?refresh=1 so the handler bypasses
|
|
// its cache-hit early-return and recomputes+SETs atomically. On failure,
|
|
// the existing (possibly stale) ranking remains.
|
|
assert.doesNotMatch(
|
|
src,
|
|
/\['DEL',\s*RESILIENCE_RANKING_CACHE_KEY\]/,
|
|
'seeder must not DEL the ranking key — ?refresh=1 is the atomic replacement path',
|
|
);
|
|
// ALL seeder-initiated calls to get-resilience-ranking must carry
|
|
// ?refresh=1. The bulk-warm path (inside `if (missing > 0)`) also needs
|
|
// it — the ranking TTL (12h) exceeds the score TTL (6h), so in the 6h-12h
|
|
// window the handler would hit its cache and skip the warm entirely,
|
|
// leaving per-country scores absent and coverage degraded.
|
|
const rankingEndpointCalls = [...src.matchAll(/\/api\/resilience\/v1\/get-resilience-ranking(\?[^\s'`"]*)?/g)];
|
|
assert.ok(rankingEndpointCalls.length >= 2, `expected at least 2 ranking-endpoint calls (bulk-warm + refresh), got ${rankingEndpointCalls.length}`);
|
|
for (const [full, query] of rankingEndpointCalls) {
|
|
assert.ok(
|
|
(query || '').includes('refresh=1'),
|
|
`ranking endpoint call must include ?refresh=1 — found: ${full}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
it('seeder does NOT write seed-meta:resilience:ranking (handler is sole writer)', () => {
|
|
// A seeder-written meta can only attest to per-country score count, not
|
|
// to whether the ranking aggregate was actually published. Handler gates
|
|
// its SET on 75% coverage; if the gate trips, an older ranking survives
|
|
// and seeder meta would lie about freshness. Remove the seeder write —
|
|
// handler writes ranking + meta atomically, ensureRankingPresent()
|
|
// triggers the handler every cron so meta stays fresh during quiet Pro
|
|
// usage without the seeder needing to heartbeat.
|
|
assert.doesNotMatch(
|
|
src,
|
|
/writeRankingSeedMeta\s*\(/,
|
|
'seed-resilience-scores.mjs must NOT define or call writeRankingSeedMeta',
|
|
);
|
|
// Assert no SET command targets the meta key — comments that reference
|
|
// the key name are fine and useful for future maintainers.
|
|
assert.doesNotMatch(
|
|
src,
|
|
/\[\s*['"]SET['"]\s*,\s*['"]seed-meta:resilience:ranking['"]/,
|
|
'seeder must not issue SET seed-meta:resilience:ranking (handler is sole writer)',
|
|
);
|
|
});
|
|
});
|
|
|
|
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.
|
|
// The fix is to chunk into smaller pipelines that comfortably fit. Static
|
|
// assertion because behavioral tests can't easily synthesize 222 countries
|
|
// through the full scoring pipeline.
|
|
it('warmMissingResilienceScores splits SETs into batches', 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, '..', 'server', 'worldmonitor', 'resilience', 'v1', '_shared.ts'),
|
|
'utf8',
|
|
);
|
|
assert.match(
|
|
src,
|
|
/const\s+SET_BATCH\s*=\s*\d+/,
|
|
'SET_BATCH constant must be defined',
|
|
);
|
|
assert.match(
|
|
src,
|
|
/for\s*\([^)]*i\s*\+=\s*SET_BATCH/,
|
|
'pipeline SETs must be issued in SET_BATCH-sized chunks',
|
|
);
|
|
});
|
|
});
|