diff --git a/scripts/seed-resilience-intervals.mjs b/scripts/seed-resilience-intervals.mjs index baa376e71..4d7c5b17a 100644 --- a/scripts/seed-resilience-intervals.mjs +++ b/scripts/seed-resilience-intervals.mjs @@ -11,7 +11,11 @@ import { loadEnvFile(import.meta.url); const API_BASE = process.env.API_BASE_URL || 'https://api.worldmonitor.app'; -const WM_KEY = process.env.WORLDMONITOR_API_KEY || ''; +// Reuse WORLDMONITOR_VALID_KEYS when a dedicated WORLDMONITOR_API_KEY isn't set. +// See seed-resilience-scores.mjs for the rationale. +const WM_KEY = process.env.WORLDMONITOR_API_KEY + || (process.env.WORLDMONITOR_VALID_KEYS ?? '').split(',').map((k) => k.trim()).filter(Boolean)[0] + || ''; const SEED_UA = 'Mozilla/5.0 (compatible; WorldMonitor-Seed/1.0)'; const INTERVAL_KEY_PREFIX = 'resilience:intervals:v1:'; diff --git a/scripts/seed-resilience-scores.mjs b/scripts/seed-resilience-scores.mjs index e826e478d..cf15ef24f 100644 --- a/scripts/seed-resilience-scores.mjs +++ b/scripts/seed-resilience-scores.mjs @@ -9,7 +9,13 @@ import { loadEnvFile(import.meta.url); const API_BASE = process.env.API_BASE_URL || 'https://api.worldmonitor.app'; -const WM_KEY = process.env.WORLDMONITOR_API_KEY || ''; +// Reuse WORLDMONITOR_VALID_KEYS when a dedicated WORLDMONITOR_API_KEY isn't set — +// any entry in that comma-separated list is accepted by the API (same +// validation list that server/_shared/premium-check.ts and validateApiKey read). +// Avoids duplicating the same secret under a second env-var name per service. +const WM_KEY = process.env.WORLDMONITOR_API_KEY + || (process.env.WORLDMONITOR_VALID_KEYS ?? '').split(',').map((k) => k.trim()).filter(Boolean)[0] + || ''; const SEED_UA = 'Mozilla/5.0 (compatible; WorldMonitor-Seed/1.0)'; export const RESILIENCE_SCORE_CACHE_PREFIX = 'resilience:score:v9:'; @@ -187,7 +193,7 @@ async function seedResilienceScores() { // Warm laggards individually (countries the bulk ranking timed out on) if (stillMissing.length > 0 && !WM_KEY) { - console.warn(`[resilience-scores] ${stillMissing.length} laggards found but WORLDMONITOR_API_KEY not set — skipping individual warmup`); + console.warn(`[resilience-scores] ${stillMissing.length} laggards found but neither WORLDMONITOR_API_KEY nor WORLDMONITOR_VALID_KEYS is set — skipping individual warmup`); } if (stillMissing.length > 0 && WM_KEY) { console.log(`[resilience-scores] Warming ${stillMissing.length} laggards individually...`); diff --git a/server/worldmonitor/resilience/v1/_shared.ts b/server/worldmonitor/resilience/v1/_shared.ts index 0dbb37b85..de9548d79 100644 --- a/server/worldmonitor/resilience/v1/_shared.ts +++ b/server/worldmonitor/resilience/v1/_shared.ts @@ -411,5 +411,21 @@ export async function warmMissingResilienceScores(countryCodes: string[]): Promi // Share one memoized reader across all countries so global Redis keys (conflict events, // sanctions, unrest, etc.) are fetched only once instead of once per country. const sharedReader = createMemoizedSeedReader(); - await Promise.allSettled(uniqueCodes.map((countryCode) => ensureResilienceScoreCached(countryCode, sharedReader))); + const results = await Promise.allSettled( + uniqueCodes.map((countryCode) => ensureResilienceScoreCached(countryCode, sharedReader)), + ); + const failures: Array<{ countryCode: string; reason: string }> = []; + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result?.status === 'rejected') { + failures.push({ + countryCode: uniqueCodes[i]!, + reason: result.reason instanceof Error ? result.reason.message : String(result.reason), + }); + } + } + if (failures.length > 0) { + const sample = failures.slice(0, 10).map((f) => `${f.countryCode}(${f.reason})`).join(', '); + console.warn(`[resilience] warm failed for ${failures.length}/${uniqueCodes.length} countries: ${sample}${failures.length > 10 ? '...' : ''}`); + } } diff --git a/server/worldmonitor/resilience/v1/get-resilience-ranking.ts b/server/worldmonitor/resilience/v1/get-resilience-ranking.ts index 98644fef1..32525d584 100644 --- a/server/worldmonitor/resilience/v1/get-resilience-ranking.ts +++ b/server/worldmonitor/resilience/v1/get-resilience-ranking.ts @@ -31,6 +31,12 @@ const RESILIENCE_RANKING_META_TTL_SECONDS = 7 * 24 * 60 * 60; // 200 covers the full static index (~130-180 countries) in a single cold-cache pass. const SYNC_WARM_LIMIT = 200; +// Minimum fraction of scorable countries that must have a cached score before we +// persist the ranking to Redis. Prevents a cold-start (0% cached) from being +// locked in, while still allowing partial-state writes (e.g. 90%) to succeed so +// the next call doesn't re-warm everything. +const RANKING_CACHE_MIN_COVERAGE = 0.75; + async function fetchIntervals(countryCodes: string[]): Promise> { if (countryCodes.length === 0) return new Map(); const results = await runRedisPipeline(countryCodes.map((cc) => ['GET', `${RESILIENCE_INTERVAL_KEY_PREFIX}${cc}`]), true); @@ -76,12 +82,24 @@ export const getResilienceRanking: ResilienceServiceHandler['getResilienceRankin greyedOut: allItems.filter((item) => item.overallCoverage < GREY_OUT_COVERAGE_THRESHOLD), }; - const stillMissing = countryCodes.filter((countryCode) => !cachedScores.has(countryCode)); - if (stillMissing.length === 0) { + // Cache the ranking when we have substantive coverage — don't hold out for 100%. + // The previous gate (stillMissing === 0) meant a single failing-to-warm country + // permanently blocked the write, leaving the cache null for days while the 6h TTL + // expired between cron ticks. Countries that fail to warm already land in + // `greyedOut` with coverage 0, so the response is correct for partial states. + const coverageRatio = cachedScores.size / countryCodes.length; + if (coverageRatio >= RANKING_CACHE_MIN_COVERAGE) { await runRedisPipeline([ ['SET', RESILIENCE_RANKING_CACHE_KEY, JSON.stringify(response), 'EX', RESILIENCE_RANKING_CACHE_TTL_SECONDS], - ['SET', RESILIENCE_RANKING_META_KEY, JSON.stringify({ fetchedAt: Date.now(), count: response.items.length + response.greyedOut.length }), 'EX', RESILIENCE_RANKING_META_TTL_SECONDS], + ['SET', RESILIENCE_RANKING_META_KEY, JSON.stringify({ + fetchedAt: Date.now(), + count: response.items.length + response.greyedOut.length, + scored: cachedScores.size, + total: countryCodes.length, + }), 'EX', RESILIENCE_RANKING_META_TTL_SECONDS], ]); + } else { + console.warn(`[resilience] ranking not cached — coverage ${cachedScores.size}/${countryCodes.length} below ${RANKING_CACHE_MIN_COVERAGE * 100}% threshold`); } return response; diff --git a/tests/resilience-ranking.test.mts b/tests/resilience-ranking.test.mts index 68e114086..3953df028 100644 --- a/tests/resilience-ranking.test.mts +++ b/tests/resilience-ranking.test.mts @@ -130,6 +130,36 @@ describe('resilience ranking contracts', () => { assert.equal(us?.rankStable, false, 'US interval width 22 should be unstable'); }); + it('caches the ranking when partial coverage meets the 75% threshold (4 countries, 3 scored)', async () => { + const { redis } = installRedis(RESILIENCE_FIXTURES); + // Override the static index so we have an un-scoreable extra country (ZZ has + // no fixture → warm will throw and ZZ stays missing). + redis.set('resilience:static:index:v1', JSON.stringify({ + countries: ['NO', 'US', 'YE', 'ZZ'], + recordCount: 4, + failedDatasets: [], + seedYear: 2025, + })); + const domainWithCoverage = [{ id: 'political', score: 80, weight: 0.2, dimensions: [{ id: 'd1', score: 80, coverage: 0.9, observedWeight: 1, imputedWeight: 0 }] }]; + redis.set('resilience:score:v9:NO', JSON.stringify({ + countryCode: 'NO', overallScore: 82, level: 'high', + domains: domainWithCoverage, trend: 'stable', change30d: 1.2, + lowConfidence: false, imputationShare: 0.05, + })); + redis.set('resilience:score:v9:US', JSON.stringify({ + countryCode: 'US', overallScore: 61, level: 'medium', + domains: domainWithCoverage, trend: 'rising', change30d: 4.3, + lowConfidence: false, imputationShare: 0.1, + })); + + await getResilienceRanking({ request: new Request('https://example.com') } as never, {}); + + // 3 of 4 (NO + US pre-cached, YE warmed from fixtures, ZZ can't be warmed) + // = 75% which meets the threshold — must cache. + assert.ok(redis.has('resilience:ranking:v9'), 'ranking must be cached at exactly 75% coverage'); + assert.ok(redis.has('seed-meta:resilience:ranking'), 'seed-meta must be written alongside the ranking'); + }); + it('defaults rankStable=false when no interval data exists', () => { const item = buildRankingItem('ZZ', { countryCode: 'ZZ', overallScore: 50, level: 'medium',