Skip to content

Lazy-materialize stats-page filter combos into stats_summary#385

Merged
ptrlrd merged 1 commit into
mainfrom
perf/stats-lazy-materialize
Jun 1, 2026
Merged

Lazy-materialize stats-page filter combos into stats_summary#385
ptrlrd merged 1 commit into
mainfrom
perf/stats-lazy-materialize

Conversation

@ptrlrd
Copy link
Copy Markdown
Owner

@ptrlrd ptrlrd commented May 30, 2026

Symptom

Users picking filters on /leaderboards/stats see a 5-10s spinner on practically every click. The same combo on the next click might be instant or might be slow again, seemingly at random.

Cause

/api/runs/stats has a 3-tier read path:

  1. stats_summary (materialized, sub-ms) -- only covers HOT_FILTER_COMBOS = [{}, per-character].
  2. Process-local TTL cache (300s) -- per worker. Prod runs --workers 4, so the hit rate on any clicked-through combo is at best 1-in-4.
  3. Live aggregation in get_stats() -- multiple pipelines including a $unwind over card_choices. 5-10s per call on the current runs size.

So any combo with win, ascension, players, or username falls past step 1, and step 2 is poor cluster-wide because the cache lives in process memory. Result: clicking filters keeps paying the 5-10s tax.

Fix

After the live aggregation runs in get_community_stats, write the result back into stats_summary with the same (_filter_key) shape the refresher uses. Now every worker (and every future request) reads the same materialized doc.

  • First request for a combo: still pays the 5-10s (one user, somewhere). Inevitable for a never-seen combo.
  • Every subsequent request for that combo, cluster-wide: single find_one, ~ms.
  • HOT combos continue to be refreshed every 60s by the existing leader-elected refresher loop, which overwrites any lazy entries with fresh aggregates.
  • Non-HOT combos persist in stats_summary and get overwritten the next time the same combo is queried. If the collection grows uncomfortably (it's small per doc), follow-up: add a TTL index on updated_at.

Files

  • backend/app/services/runs_db_mongo.py -- new public write_stats_summary() helper.
  • backend/app/routers/runs.py -- get_community_stats calls it after the live path.

No data migration; no schema change; falls through to existing behavior on any failure.

The stats page lets users pick character + win + ascension + players
filters, but HOT_FILTER_COMBOS only materializes {} + per-character.
Any other combo falls through to the live aggregation, which runs
several pipelines (totals / ascension / deaths / pick_rates with
$unwind / etc.) and takes 5-10s.

The process-local TTL fallback cache is per worker, so with 4 workers
the cache hit rate on a clicked-through filter sequence is poor and
users see a 5-10s spinner on practically every filter change. That's
what was being reported as "stats page settings breaking."

Add a write-through step: after a live aggregation runs in the route,
write the result back into stats_summary keyed by the same filter
tuple. Subsequent requests for the same combo -- on any worker, ever --
read from stats_summary in a single find_one (~ms).

- First request for any filter combo: still ~5-10s (one user pays).
- Every subsequent request for that combo, cluster-wide: sub-ms.
- HOT combos keep being refreshed every 60s by the existing refresher
  loop and overwrite any lazy entries with fresh aggregates.
- Non-HOT combos persist in stats_summary; they'll get overwritten the
  next time someone queries them with new data. If unbounded growth
  becomes a concern later, add a TTL index on updated_at.
@ptrlrd ptrlrd force-pushed the perf/stats-lazy-materialize branch from 84b2122 to 384a7be Compare June 1, 2026 03:34
@ptrlrd ptrlrd merged commit 758cc5b into main Jun 1, 2026
6 checks passed
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