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);
|
||||
|
||||
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:';
|
||||
|
||||
@@ -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...`);
|
||||
|
||||
@@ -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 ? '...' : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Map<string, ScoreInterval>> {
|
||||
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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user