fix(resilience): relax ranking cache gate, reuse WORLDMONITOR_VALID_KEYS (#3045)

This commit is contained in:
Elie Habib
2026-04-13 09:21:13 +04:00
committed by GitHub
parent 204fb02737
commit 9b94bbc625
5 changed files with 81 additions and 7 deletions

View File

@@ -11,7 +11,11 @@ import {
loadEnvFile(import.meta.url); loadEnvFile(import.meta.url);
const API_BASE = process.env.API_BASE_URL || 'https://api.worldmonitor.app'; 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 SEED_UA = 'Mozilla/5.0 (compatible; WorldMonitor-Seed/1.0)';
const INTERVAL_KEY_PREFIX = 'resilience:intervals:v1:'; const INTERVAL_KEY_PREFIX = 'resilience:intervals:v1:';

View File

@@ -9,7 +9,13 @@ import {
loadEnvFile(import.meta.url); loadEnvFile(import.meta.url);
const API_BASE = process.env.API_BASE_URL || 'https://api.worldmonitor.app'; 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)'; const SEED_UA = 'Mozilla/5.0 (compatible; WorldMonitor-Seed/1.0)';
export const RESILIENCE_SCORE_CACHE_PREFIX = 'resilience:score:v9:'; 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) // Warm laggards individually (countries the bulk ranking timed out on)
if (stillMissing.length > 0 && !WM_KEY) { 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) { if (stillMissing.length > 0 && WM_KEY) {
console.log(`[resilience-scores] Warming ${stillMissing.length} laggards individually...`); console.log(`[resilience-scores] Warming ${stillMissing.length} laggards individually...`);

View File

@@ -411,5 +411,21 @@ export async function warmMissingResilienceScores(countryCodes: string[]): Promi
// Share one memoized reader across all countries so global Redis keys (conflict events, // 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. // sanctions, unrest, etc.) are fetched only once instead of once per country.
const sharedReader = createMemoizedSeedReader(); 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 ? '...' : ''}`);
}
} }

View File

@@ -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. // 200 covers the full static index (~130-180 countries) in a single cold-cache pass.
const SYNC_WARM_LIMIT = 200; 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<Map<string, ScoreInterval>> { async function fetchIntervals(countryCodes: string[]): Promise<Map<string, ScoreInterval>> {
if (countryCodes.length === 0) return new Map(); if (countryCodes.length === 0) return new Map();
const results = await runRedisPipeline(countryCodes.map((cc) => ['GET', `${RESILIENCE_INTERVAL_KEY_PREFIX}${cc}`]), true); 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), greyedOut: allItems.filter((item) => item.overallCoverage < GREY_OUT_COVERAGE_THRESHOLD),
}; };
const stillMissing = countryCodes.filter((countryCode) => !cachedScores.has(countryCode)); // Cache the ranking when we have substantive coverage — don't hold out for 100%.
if (stillMissing.length === 0) { // 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([ await runRedisPipeline([
['SET', RESILIENCE_RANKING_CACHE_KEY, JSON.stringify(response), 'EX', RESILIENCE_RANKING_CACHE_TTL_SECONDS], ['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; return response;

View File

@@ -130,6 +130,36 @@ describe('resilience ranking contracts', () => {
assert.equal(us?.rankStable, false, 'US interval width 22 should be unstable'); 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', () => { it('defaults rankStable=false when no interval data exists', () => {
const item = buildRankingItem('ZZ', { const item = buildRankingItem('ZZ', {
countryCode: 'ZZ', overallScore: 50, level: 'medium', countryCode: 'ZZ', overallScore: 50, level: 'medium',