mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(resilience): relax ranking cache gate, reuse WORLDMONITOR_VALID_KEYS (#3045)
This commit is contained in:
@@ -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:';
|
||||||
|
|||||||
@@ -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...`);
|
||||||
|
|||||||
@@ -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 ? '...' : ''}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user