# Ranking Snapshot Read Model Plan ## Objective Define a snapshot-backed read model for weekly and monthly public ranking so `GET /api/ranking` does not depend on runtime aggregation over materialized RCON match stats on every request. The design follows the same philosophy as the current annual ranking snapshot path: - generate outside the public request path - serve stable snapshot rows through a small read model - expose clear metadata for `ready`, `missing` and controlled fallback states ## Scope Impacted public endpoint: - `GET /api/ranking?timeframe=weekly|monthly|annual&server_id=&metric=&limit=&year=` Primary target of this plan: - weekly ranking snapshots - monthly ranking snapshots Compatibility target: - the model must also represent annual snapshots so the repository can converge on one ranking snapshot vocabulary over time - `TASK-191` should keep annual requests on the existing annual snapshot path until migration is explicitly implemented ## Why This Is Needed `TASK-188` showed: - weekly/monthly ranking still performs repeated window counting and grouped aggregation at request time - stats/ranking runtime reads depend on `rcon_materialized_matches` and `rcon_match_player_stats` - the empty June 2026 window hides the structural cost, but the query plans still show scans and temp B-trees `TASK-189` reduced some scan risk with indexes, but it did not remove the core public-request recomputation pattern. ## Proposed Tables ### `ranking_snapshots` Purpose: - one snapshot header per `(timeframe, server_id, metric, window_start, window_end)` Proposed fields: - `id` - `timeframe` - allowed values: `weekly`, `monthly`, `annual` - `server_id` - `all-servers`, `comunidad-hispana-01`, `comunidad-hispana-02` - `metric` - V1.1 weekly/monthly: `kills`, `deaths`, `teamkills`, `matches_considered`, `kd_ratio`, `kills_per_match` - annual: keep `kills` as the required supported metric until annual expansion is explicitly implemented - `window_start` - `window_end` - `generated_at` - `source` - expected current value: `rcon-materialized-admin-log` - `snapshot_status` - expected values: `ready`, `building`, `failed` - `item_count` - `limit_size` - `source_matches_count` - `freshness` - example values: `fresh`, `stale` - `error_message` - nullable, operational only Key rules: - unique key on `(timeframe, server_id, metric, window_start, window_end)` - keep only one `ready` snapshot per exact window and metric scope - `item_count` reflects stored rows, not the request limit - `limit_size` records how many positions were generated, for example top 20 ### `ranking_snapshot_items` Purpose: - ordered player rows for one ranking snapshot Proposed fields: - `id` - `snapshot_id` - `ranking_position` - `player_id` - `player_name` - `metric_value` - `matches_considered` - `kills` - `deaths` - `teamkills` - `kd_ratio` - `kills_per_match` Key rules: - unique key on `(snapshot_id, ranking_position)` - unique key on `(snapshot_id, player_id)` - all item rows must carry the common display totals even when the requested metric is different ## Snapshot Window Rules ### Weekly Window logic: - preserve the current weekly selection policy already exposed by `select_leaderboard_window(...)` - snapshot window is whichever week the public runtime policy would have served: - `current-week` when the current week has sufficient closed matches - otherwise `previous-week` Stored metadata: - `timeframe=weekly` - `window_start` - `window_end` - `snapshot_status` - `generated_at` - `source='rcon-materialized-admin-log'` ### Monthly Window logic: - preserve the current monthly selection policy already exposed by `select_leaderboard_window(...)` - snapshot window is: - `previous-month` until day 7 inclusive - `current-month` after day 7 Stored metadata: - `timeframe=monthly` - `window_start` - `window_end` - `snapshot_status` - `generated_at` - `source='rcon-materialized-admin-log'` ### Annual Window logic: - `year-01-01T00:00:00Z` to `(year+1)-01-01T00:00:00Z` Transition note: - the new table design supports annual snapshots - `TASK-191` should keep annual requests on the existing `rcon_annual_ranking_snapshots` path until a dedicated migration task consolidates storage ## Refresh Policy Current windows: - weekly current: refresh every `5` to `15` minutes - monthly current: refresh every `15` to `30` minutes - annual current: manual or daily Closed windows: - previous week: treated as closed and stable - previous month: treated as closed and stable - annual closed windows: treated as stable after generation Operational rules: - generation happens outside the public request path - one refresh job should upsert the header row and replace the corresponding item set atomically - if refresh fails, keep the last `ready` snapshot until a new `ready` snapshot is produced ## Public Fallback Policy Public read priority: 1. serve snapshot when a matching `ready` snapshot exists 2. if no matching snapshot exists: - return controlled `snapshot_status='missing'` - or use runtime fallback only when configuration explicitly enables it 3. never recalculate by default on every public request Required policy fields in API response: - `source` - `snapshot_status` - `generated_at` - `freshness` - `fallback_used` - `window_start` - `window_end` Expected meanings: - `source='ranking-snapshot'` when serving stored weekly/monthly snapshot rows - `source='rcon-materialized-runtime-fallback'` only when configuration allows fallback and snapshot is missing - `snapshot_status='ready'` when snapshot rows were served - `snapshot_status='missing'` when no snapshot exists for the requested window and runtime fallback is disabled ## Expected API Metadata Weekly/monthly responses should expose: - `page_kind` - `timeframe` - `server_id` - `metric` - `limit` - `requested_limit` - `window_start` - `window_end` - `window_kind` - `window_label` - `snapshot_status` - `generated_at` - `freshness` - `fallback_used` - `source` - `items` Snapshot item contract: - `ranking_position` - `player_id` - `player_name` - `metric_value` - `matches_considered` - `kills` - `deaths` - `teamkills` - `kd_ratio` - `kills_per_match` ## Generation Source Authoritative source for weekly/monthly/annual ranking snapshots: - `rcon_materialized_matches` - `rcon_match_player_stats` Source filter: - `matches.source_basis = 'admin-log-match-ended'` Generation logic: - reuse the same metric formulas and tie-break ordering already documented for `Ranking` - do not use public scoreboard as the primary ranking source - do not reintroduce Comunidad Hispana #03 ## Transition Notes For TASK-191 Implementation order: 1. add snapshot lookup helpers for weekly/monthly 2. make `/api/ranking` weekly/monthly try snapshot lookup first 3. keep annual on the current annual snapshot loader 4. add controlled runtime fallback behind configuration 5. return explicit metadata for `ready`, `missing` and fallback cases Expected runtime behavior in `TASK-191`: - weekly/monthly: - snapshot first - runtime fallback only when snapshot is missing and fallback is enabled - controlled missing when snapshot is missing and fallback is disabled - annual: - keep current snapshot behavior unchanged Operational note after `TASK-191`: - snapshot tables are initialized automatically on first ranking access - snapshot rows are not generated automatically yet - runtime fallback remains enabled by default for transition through `HLL_BACKEND_RANKING_RUNTIME_FALLBACK_ENABLED=true` - operators can force controlled missing behavior by setting `HLL_BACKEND_RANKING_RUNTIME_FALLBACK_ENABLED=false` ## Manual Generation Workflow Manual generator entrypoint: ```bash python -m app.rcon_historical_leaderboards generate-ranking-snapshot --timeframe weekly --server-key all --metric kills --limit 20 ``` Bulk refresh entrypoint: ```bash python -m app.rcon_historical_leaderboards refresh-ranking-snapshots --limit 30 ``` Operational default: - when `HLL_BACKEND_DATABASE_URL` is configured, the CLI uses PostgreSQL by default - SQLite is no longer the default operational target for snapshot generation - local SQLite generation remains available only through an explicit override such as: ```bash python -m app.rcon_historical_leaderboards generate-ranking-snapshot --timeframe weekly --server-key all --metric kills --limit 20 --sqlite-path backend/data/hll_vietnam_dev.sqlite3 ``` Docker form: ```bash docker compose exec backend python -m app.rcon_historical_leaderboards generate-ranking-snapshot --timeframe weekly --server-key all --metric kills --limit 20 ``` Bulk Docker form: ```bash docker compose exec backend python -m app.rcon_historical_leaderboards refresh-ranking-snapshots --limit 30 ``` Operational expectation: - the recommended Docker command should generate weekly/monthly snapshots in PostgreSQL, matching the `/api/ranking` production read path Supported manual parameters: - `timeframe`: `weekly`, `monthly` - `server-key`: `all`, `all-servers`, `comunidad-hispana-01`, `comunidad-hispana-02` - `metric`: `kills`, `deaths`, `teamkills`, `matches_considered`, `kd_ratio`, `kills_per_match` - `limit`: positive integer, normally `20` Current implementation note: - `generate-ranking-snapshot` remains unitary per command invocation for explicit manual control - `refresh-ranking-snapshots --limit 30` generates the full weekly/monthly public matrix in one run - the periodic historical runner in `backend/app/historical_runner.py` should invoke that bulk refresh as part of the normal backend refresh cycle - per-combination failures should be reported without aborting the entire matrix refresh ## Recommended Combinations Minimum production matrix for parity with the public Ranking filters: - weekly + `all-servers` + all six supported metrics - weekly + `comunidad-hispana-01` + all six supported metrics - weekly + `comunidad-hispana-02` + all six supported metrics - monthly + `all-servers` + all six supported metrics - monthly + `comunidad-hispana-01` + all six supported metrics - monthly + `comunidad-hispana-02` + all six supported metrics That matrix requires `36` snapshot generations for each refresh cycle. Operational default limit: - generate each combination with `limit=30` - that stored `limit_size=30` covers the current UI request limits `10`, `20` and `30` ## Suggested Frequency Suggested operator cadence: - weekly current window: regenerate every `5` to `15` minutes while the active week is changing - monthly current window: regenerate every `15` to `30` minutes while the active month is changing - previous week and previous month windows: regenerate once after closure or after any historical backfill that changes source coverage Operational guidance: - regenerate after materialized RCON/AdminLog data grows - regenerate after manual backfill - regenerate after metric SQL changes that affect ranking totals or ordering - when using the periodic backend runner, keep ranking refresh attached to the same recurring cycle rather than a separate scheduler unless operational load proves otherwise ## Ready Vs Fallback Expected API result after successful generation for an exact requested combination: - `snapshot_status=ready` - `fallback_used=false` - `source.read_model=ranking-snapshot` Expected API result when the requested combination has not been generated yet and fallback remains enabled: - `snapshot_status=missing` - `fallback_used=true` - `freshness=runtime` Expected API result when the requested combination has not been generated and fallback is disabled: - `snapshot_status=missing` - `fallback_used=false` - `items=[]` ## Out Of Scope - backend implementation of the snapshot generator - migrations - frontend changes - annual storage migration from the legacy annual snapshot tables - Elo/MMR - public scoreboard as ranking primary source