Skip to content

fix(resilience): score all countries on first ranking request#2730

Merged
koala73 merged 3 commits intomainfrom
fix/resilience-choropleth-warm-limit
Apr 5, 2026
Merged

fix(resilience): score all countries on first ranking request#2730
koala73 merged 3 commits intomainfrom
fix/resilience-choropleth-warm-limit

Conversation

@koala73
Copy link
Copy Markdown
Owner

@koala73 koala73 commented Apr 5, 2026

Summary

  • RESILIENCE_WARM_LIMIT = 24 capped warmMissingResilienceScores so only 24 countries were scored per ranking request
  • On a cold cache (layer just enabled), the ranking API returned 100+ countries but only the first 24 had overallScore >= 0
  • buildResilienceChoroplethMap filters overallScore < 0, so ~170 countries were invisible on the Resilience choropleth layer
  • Fix: remove the cap; introduce a single shared createMemoizedSeedReader() 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 country

Test plan

  • TypeScript typechecks pass (typecheck + typecheck:api)
  • Biome lint passes
  • All 3047 unit/data tests pass (includes resilience-handlers, resilience-ranking, resilience-scorers)
  • Edge function bundle check passes
  • Edge function isolation tests pass (153 tests)
  • Markdown lint passes
  • Version sync check passes
  • Verify resilience choropleth layer shows all ~150+ indexed countries instead of ~20-25

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
worldmonitor Ignored Ignored Preview Apr 5, 2026 3:38pm

Request Review

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 5, 2026

Greptile Summary

Fixes the Resilience choropleth showing only ~20–25 countries on a cold cache by removing the RESILIENCE_WARM_LIMIT = 24 cap from warmMissingResilienceScores. A shared createMemoizedSeedReader() is introduced and threaded through all country scorers so global Redis keys (conflict events, sanctions, unrest, etc.) are fetched once per ranking request regardless of how many countries need warming.

Confidence Score: 5/5

Safe 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

Filename Overview
server/worldmonitor/resilience/v1/_shared.ts Removes the 24-country cap in warmMissingResilienceScores and adds a shared memoized seed reader to deduplicate global Redis key fetches across all country scorers

Sequence Diagram

sequenceDiagram
    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)
Loading

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)));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

koala73 added 2 commits April 5, 2026 19:34
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).
@koala73 koala73 force-pushed the fix/resilience-choropleth-warm-limit branch from 02374e5 to 1692b5a Compare April 5, 2026 15:35
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.
@koala73 koala73 merged commit aadda3d into main Apr 5, 2026
10 checks passed
@koala73 koala73 deleted the fix/resilience-choropleth-warm-limit branch April 5, 2026 15:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant