fix(resilience): score all countries on first ranking request#2730
fix(resilience): score all countries on first ranking request#2730
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Greptile SummaryFixes the Resilience choropleth showing only ~20–25 countries on a cold cache by removing the Confidence Score: 5/5Safe to merge — the fix is targeted and correct with no P0/P1 issues found The bug (.slice(0, 24)) is unambiguous, the fix directly addresses the root cause, RESILIENCE_WARM_LIMIT has zero other usages in the repo (confirmed by full-repo grep), and the shared memoized reader is race-free given JS's single-threaded event loop. The only finding is a harmless P2 double-memoization pattern that does not affect correctness. No files require special attention Important Files Changed
Sequence DiagramsequenceDiagram
participant Handler as getResilienceRanking
participant Warm as warmMissingResilienceScores
participant Cache as ensureResilienceScoreCached
participant Score as scoreAllDimensions
participant Redis as Redis
Handler->>Warm: warmMissingResilienceScores(missingCodes)
Warm->>Warm: sharedReader = createMemoizedSeedReader()
par For each missing country (all ~194)
Warm->>Cache: ensureResilienceScoreCached(code, sharedReader)
Cache->>Score: scoreAllDimensions(code, sharedReader)
Score->>Redis: sharedReader("conflict:ucdp-events:v1") [1st country — FETCHES]
Redis-->>Score: event data (cached in sharedReader)
Score->>Redis: sharedReader("sanctions:pressure:v1") [1st country — FETCHES]
Redis-->>Score: sanctions data (cached in sharedReader)
Note over Score,Redis: 2nd..Nth country calls return cached promise — no extra Redis round-trips
Score-->>Cache: dimension scores
Cache->>Redis: SET resilience:score:XX (TTL 6h)
end
Warm-->>Handler: all settled (Promise.allSettled)
Reviews (1): Last reviewed commit: "fix(resilience): score all countries on ..." | Re-trigger Greptile |
| // 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))); |
There was a problem hiding this comment.
Double-memoization in
scoreAllDimensions
sharedReader is already memoized (created by createMemoizedSeedReader()), but scoreAllDimensions in _dimension-scorers.ts unconditionally wraps it in another createMemoizedSeedReader(reader) call, producing a redundant per-country layer. The cross-country deduplication still works correctly — the outer sharedReader is what prevents repeated Redis fetches — but a new Map and closure are allocated for every country on each cold-cache ranking request. Worth cleaning up in a follow-up.
7b65517 to
0a95655
Compare
0a95655 to
f793740
Compare
f793740 to
fd1d690
Compare
fd1d690 to
43ab375
Compare
RESILIENCE_WARM_LIMIT=24 capped warmMissingResilienceScores so only the first 24 countries were scored per ranking request. Countries beyond that received overallScore=-1, which buildResilienceChoroplethMap filters out, leaving ~170 countries invisible on the choropleth layer. Changes: - warmMissingResilienceScores: remove the slice cap; introduce a single shared createMemoizedSeedReader() so global Redis keys (conflict events, sanctions, unrest, etc.) are fetched once across all country scorers instead of once per country. The function is now an unbounded, efficient utility safe to call from cron jobs. - get-resilience-ranking: introduce SYNC_WARM_LIMIT=200 to document the request-path budget. The shared reader means the actual Upstash burst is 17 shared reads + N×3 per-country reads + N pipeline writes; wall time is bounded by ~2-3 sequential RTTs (~60-150 ms) regardless of N because all countries score in parallel. 200 covers the full static index in one pass. - seed-resilience-scores.mts: new Railway cron seeder (every 5h) that pre-warms all country scores and writes resilience:ranking so the layer is always an instant cache hit for users. Uses tsx to import the existing TypeScript scoring logic directly (no duplication). tsx added to scripts/package.json. Service registered in railway-set-watch-paths.mjs.
Replaces the tsx-based .mts seeder with a plain .mjs script that reads
resilience:score:{iso2} keys from Redis directly via HTTP pipeline — same
pattern as every other seed service. Replicates the trivial buildRankingItem
and sortRankingItems logic inline; no TypeScript imports needed.
Removes tsx from scripts/package.json. Fixes railway-set-watch-paths.mjs
to use node seed-resilience-scores.mjs (not npx tsx).
02374e5 to
1692b5a
Compare
Tests cover buildRankingItem (null/undefined → sentinel -1, real score → correct shape), sortRankingItems (descending order, tie-breaking, sentinel placement, no mutation), buildRankingPayload (all-scored path, partial-miss skip guard, empty inputs), and exported constants matching the server-side values. Also adds isMain guard so main() does not fire during test imports, and exports buildRankingItem/sortRankingItems/buildRankingPayload as testable pure functions.
Summary
RESILIENCE_WARM_LIMIT = 24cappedwarmMissingResilienceScoresso only 24 countries were scored per ranking requestoverallScore >= 0buildResilienceChoroplethMapfiltersoverallScore < 0, so ~170 countries were invisible on the Resilience choropleth layercreateMemoizedSeedReader()passed to all country scorers so global Redis keys (conflict:ucdp-events:v1,sanctions:pressure:v1, etc.) are fetched once per ranking request instead of once per countryTest plan
typecheck+typecheck:api)resilience-handlers,resilience-ranking,resilience-scorers)