diff --git a/ai/tasks/pending/TASK-188-audit-ranking-and-stats-query-performance.md b/ai/tasks/done/TASK-188-audit-ranking-and-stats-query-performance.md similarity index 70% rename from ai/tasks/pending/TASK-188-audit-ranking-and-stats-query-performance.md rename to ai/tasks/done/TASK-188-audit-ranking-and-stats-query-performance.md index 9330b06..88fa550 100644 --- a/ai/tasks/pending/TASK-188-audit-ranking-and-stats-query-performance.md +++ b/ai/tasks/done/TASK-188-audit-ranking-and-stats-query-performance.md @@ -1,7 +1,7 @@ --- id: TASK-188-audit-ranking-and-stats-query-performance title: Audit ranking and stats query performance -status: pending +status: done type: research team: Arquitecto de Base de Datos supporting_teams: @@ -85,12 +85,21 @@ Before completing the task ensure: ## Outcome -Document: - -- measured baseline results -- environment limitations -- the most likely root cause of slow public reads -- the follow-up recommendation that should feed `TASK-189` and `TASK-190` +- Audit completed in `docs/ranking-stats-performance-audit.md`. +- Measured baseline captured for the six required endpoint probes. +- Environment limitation documented: backend HTTP server was not running locally, so request timing used in-process route resolution and SQL tracing over SQLite. +- Current runtime weekly/monthly ranking windows on `2026-06-09` are empty because the latest materialized `admin-log-match-ended` data ends on `2026-05-20T23:21:45.816Z`. +- Slowest measured endpoint is `/api/stats/players/{player_id}` because it issues 16 SQL statements, including repeated window counts and two ranking-position subqueries. +- Primary future performance risks identified: + - full scans on `rcon_materialized_matches` for `source_basis + time-window` filters + - a full scan on `rcon_match_player_stats` for player-detail-by-`player_id` + - temp B-trees for grouping, `COUNT(DISTINCT)` and ordering in leaderboard/search queries +- Follow-up recommendation for `TASK-189`: + - add time-window indexes on `rcon_materialized_matches` + - add a direct `player_id` index on `rcon_match_player_stats` + - keep annual snapshot indexes as-is +- Follow-up recommendation for `TASK-190`: + - move weekly/monthly public ranking to snapshot-backed reads with controlled runtime fallback ## Change Budget diff --git a/ai/tasks/pending/TASK-189-add-ranking-materialized-read-indexes.md b/ai/tasks/done/TASK-189-add-ranking-materialized-read-indexes.md similarity index 66% rename from ai/tasks/pending/TASK-189-add-ranking-materialized-read-indexes.md rename to ai/tasks/done/TASK-189-add-ranking-materialized-read-indexes.md index da34a74..d3e12f5 100644 --- a/ai/tasks/pending/TASK-189-add-ranking-materialized-read-indexes.md +++ b/ai/tasks/done/TASK-189-add-ranking-materialized-read-indexes.md @@ -1,7 +1,7 @@ --- id: TASK-189-add-ranking-materialized-read-indexes title: Add ranking materialized read indexes -status: pending +status: done type: backend team: Arquitecto de Base de Datos supporting_teams: @@ -80,12 +80,24 @@ Before completing the task ensure: ## Outcome -Document: - -- indexes added -- why each index was chosen -- observed improvement or inability to measure it -- residual performance gaps that still require snapshot-based reads +- Added SQLite/PostgreSQL-compatible indexes in the materialized storage initialization path: + - `idx_rcon_materialized_matches_source_window_text` + - `idx_rcon_materialized_matches_target_source_window_text` + - `idx_rcon_materialized_matches_external_source_window_text` + - `idx_rcon_match_player_stats_player_id_match` +- Kept existing annual snapshot indexes unchanged because the annual read path was already using matching indexes. +- Did not add a plain `player_name` B-tree because the current search query uses `LOWER(player_name) LIKE '%term%'` with a leading wildcard, so the audit did not justify it as a useful narrow index. +- Post-index validation confirmed plan improvement: + - weekly count queries now use the new `source_basis + window` covering index + - the stats player-detail aggregate now narrows through the match window index and then probes stats by `(target_key, match_key, player_id)` +- Before/after timing was captured for representative endpoints: + - `/api/stats/players/{player_id}` weekly improved from `8.924 ms / 4.494 ms SQL` to `4.300 ms / 1.043 ms SQL` + - `/api/ranking` weekly `kills` showed no meaningful change in this dataset because the active weekly window on `2026-06-09` is empty +- Validation scripts executed successfully: + - `powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1` + - `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- Remaining gap: + - weekly/monthly public ranking still does repeated runtime counting and grouped aggregation per request, so snapshot-backed reads remain necessary in `TASK-190` and `TASK-191` ## Change Budget diff --git a/ai/tasks/pending/TASK-190-design-weekly-monthly-ranking-snapshots.md b/ai/tasks/done/TASK-190-design-weekly-monthly-ranking-snapshots.md similarity index 76% rename from ai/tasks/pending/TASK-190-design-weekly-monthly-ranking-snapshots.md rename to ai/tasks/done/TASK-190-design-weekly-monthly-ranking-snapshots.md index 43693ec..d6b3230 100644 --- a/ai/tasks/pending/TASK-190-design-weekly-monthly-ranking-snapshots.md +++ b/ai/tasks/done/TASK-190-design-weekly-monthly-ranking-snapshots.md @@ -1,7 +1,7 @@ --- id: TASK-190-design-weekly-monthly-ranking-snapshots title: Design weekly monthly ranking snapshots -status: pending +status: done type: documentation team: Arquitecto de Base de Datos supporting_teams: @@ -98,12 +98,34 @@ Before completing the task ensure: ## Outcome -Document: - -- the proposed read-model schema -- refresh policy -- fallback policy -- transition notes for implementation in `TASK-191` +- Snapshot design documented in `docs/ranking-snapshot-read-model-plan.md`. +- Proposed read model uses: + - `ranking_snapshots` + - `ranking_snapshot_items` +- The plan explicitly covers: + - `timeframe` weekly/monthly/annual + - `server_id` + - `metric` + - `window_start` + - `window_end` + - `generated_at` + - `source` + - `snapshot_status` + - `item_count` + - `limit_size` + - per-item ranking and player fields +- Refresh policy defined: + - weekly current every `5` to `15` minutes + - monthly current every `15` to `30` minutes + - previous week/month stable once closed + - annual manual or daily +- Fallback policy defined: + - serve snapshot when `ready` + - return controlled `missing` or use runtime fallback only by configuration when snapshot is absent + - never recalculate by default on every public request +- Transition notes prepared for `TASK-191`: + - weekly/monthly snapshot-first read path + - annual remains on the existing annual snapshot implementation until a dedicated migration task consolidates storage ## Change Budget diff --git a/ai/tasks/pending/TASK-191-serve-ranking-from-snapshots-with-runtime-fallback.md b/ai/tasks/done/TASK-191-serve-ranking-from-snapshots-with-runtime-fallback.md similarity index 73% rename from ai/tasks/pending/TASK-191-serve-ranking-from-snapshots-with-runtime-fallback.md rename to ai/tasks/done/TASK-191-serve-ranking-from-snapshots-with-runtime-fallback.md index ec23ad4..a112dcd 100644 --- a/ai/tasks/pending/TASK-191-serve-ranking-from-snapshots-with-runtime-fallback.md +++ b/ai/tasks/done/TASK-191-serve-ranking-from-snapshots-with-runtime-fallback.md @@ -1,7 +1,7 @@ --- id: TASK-191-serve-ranking-from-snapshots-with-runtime-fallback title: Serve ranking from snapshots with runtime fallback -status: pending +status: done type: backend team: Backend Senior supporting_teams: @@ -99,12 +99,35 @@ Before completing the task ensure: ## Outcome -Document: - -- final read-path behavior -- fallback conditions -- validation results -- any remaining operational dependency for snapshot generation +- Implemented weekly/monthly `/api/ranking` as snapshot-first: + - snapshot `ready` rows are served from `ranking_snapshots` + `ranking_snapshot_items` + - annual requests remain on the existing annual snapshot path +- Added controlled runtime fallback behavior: + - fallback is used only when the weekly/monthly snapshot is missing + - fallback is controlled by `HLL_BACKEND_RANKING_RUNTIME_FALLBACK_ENABLED` + - default remains enabled for transition + - setting the variable to `false` returns controlled `snapshot_status='missing'` with empty items +- Response metadata now distinguishes: + - snapshot-ready + - snapshot-missing + - runtime-fallback +- Metadata exposed on ranking responses now includes: + - `source` + - `snapshot_status` + - `generated_at` + - `freshness` + - `fallback_used` + - `window_start` + - `window_end` +- Validation completed: + - `powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1` + - `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- Validation script now proves: + - normal route-contract behavior + - snapshot-ready behavior using temporary fixture rows + - snapshot-missing behavior with runtime fallback disabled +- Remaining operational dependency: + - snapshot tables now exist on first access, but snapshot rows are not generated automatically yet; a future generator/job still needs to populate weekly/monthly snapshots for production use ## Change Budget diff --git a/backend/app/payloads.py b/backend/app/payloads.py index 840d2b3..5ff2892 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -50,7 +50,11 @@ from .historical_storage import ( ) from .rcon_historical_read_model import get_rcon_historical_match_detail from .rcon_annual_rankings import get_annual_ranking_snapshot -from .rcon_historical_leaderboards import list_rcon_materialized_leaderboard +from .rcon_historical_leaderboards import ( + get_latest_ranking_snapshot, + is_ranking_runtime_fallback_enabled, + list_rcon_materialized_leaderboard, +) from .rcon_historical_player_stats import search_rcon_materialized_players from .rcon_historical_player_stats import get_rcon_materialized_player_stats from .normalizers import normalize_map_name @@ -818,6 +822,11 @@ def build_global_ranking_payload( "window_kind": "annual-snapshot", "window_label": "Anual", "snapshot_status": result.get("snapshot_status"), + "generated_at": result.get("generated_at"), + "freshness": ( + "snapshot" if result.get("snapshot_status") == "ready" else "missing" + ), + "fallback_used": False, "snapshot_limit": result.get("snapshot_limit"), "item_count": int(result.get("item_count") or 0), "source_matches_count": int(result.get("source_matches_count") or 0), @@ -833,6 +842,79 @@ def build_global_ranking_payload( }, } + snapshot_result = get_latest_ranking_snapshot( + server_key=normalized_server_id, + timeframe=normalized_timeframe, + metric=metric, + limit=limit, + ) + if snapshot_result.get("snapshot_status") == "ready": + return { + "status": "ok", + "data": { + "page_kind": "global-ranking", + "title": "Ranking global", + "context": f"global-ranking-{normalized_timeframe}", + "timeframe": normalized_timeframe, + "server_id": _serialize_public_server_id(snapshot_result.get("server_id")), + "metric": snapshot_result.get("metric"), + "limit": int(snapshot_result.get("limit") or 0), + "requested_limit": int(snapshot_result.get("requested_limit") or limit), + "effective_limit": int(snapshot_result.get("effective_limit") or 0), + "window_start": snapshot_result.get("window_start"), + "window_end": snapshot_result.get("window_end"), + "window_kind": snapshot_result.get("window_kind"), + "window_label": snapshot_result.get("window_label"), + "snapshot_status": "ready", + "generated_at": snapshot_result.get("generated_at"), + "freshness": snapshot_result.get("freshness") or "fresh", + "fallback_used": False, + "source_matches_count": int(snapshot_result.get("source_matches_count") or 0), + "source": { + "primary_source": "rcon", + "read_model": "ranking-snapshot", + "snapshot_source": snapshot_result.get("source"), + "generated_at": snapshot_result.get("generated_at"), + "freshness": snapshot_result.get("freshness") or "fresh", + }, + "items": _normalize_global_ranking_items(snapshot_result.get("items")), + }, + } + + runtime_fallback_enabled = is_ranking_runtime_fallback_enabled() + if not runtime_fallback_enabled: + return { + "status": "ok", + "data": { + "page_kind": "global-ranking", + "title": "Ranking global", + "context": f"global-ranking-{normalized_timeframe}", + "timeframe": normalized_timeframe, + "server_id": _serialize_public_server_id(snapshot_result.get("server_id")), + "metric": snapshot_result.get("metric"), + "limit": int(snapshot_result.get("limit") or limit), + "requested_limit": int(snapshot_result.get("requested_limit") or limit), + "effective_limit": int(snapshot_result.get("effective_limit") or 0), + "window_start": snapshot_result.get("window_start"), + "window_end": snapshot_result.get("window_end"), + "window_kind": snapshot_result.get("window_kind"), + "window_label": snapshot_result.get("window_label"), + "snapshot_status": "missing", + "generated_at": None, + "freshness": "missing", + "fallback_used": False, + "source_matches_count": 0, + "source": { + "primary_source": "rcon", + "read_model": "ranking-snapshot", + "snapshot_source": snapshot_result.get("source"), + "generated_at": None, + "freshness": "missing", + }, + "items": [], + }, + } + result = list_rcon_materialized_leaderboard( server_key=normalized_server_id, timeframe=normalized_timeframe, @@ -856,10 +938,14 @@ def build_global_ranking_payload( "window_kind": result.get("window_kind"), "window_label": result.get("window_label"), "selection_reason": result.get("selection_reason"), - "snapshot_status": "ready", + "snapshot_status": "missing", + "generated_at": None, + "freshness": "runtime", + "fallback_used": True, "source": { "primary_source": "rcon", "read_model": "rcon-materialized-admin-log-leaderboard", + "snapshot_source": "ranking-snapshot", "generated_at": _utc_timestamp_now(), "freshness": "runtime", }, diff --git a/backend/app/postgres_rcon_storage.py b/backend/app/postgres_rcon_storage.py index e01a084..43f7761 100644 --- a/backend/app/postgres_rcon_storage.py +++ b/backend/app/postgres_rcon_storage.py @@ -250,8 +250,27 @@ CREATE INDEX IF NOT EXISTS idx_rcon_player_profile_snapshots_player ON rcon_player_profile_snapshots(target_key, player_id, source_server_time DESC); CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_recent ON rcon_materialized_matches(target_key, ended_at DESC, ended_server_time DESC); +CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_source_window_text +ON rcon_materialized_matches( + source_basis, + COALESCE(CAST(ended_at AS TEXT), CAST(started_at AS TEXT)) +); +CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_target_source_window_text +ON rcon_materialized_matches( + target_key, + source_basis, + COALESCE(CAST(ended_at AS TEXT), CAST(started_at AS TEXT)) +); +CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_external_source_window_text +ON rcon_materialized_matches( + external_server_id, + source_basis, + COALESCE(CAST(ended_at AS TEXT), CAST(started_at AS TEXT)) +); CREATE INDEX IF NOT EXISTS idx_rcon_match_player_stats_match ON rcon_match_player_stats(target_key, match_key); +CREATE INDEX IF NOT EXISTS idx_rcon_match_player_stats_player_id_match +ON rcon_match_player_stats(player_id, target_key, match_key); CREATE INDEX IF NOT EXISTS idx_rcon_annual_ranking_snapshots_year ON rcon_annual_ranking_snapshots(year, server_key, metric); CREATE INDEX IF NOT EXISTS idx_rcon_annual_ranking_snapshots_status diff --git a/backend/app/rcon_admin_log_materialization.py b/backend/app/rcon_admin_log_materialization.py index a2c0e82..ef030c5 100644 --- a/backend/app/rcon_admin_log_materialization.py +++ b/backend/app/rcon_admin_log_materialization.py @@ -59,6 +59,26 @@ def initialize_rcon_materialized_storage(*, db_path: Path | None = None) -> Path CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_recent ON rcon_materialized_matches(target_key, ended_at DESC, ended_server_time DESC); + CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_source_window_text + ON rcon_materialized_matches( + source_basis, + COALESCE(CAST(ended_at AS TEXT), CAST(started_at AS TEXT)) + ); + + CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_target_source_window_text + ON rcon_materialized_matches( + target_key, + source_basis, + COALESCE(CAST(ended_at AS TEXT), CAST(started_at AS TEXT)) + ); + + CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_external_source_window_text + ON rcon_materialized_matches( + external_server_id, + source_basis, + COALESCE(CAST(ended_at AS TEXT), CAST(started_at AS TEXT)) + ); + CREATE TABLE IF NOT EXISTS rcon_match_player_stats ( id INTEGER PRIMARY KEY AUTOINCREMENT, target_key TEXT NOT NULL, @@ -84,6 +104,9 @@ def initialize_rcon_materialized_storage(*, db_path: Path | None = None) -> Path CREATE INDEX IF NOT EXISTS idx_rcon_match_player_stats_match ON rcon_match_player_stats(target_key, match_key); + CREATE INDEX IF NOT EXISTS idx_rcon_match_player_stats_player_id_match + ON rcon_match_player_stats(player_id, target_key, match_key); + CREATE TABLE IF NOT EXISTS rcon_annual_ranking_snapshots ( id INTEGER PRIMARY KEY AUTOINCREMENT, year INTEGER NOT NULL, diff --git a/backend/app/rcon_historical_leaderboards.py b/backend/app/rcon_historical_leaderboards.py index 7ce68cf..db25874 100644 --- a/backend/app/rcon_historical_leaderboards.py +++ b/backend/app/rcon_historical_leaderboards.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from contextlib import closing from datetime import datetime, timedelta, timezone from pathlib import Path @@ -14,7 +15,7 @@ from .rcon_admin_log_materialization import ( MATCH_RESULT_SOURCE, initialize_rcon_materialized_storage, ) -from .sqlite_utils import connect_sqlite_readonly +from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer LeaderboardTimeframe = Literal["weekly", "monthly"] LeaderboardMetric = Literal[ @@ -28,6 +29,156 @@ LeaderboardMetric = Literal[ "support", ] +RANKING_SNAPSHOT_SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS ranking_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timeframe TEXT NOT NULL, + server_id TEXT NOT NULL, + metric TEXT NOT NULL, + window_start TEXT NOT NULL, + window_end TEXT NOT NULL, + generated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + source TEXT NOT NULL DEFAULT 'rcon-materialized-admin-log', + snapshot_status TEXT NOT NULL DEFAULT 'ready', + item_count INTEGER NOT NULL DEFAULT 0, + limit_size INTEGER NOT NULL DEFAULT 20, + source_matches_count INTEGER NOT NULL DEFAULT 0, + freshness TEXT NOT NULL DEFAULT 'fresh', + window_kind TEXT, + window_label TEXT, + error_message TEXT, + UNIQUE(timeframe, server_id, metric, window_start, window_end) +); + +CREATE INDEX IF NOT EXISTS idx_ranking_snapshots_lookup +ON ranking_snapshots(timeframe, server_id, metric, snapshot_status, window_end DESC, generated_at DESC); + +CREATE TABLE IF NOT EXISTS ranking_snapshot_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL REFERENCES ranking_snapshots(id) ON DELETE CASCADE, + ranking_position INTEGER NOT NULL, + player_id TEXT NOT NULL, + player_name TEXT NOT NULL, + metric_value REAL NOT NULL DEFAULT 0, + matches_considered INTEGER NOT NULL DEFAULT 0, + kills INTEGER NOT NULL DEFAULT 0, + deaths INTEGER NOT NULL DEFAULT 0, + teamkills INTEGER NOT NULL DEFAULT 0, + kd_ratio REAL NOT NULL DEFAULT 0.0, + kills_per_match REAL NOT NULL DEFAULT 0.0, + UNIQUE(snapshot_id, ranking_position), + UNIQUE(snapshot_id, player_id) +); + +CREATE INDEX IF NOT EXISTS idx_ranking_snapshot_items_snapshot +ON ranking_snapshot_items(snapshot_id, ranking_position); + +CREATE INDEX IF NOT EXISTS idx_ranking_snapshot_items_player +ON ranking_snapshot_items(snapshot_id, player_id); +""" + + +def initialize_ranking_snapshot_storage(*, db_path: Path | None = None) -> Path: + """Create ranking snapshot tables used by weekly/monthly public ranking reads.""" + resolved_path = initialize_rcon_materialized_storage(db_path=db_path) + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import connect_postgres + + with connect_postgres() as connection: + with connection.cursor() as cursor: + cursor.execute(RANKING_SNAPSHOT_SCHEMA_SQL) + return resolved_path + + with closing(connect_sqlite_writer(resolved_path)) as connection: + with connection: + connection.executescript(RANKING_SNAPSHOT_SCHEMA_SQL) + return resolved_path + + +def is_ranking_runtime_fallback_enabled() -> bool: + """Return whether `/api/ranking` may fall back to runtime reads when snapshot is missing.""" + normalized = os.getenv( + "HLL_BACKEND_RANKING_RUNTIME_FALLBACK_ENABLED", + "true", + ).strip().lower() + return normalized in {"1", "true", "yes", "on"} + + +def get_latest_ranking_snapshot( + *, + server_key: str | None = None, + timeframe: str = "weekly", + metric: str = "kills", + limit: int = 10, + db_path: Path | None = None, +) -> dict[str, object]: + """Return the latest ready weekly/monthly ranking snapshot for the requested scope.""" + normalized_server_key = _normalize_snapshot_server_key(server_key) + normalized_timeframe = _normalize_timeframe(timeframe) + normalized_metric = _normalize_metric(metric) + normalized_limit = max(1, int(limit or 10)) + + resolved_path = initialize_ranking_snapshot_storage(db_path=db_path) + connection_scope = _connect_scope(resolved_path, db_path=db_path) + with connection_scope as connection: + snapshot = _find_latest_snapshot( + connection=connection, + timeframe=normalized_timeframe, + server_key=normalized_server_key, + metric=normalized_metric, + ) + if snapshot is None: + return { + "snapshot_status": "missing", + "timeframe": normalized_timeframe, + "server_id": normalized_server_key, + "metric": normalized_metric, + "limit": normalized_limit, + "requested_limit": normalized_limit, + "effective_limit": 0, + "snapshot_limit": None, + "item_count": 0, + "generated_at": None, + "window_start": None, + "window_end": None, + "window_kind": None, + "window_label": None, + "source": "ranking-snapshot", + "freshness": "missing", + "source_matches_count": 0, + "items": [], + } + + snapshot_limit = max(1, int(snapshot.get("limit_size") or normalized_limit)) + item_count = int(snapshot.get("item_count") or 0) + effective_limit = max(0, min(normalized_limit, snapshot_limit, item_count)) + items = _list_snapshot_items( + connection=connection, + snapshot_id=int(snapshot["id"]), + limit=effective_limit if effective_limit > 0 else None, + ) + + return { + "snapshot_status": "ready", + "timeframe": normalized_timeframe, + "server_id": normalized_server_key, + "metric": normalized_metric, + "limit": effective_limit, + "requested_limit": normalized_limit, + "effective_limit": effective_limit, + "snapshot_limit": snapshot_limit, + "item_count": item_count, + "generated_at": snapshot.get("generated_at"), + "window_start": snapshot.get("window_start"), + "window_end": snapshot.get("window_end"), + "window_kind": snapshot.get("window_kind"), + "window_label": snapshot.get("window_label"), + "source": snapshot.get("source") or "ranking-snapshot", + "freshness": snapshot.get("freshness") or "fresh", + "source_matches_count": int(snapshot.get("source_matches_count") or 0), + "items": items, + } + def build_rcon_materialized_leaderboard_snapshot_payload( *, @@ -196,6 +347,74 @@ def list_rcon_materialized_leaderboard( } +def _find_latest_snapshot( + *, + connection: object, + timeframe: str, + server_key: str, + metric: str, +) -> dict[str, object] | None: + row = connection.execute( + """ + SELECT + id, + timeframe, + server_id, + metric, + window_start, + window_end, + generated_at, + source, + snapshot_status, + item_count, + limit_size, + source_matches_count, + freshness, + window_kind, + window_label + FROM ranking_snapshots + WHERE timeframe = ? + AND server_id = ? + AND metric = ? + AND snapshot_status = 'ready' + ORDER BY window_end DESC, generated_at DESC + LIMIT 1 + """, + [timeframe, server_key, metric], + ).fetchone() + return dict(row) if row else None + + +def _list_snapshot_items( + *, + connection: object, + snapshot_id: int, + limit: int | None = None, +) -> list[dict[str, object]]: + params: list[object] = [snapshot_id] + query = """ + SELECT + ranking_position, + player_id, + player_name, + metric_value, + matches_considered, + kills, + deaths, + teamkills, + kd_ratio, + kills_per_match + FROM ranking_snapshot_items + WHERE snapshot_id = ? + ORDER BY ranking_position ASC + """ + if limit is not None: + query = f"{query}\n LIMIT ?" + params.append(limit) + rows = connection.execute(query, params).fetchall() + return [dict(row) for row in rows] + + def _fetch_leaderboard_rows( connection: object, *, @@ -469,6 +688,14 @@ def _build_scope_sql( ] +def _normalize_snapshot_server_key(server_key: str | None) -> str: + normalized = str(server_key or "").strip() + normalized_lower = normalized.lower() + if not normalized or normalized_lower in {ALL_SERVERS_SLUG, "all"}: + return ALL_SERVERS_SLUG + return normalized + + def _connect_scope(resolved_path: Path, *, db_path: Path | None): if use_postgres_rcon_storage(explicit_sqlite_path=db_path): from .postgres_rcon_storage import connect_postgres_compat diff --git a/docs/ranking-snapshot-read-model-plan.md b/docs/ranking-snapshot-read-model-plan.md new file mode 100644 index 0000000..69bb5af --- /dev/null +++ b/docs/ranking-snapshot-read-model-plan.md @@ -0,0 +1,255 @@ +# 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` + +## 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 diff --git a/docs/ranking-stats-performance-audit.md b/docs/ranking-stats-performance-audit.md new file mode 100644 index 0000000..af1c6ee --- /dev/null +++ b/docs/ranking-stats-performance-audit.md @@ -0,0 +1,262 @@ +# Ranking And Stats Performance Audit + +## Scope + +Audit date: +- 2026-06-09 + +Target endpoints: +- `/api/ranking?timeframe=weekly&server_id=all&metric=kills&limit=20` +- `/api/ranking?timeframe=weekly&server_id=all&metric=kd_ratio&limit=20` +- `/api/ranking?timeframe=monthly&server_id=all&metric=kills_per_match&limit=20` +- `/api/ranking?timeframe=annual&year=2026&server_id=all&metric=kills&limit=20` +- `/api/stats/players/search?q=Chi&limit=10` +- `/api/stats/players/3530e19cf8a9dd6a9fada4599592fbf8?timeframe=weekly` + +## Environment And Method + +- Backend HTTP server was not running on `http://127.0.0.1:8000` during the audit. +- Request timing was measured in-process through `app.routes.resolve_get_payload(...)`. +- SQL timing was measured by instrumenting the SQLite read connections used by: + - `backend/app/rcon_historical_leaderboards.py` + - `backend/app/rcon_historical_player_stats.py` + - `backend/app/rcon_annual_rankings.py` +- `EXPLAIN QUERY PLAN` was obtained successfully from SQLite. +- Storage inspected: + - `backend/data/hll_vietnam_dev.sqlite3` + +Important coverage note: +- The latest materialized match rows for `source_basis="admin-log-match-ended"` end on `2026-05-20T23:21:45.816Z`. +- The weekly/monthly public requests on `2026-06-09` therefore read windows with `0` qualifying matches. +- Measured latency is real for the current dataset, but it understates future production cost because the active public windows are empty. + +## Measured Baseline + +| Endpoint | Avg request ms | Avg SQL ms | SQL queries | Result | +| --- | ---: | ---: | ---: | --- | +| `/api/ranking` weekly `kills` | 4.045 | 0.882 | 6 | `snapshot_status=ready`, `items=0` | +| `/api/ranking` weekly `kd_ratio` | 3.801 | 0.837 | 6 | `snapshot_status=ready`, `items=0` | +| `/api/ranking` monthly `kills_per_match` | 3.612 | 0.816 | 6 | `snapshot_status=ready`, `items=0` | +| `/api/ranking` annual `kills` | 3.326 | 0.629 | 3 | `snapshot_status=ready`, `items=2` | +| `/api/stats/players/search?q=Chi&limit=10` | 6.731 | 3.593 | 3 | `items=10` | +| `/api/stats/players/{player_id}` weekly | 8.924 | 4.494 | 16 | weekly profile payload | + +Slowest endpoint: +- `/api/stats/players/{player_id}` weekly +- Main reason: 16 SQL statements per request, including repeated window-count queries and two ranking subqueries. + +## Relevant Table Size And Coverage + +Row counts: +- `rcon_materialized_matches`: `58` +- `rcon_match_player_stats`: `3,824` +- `rcon_annual_ranking_snapshots`: `2` +- `rcon_annual_ranking_snapshot_items`: `2` + +Rows actually used by runtime ranking/stats: +- `rcon_materialized_matches` with `source_basis="admin-log-match-ended"`: `22` + +Observed covered range for runtime ranking/stats: +- first materialized match end/start: `2026-05-19T11:16:10.281Z` +- latest materialized match end/start: `2026-05-20T23:21:45.816Z` + +Current public window coverage: +- weekly window `2026-06-01T00:00:00Z` to `2026-06-08T00:00:00Z`: `0` matches +- monthly window `2026-06-01T00:00:00Z` to `2026-06-09T23:59:59Z`: `0` matches + +Annual snapshot presence: +- `2026 / all-servers / kills / ready / limit_size=2 / source_matches_count=22` + +## Existing Indexes + +`rcon_materialized_matches` +- unique `(target_key, match_key)` +- non-unique `(target_key, ended_at, ended_server_time)` + +`rcon_match_player_stats` +- unique `(target_key, match_key, player_id)` +- non-unique `(target_key, match_key)` + +`rcon_annual_ranking_snapshots` +- unique `(year, server_key, metric)` +- non-unique `(year, server_key, metric)` +- non-unique `(status)` + +`rcon_annual_ranking_snapshot_items` +- unique `(snapshot_id, ranking_position)` +- unique `(snapshot_id, player_id)` +- non-unique `(snapshot_id, ranking_position)` +- non-unique `(snapshot_id, player_id)` + +## Current Query Shapes + +Weekly/monthly ranking runtime path: +- 4x `COUNT(*)` over `rcon_materialized_matches` to choose weekly/monthly windows +- 1x aggregate join from `rcon_match_player_stats` to `rcon_materialized_matches` +- 1x source-range query over `rcon_materialized_matches` + +Annual ranking path: +- 1x snapshot lookup in `rcon_annual_ranking_snapshots` +- 1x item count in `rcon_annual_ranking_snapshot_items` +- 1x ordered item read in `rcon_annual_ranking_snapshot_items` + +Stats player search path: +- 1x grouped search query joining `rcon_match_player_stats` to `rcon_materialized_matches` +- 1x latest-name lookup for returned `player_id` set +- 1x `servers_seen` lookup for returned `player_id` set + +Stats player detail path: +- 12x `COUNT(*)` window-selection queries over `rcon_materialized_matches` +- 1x player aggregate query +- 1x source-range query +- 2x ranking-position subqueries using grouped leaderboard logic + +## Execution Plan Findings + +SQLite `EXPLAIN QUERY PLAN` showed: + +Ranking count queries: +- `SCAN matches` + +Weekly/monthly leaderboard aggregate queries: +- `SCAN matches` +- `SEARCH stats USING INDEX idx_rcon_match_player_stats_match (target_key=? AND match_key=?)` +- `USE TEMP B-TREE FOR GROUP BY` +- `USE TEMP B-TREE FOR count(DISTINCT)` +- `USE TEMP B-TREE FOR ORDER BY` + +Stats search primary query: +- `SCAN matches` +- `SEARCH stats USING INDEX idx_rcon_match_player_stats_match (target_key=? AND match_key=?)` +- `USE TEMP B-TREE FOR GROUP BY` +- `USE TEMP B-TREE FOR count(DISTINCT)` +- `USE TEMP B-TREE FOR ORDER BY` + +Stats player detail aggregate query: +- `SCAN stats` +- `SEARCH matches USING INDEX sqlite_autoindex_rcon_materialized_matches_1 (target_key=? AND match_key=?)` +- `USE TEMP B-TREE FOR count(DISTINCT)` + +Stats player detail ranking subquery: +- `SCAN stats USING INDEX sqlite_autoindex_rcon_match_player_stats_1` +- `SEARCH matches USING INDEX sqlite_autoindex_rcon_materialized_matches_1 (target_key=? AND match_key=?)` +- `USE TEMP B-TREE FOR GROUP BY` +- `USE TEMP B-TREE FOR count(DISTINCT)` +- `USE TEMP B-TREE FOR ORDER BY` + +Annual snapshot lookup: +- `SEARCH rcon_annual_ranking_snapshots USING INDEX sqlite_autoindex_rcon_annual_ranking_snapshots_1 (year=? AND server_key=? AND metric=?)` + +## Root Cause Assessment + +Current measured latency is low because the runtime weekly/monthly windows are empty on `2026-06-09`, not because the runtime read path is already efficient at scale. + +The most likely root causes for future slowdown are: +- no index that matches `source_basis + time window` on `rcon_materialized_matches` +- repeated public-request window counting against `rcon_materialized_matches` +- grouped leaderboard/search queries that spill to temp B-trees for `GROUP BY`, `COUNT(DISTINCT)` and `ORDER BY` +- no direct `player_id` read index for the stats player-detail aggregate path + +The main non-performance operational issue exposed by the audit: +- runtime weekly/monthly ranking currently has stale coverage for the current public window because the latest materialized match data ends on `2026-05-20`, while the requests were evaluated on `2026-06-09` + +## Recommendation For TASK-189 + +Priority indexes justified by the current plans: +- add a time-window read index on `rcon_materialized_matches` keyed by `source_basis` plus the runtime time field used by public reads +- add a scoped time-window index on `rcon_materialized_matches` that also includes `target_key` +- add a direct `player_id` index on `rcon_match_player_stats` + +Concrete candidate coverage to evaluate in implementation: +- `rcon_materialized_matches(source_basis, ended_at)` +- `rcon_materialized_matches(source_basis, started_at)` +- `rcon_materialized_matches(target_key, source_basis, ended_at)` +- `rcon_materialized_matches(target_key, source_basis, started_at)` +- `rcon_match_player_stats(player_id)` + +Notes: +- `(target_key, match_key)` is already covered and should be preserved. +- annual snapshot item lookup is already covered by `(snapshot_id, ranking_position)` and `(snapshot_id, player_id)`. +- a simple `player_name` B-tree is not likely to materially help the current search query because the query uses `LOWER(...) LIKE '%term%'` with a leading wildcard. If search becomes a real bottleneck later, a normalized-prefix strategy or FTS is more promising than a plain index. + +## TASK-189 Applied Indexes + +Applied in storage initialization for both SQLite and PostgreSQL: +- `idx_rcon_materialized_matches_source_window_text` +- `idx_rcon_materialized_matches_target_source_window_text` +- `idx_rcon_materialized_matches_external_source_window_text` +- `idx_rcon_match_player_stats_player_id_match` + +Not added: +- plain `player_name` index + +Reason: +- the current search query uses `LOWER(player_name) LIKE '%term%'`, so a simple B-tree on `player_name` is not expected to materially help the current search shape + +## TASK-189 Post-Index Check + +Observed plan change: +- weekly count query now uses `SEARCH matches USING COVERING INDEX idx_rcon_materialized_matches_source_window_text (source_basis=? AND >? AND