Add ranking snapshot performance path

This commit is contained in:
devRaGonSa
2026-06-09 08:13:42 +02:00
parent df84a11f53
commit 60334417d2
11 changed files with 1126 additions and 32 deletions

View File

@@ -1,7 +1,7 @@
--- ---
id: TASK-188-audit-ranking-and-stats-query-performance id: TASK-188-audit-ranking-and-stats-query-performance
title: Audit ranking and stats query performance title: Audit ranking and stats query performance
status: pending status: done
type: research type: research
team: Arquitecto de Base de Datos team: Arquitecto de Base de Datos
supporting_teams: supporting_teams:
@@ -85,12 +85,21 @@ Before completing the task ensure:
## Outcome ## Outcome
Document: - Audit completed in `docs/ranking-stats-performance-audit.md`.
- Measured baseline captured for the six required endpoint probes.
- measured baseline results - Environment limitation documented: backend HTTP server was not running locally, so request timing used in-process route resolution and SQL tracing over SQLite.
- environment limitations - 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`.
- the most likely root cause of slow public reads - Slowest measured endpoint is `/api/stats/players/{player_id}` because it issues 16 SQL statements, including repeated window counts and two ranking-position subqueries.
- the follow-up recommendation that should feed `TASK-189` and `TASK-190` - 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 ## Change Budget

View File

@@ -1,7 +1,7 @@
--- ---
id: TASK-189-add-ranking-materialized-read-indexes id: TASK-189-add-ranking-materialized-read-indexes
title: Add ranking materialized read indexes title: Add ranking materialized read indexes
status: pending status: done
type: backend type: backend
team: Arquitecto de Base de Datos team: Arquitecto de Base de Datos
supporting_teams: supporting_teams:
@@ -80,12 +80,24 @@ Before completing the task ensure:
## Outcome ## Outcome
Document: - Added SQLite/PostgreSQL-compatible indexes in the materialized storage initialization path:
- `idx_rcon_materialized_matches_source_window_text`
- indexes added - `idx_rcon_materialized_matches_target_source_window_text`
- why each index was chosen - `idx_rcon_materialized_matches_external_source_window_text`
- observed improvement or inability to measure it - `idx_rcon_match_player_stats_player_id_match`
- residual performance gaps that still require snapshot-based reads - 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 ## Change Budget

View File

@@ -1,7 +1,7 @@
--- ---
id: TASK-190-design-weekly-monthly-ranking-snapshots id: TASK-190-design-weekly-monthly-ranking-snapshots
title: Design weekly monthly ranking snapshots title: Design weekly monthly ranking snapshots
status: pending status: done
type: documentation type: documentation
team: Arquitecto de Base de Datos team: Arquitecto de Base de Datos
supporting_teams: supporting_teams:
@@ -98,12 +98,34 @@ Before completing the task ensure:
## Outcome ## Outcome
Document: - Snapshot design documented in `docs/ranking-snapshot-read-model-plan.md`.
- Proposed read model uses:
- the proposed read-model schema - `ranking_snapshots`
- refresh policy - `ranking_snapshot_items`
- fallback policy - The plan explicitly covers:
- transition notes for implementation in `TASK-191` - `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 ## Change Budget

View File

@@ -1,7 +1,7 @@
--- ---
id: TASK-191-serve-ranking-from-snapshots-with-runtime-fallback id: TASK-191-serve-ranking-from-snapshots-with-runtime-fallback
title: Serve ranking from snapshots with runtime fallback title: Serve ranking from snapshots with runtime fallback
status: pending status: done
type: backend type: backend
team: Backend Senior team: Backend Senior
supporting_teams: supporting_teams:
@@ -99,12 +99,35 @@ Before completing the task ensure:
## Outcome ## Outcome
Document: - Implemented weekly/monthly `/api/ranking` as snapshot-first:
- snapshot `ready` rows are served from `ranking_snapshots` + `ranking_snapshot_items`
- final read-path behavior - annual requests remain on the existing annual snapshot path
- fallback conditions - Added controlled runtime fallback behavior:
- validation results - fallback is used only when the weekly/monthly snapshot is missing
- any remaining operational dependency for snapshot generation - 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 ## Change Budget

View File

@@ -50,7 +50,11 @@ from .historical_storage import (
) )
from .rcon_historical_read_model import get_rcon_historical_match_detail from .rcon_historical_read_model import get_rcon_historical_match_detail
from .rcon_annual_rankings import get_annual_ranking_snapshot 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 search_rcon_materialized_players
from .rcon_historical_player_stats import get_rcon_materialized_player_stats from .rcon_historical_player_stats import get_rcon_materialized_player_stats
from .normalizers import normalize_map_name from .normalizers import normalize_map_name
@@ -818,6 +822,11 @@ def build_global_ranking_payload(
"window_kind": "annual-snapshot", "window_kind": "annual-snapshot",
"window_label": "Anual", "window_label": "Anual",
"snapshot_status": result.get("snapshot_status"), "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"), "snapshot_limit": result.get("snapshot_limit"),
"item_count": int(result.get("item_count") or 0), "item_count": int(result.get("item_count") or 0),
"source_matches_count": int(result.get("source_matches_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( result = list_rcon_materialized_leaderboard(
server_key=normalized_server_id, server_key=normalized_server_id,
timeframe=normalized_timeframe, timeframe=normalized_timeframe,
@@ -856,10 +938,14 @@ def build_global_ranking_payload(
"window_kind": result.get("window_kind"), "window_kind": result.get("window_kind"),
"window_label": result.get("window_label"), "window_label": result.get("window_label"),
"selection_reason": result.get("selection_reason"), "selection_reason": result.get("selection_reason"),
"snapshot_status": "ready", "snapshot_status": "missing",
"generated_at": None,
"freshness": "runtime",
"fallback_used": True,
"source": { "source": {
"primary_source": "rcon", "primary_source": "rcon",
"read_model": "rcon-materialized-admin-log-leaderboard", "read_model": "rcon-materialized-admin-log-leaderboard",
"snapshot_source": "ranking-snapshot",
"generated_at": _utc_timestamp_now(), "generated_at": _utc_timestamp_now(),
"freshness": "runtime", "freshness": "runtime",
}, },

View File

@@ -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); ON rcon_player_profile_snapshots(target_key, player_id, source_server_time DESC);
CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_recent CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_recent
ON rcon_materialized_matches(target_key, ended_at DESC, ended_server_time DESC); 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 CREATE INDEX IF NOT EXISTS idx_rcon_match_player_stats_match
ON rcon_match_player_stats(target_key, match_key); 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 CREATE INDEX IF NOT EXISTS idx_rcon_annual_ranking_snapshots_year
ON rcon_annual_ranking_snapshots(year, server_key, metric); ON rcon_annual_ranking_snapshots(year, server_key, metric);
CREATE INDEX IF NOT EXISTS idx_rcon_annual_ranking_snapshots_status CREATE INDEX IF NOT EXISTS idx_rcon_annual_ranking_snapshots_status

View File

@@ -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 CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_recent
ON rcon_materialized_matches(target_key, ended_at DESC, ended_server_time DESC); 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 ( CREATE TABLE IF NOT EXISTS rcon_match_player_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
target_key TEXT NOT NULL, 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 CREATE INDEX IF NOT EXISTS idx_rcon_match_player_stats_match
ON rcon_match_player_stats(target_key, match_key); 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 ( CREATE TABLE IF NOT EXISTS rcon_annual_ranking_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
year INTEGER NOT NULL, year INTEGER NOT NULL,

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import os
from contextlib import closing from contextlib import closing
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
@@ -14,7 +15,7 @@ from .rcon_admin_log_materialization import (
MATCH_RESULT_SOURCE, MATCH_RESULT_SOURCE,
initialize_rcon_materialized_storage, 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"] LeaderboardTimeframe = Literal["weekly", "monthly"]
LeaderboardMetric = Literal[ LeaderboardMetric = Literal[
@@ -28,6 +29,156 @@ LeaderboardMetric = Literal[
"support", "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( 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( def _fetch_leaderboard_rows(
connection: object, 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): def _connect_scope(resolved_path: Path, *, db_path: Path | None):
if use_postgres_rcon_storage(explicit_sqlite_path=db_path): if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat from .postgres_rcon_storage import connect_postgres_compat

View File

@@ -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=<scope>&metric=<metric>&limit=<n>&year=<year-when-annual>`
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

View File

@@ -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 <expr>>? AND <expr><?)`
- stats player-detail aggregate now uses:
- `SEARCH matches USING INDEX idx_rcon_materialized_matches_source_window_text (...)`
- `SEARCH stats USING INDEX sqlite_autoindex_rcon_match_player_stats_1 (target_key=? AND match_key=? AND player_id=?)`
Before/after timing probe:
- `/api/stats/players/{player_id}` weekly
- before: request `8.924 ms`, SQL `4.494 ms`
- after: request `4.300 ms`, SQL `1.043 ms`
- `/api/ranking` weekly `kills`
- before: request `4.045 ms`, SQL `0.882 ms`
- after: request `4.724 ms`, SQL `0.881 ms`
- interpretation: no meaningful change in this dataset because the current public weekly window on `2026-06-09` is empty and the table is still small
Residual gap after indexes:
- weekly/monthly public ranking still performs repeated runtime counting and grouped aggregation per request
- snapshots remain the correct next step for predictable public ranking latency
## Recommendation For TASK-190
Weekly/monthly public ranking should move to snapshot-backed reads for the same reason annual ranking is already cheap:
- runtime ranking currently performs repeated window counting and grouped aggregation on every public request
- the empty-window result on `2026-06-09` hides that structural cost
- snapshot-backed weekly/monthly reads would make public ranking latency predictable and remove repeated runtime recomputation from the request path
Recommended transition policy:
- serve weekly/monthly ranking from snapshots when `snapshot_status=ready`
- keep runtime fallback controlled and optional during migration
- do not recalculate heavy weekly/monthly aggregates by default on every public request
## Commands Executed
Repository and task context:
- `Get-Content AGENTS.md`
- `Get-Content ai/architecture-index.md`
- `Get-Content ai/repo-context.md`
- `Get-Content ai/tasks/pending/TASK-188-audit-ranking-and-stats-query-performance.md`
- `Get-Content ai/orchestrator/backend-senior.md`
- `Get-Content ai/orchestrator/database-architect.md`
Relevant code inspection:
- `Get-Content backend/app/routes.py`
- `Get-Content backend/app/payloads.py`
- `Get-Content backend/app/rcon_historical_leaderboards.py`
- `Get-Content backend/app/rcon_historical_player_stats.py`
- `Get-Content backend/app/rcon_annual_rankings.py`
- `Get-Content backend/app/config.py`
- `Get-Content backend/app/postgres_rcon_storage.py`
- `Get-Content backend/app/sqlite_utils.py`
- `Get-Content docs/global-ranking-page-plan.md`
- `Get-Content scripts/run-stats-validation.ps1`
Environment inspection:
- `Get-ChildItem backend/data -Force`
- `try { (Invoke-WebRequest -UseBasicParsing -TimeoutSec 3 http://127.0.0.1:8000/health).Content } catch { $_.Exception.Message }`
Measurement and database inspection:
- inline Python against `backend/data/hll_vietnam_dev.sqlite3` for endpoint timing, SQL timing, row counts, `PRAGMA index_list`, `PRAGMA index_info` and `EXPLAIN QUERY PLAN`
## Limitations
- No live HTTP timing was captured because the backend process was not running locally during the audit.
- The current runtime weekly/monthly windows were empty on `2026-06-09`, so the measured latency is a lower bound, not a stressed production baseline.
- The database is large on disk because the repository stores many other domains, but the materialized ranking/stats tables used by these endpoints are currently small.

View File

@@ -114,12 +114,16 @@ Assert-ContainsText $statsJs "Promise.allSettled" `
$backendContractCheck = @' $backendContractCheck = @'
import json import json
import os
import sqlite3
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, "backend") sys.path.insert(0, "backend")
from app.routes import resolve_get_payload from app.routes import resolve_get_payload
from app.rcon_historical_leaderboards import initialize_ranking_snapshot_storage
def require(condition, message): def require(condition, message):
@@ -145,6 +149,109 @@ def require_number(value, message):
require(isinstance(value, (int, float)), message) require(isinstance(value, (int, float)), message)
def build_snapshot_fixture():
db_path = Path("backend/data/hll_vietnam_dev.sqlite3")
initialize_ranking_snapshot_storage(db_path=db_path)
weekly_window_start = "2026-06-02T00:00:00Z"
weekly_window_end = "2026-06-09T00:00:00Z"
monthly_window_start = "2026-06-01T00:00:00Z"
monthly_window_end = "2026-06-09T00:00:00Z"
fixture_generated_at = "2026-06-09T08:00:00Z"
connection = sqlite3.connect(db_path)
connection.row_factory = sqlite3.Row
with connection:
connection.execute(
"""
DELETE FROM ranking_snapshot_items
WHERE snapshot_id IN (
SELECT id
FROM ranking_snapshots
WHERE source = 'stats-validation-fixture'
)
"""
)
connection.execute(
"DELETE FROM ranking_snapshots WHERE source = 'stats-validation-fixture'"
)
weekly_id = connection.execute(
"""
INSERT INTO ranking_snapshots (
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
) VALUES (?, ?, ?, ?, ?, ?, ?, 'ready', 1, 20, 4, 'fresh', 'current-week', 'Semana actual')
RETURNING id
""",
(
"weekly",
"all-servers",
"kills",
weekly_window_start,
weekly_window_end,
fixture_generated_at,
"stats-validation-fixture",
),
).fetchone()["id"]
monthly_id = connection.execute(
"""
INSERT INTO ranking_snapshots (
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
) VALUES (?, ?, ?, ?, ?, ?, ?, 'ready', 1, 20, 5, 'fresh', 'current-month', 'Mes actual')
RETURNING id
""",
(
"monthly",
"comunidad-hispana-01",
"kills_per_match",
monthly_window_start,
monthly_window_end,
fixture_generated_at,
"stats-validation-fixture",
),
).fetchone()["id"]
connection.execute(
"""
INSERT INTO ranking_snapshot_items (
snapshot_id, ranking_position, player_id, player_name, metric_value,
matches_considered, kills, deaths, teamkills, kd_ratio, kills_per_match
) VALUES (?, 1, 'fixture-player', 'Fixture Player', 99, 3, 297, 120, 1, 2.48, 99)
""",
(weekly_id,),
)
connection.execute(
"""
INSERT INTO ranking_snapshot_items (
snapshot_id, ranking_position, player_id, player_name, metric_value,
matches_considered, kills, deaths, teamkills, kd_ratio, kills_per_match
) VALUES (?, 1, 'fixture-kpm-player', 'Fixture KPM Player', 42.5, 4, 170, 80, 0, 2.13, 42.5)
""",
(monthly_id,),
)
connection.close()
return db_path
def cleanup_snapshot_fixture(db_path):
connection = sqlite3.connect(db_path)
with connection:
connection.execute(
"""
DELETE FROM ranking_snapshot_items
WHERE snapshot_id IN (
SELECT id
FROM ranking_snapshots
WHERE source = 'stats-validation-fixture'
)
"""
)
connection.execute(
"DELETE FROM ranking_snapshots WHERE source = 'stats-validation-fixture'"
)
connection.close()
health_status, health_payload = read_payload("/health") health_status, health_payload = read_payload("/health")
require(health_status == 200, "Route resolver /health should return 200.") require(health_status == 200, "Route resolver /health should return 200.")
require(health_payload.get("status") == "ok", "/health payload should be ok.") require(health_payload.get("status") == "ok", "/health payload should be ok.")
@@ -241,9 +348,14 @@ require(weekly_ranking_payload.get("status") == "ok", "Global ranking weekly pay
require(weekly_ranking_data.get("page_kind") == "global-ranking", "Global ranking should expose page_kind.") require(weekly_ranking_data.get("page_kind") == "global-ranking", "Global ranking should expose page_kind.")
require(weekly_ranking_data.get("timeframe") == "weekly", "Global ranking weekly timeframe should be preserved.") require(weekly_ranking_data.get("timeframe") == "weekly", "Global ranking weekly timeframe should be preserved.")
require(weekly_ranking_data.get("metric") == "kills", "Global ranking weekly metric should be kills.") require(weekly_ranking_data.get("metric") == "kills", "Global ranking weekly metric should be kills.")
require(weekly_ranking_data.get("snapshot_status") == "ready", "Global ranking weekly should expose ready snapshot status.") require(weekly_ranking_data.get("snapshot_status") in {"ready", "missing"}, "Global ranking weekly should expose ready/missing snapshot status.")
require(isinstance(weekly_ranking_data.get("items"), list), "Global ranking weekly items must be list.") require(isinstance(weekly_ranking_data.get("items"), list), "Global ranking weekly items must be list.")
require(isinstance(weekly_ranking_data.get("source"), dict), "Global ranking weekly should expose source metadata.") require(isinstance(weekly_ranking_data.get("source"), dict), "Global ranking weekly should expose source metadata.")
require("fallback_used" in weekly_ranking_data, "Global ranking weekly should expose fallback_used.")
require("freshness" in weekly_ranking_data, "Global ranking weekly should expose freshness.")
require("generated_at" in weekly_ranking_data, "Global ranking weekly should expose generated_at.")
require("window_start" in weekly_ranking_data, "Global ranking weekly should expose window_start.")
require("window_end" in weekly_ranking_data, "Global ranking weekly should expose window_end.")
weekly_deaths_status, weekly_deaths_payload = read_payload( weekly_deaths_status, weekly_deaths_payload = read_payload(
"/api/ranking?timeframe=weekly&server_id=all&metric=deaths&limit=20" "/api/ranking?timeframe=weekly&server_id=all&metric=deaths&limit=20"
@@ -282,6 +394,11 @@ require(monthly_ranking_status == 200, "Global ranking monthly route should retu
monthly_ranking_data = monthly_ranking_payload.get("data") or {} monthly_ranking_data = monthly_ranking_payload.get("data") or {}
require(monthly_ranking_data.get("timeframe") == "monthly", "Global ranking monthly timeframe should be preserved.") require(monthly_ranking_data.get("timeframe") == "monthly", "Global ranking monthly timeframe should be preserved.")
require(monthly_ranking_data.get("server_id") == "comunidad-hispana-01", "Global ranking monthly should preserve server_id.") require(monthly_ranking_data.get("server_id") == "comunidad-hispana-01", "Global ranking monthly should preserve server_id.")
require("fallback_used" in monthly_ranking_data, "Global ranking monthly should expose fallback_used.")
require("freshness" in monthly_ranking_data, "Global ranking monthly should expose freshness.")
require("generated_at" in monthly_ranking_data, "Global ranking monthly should expose generated_at.")
require("window_start" in monthly_ranking_data, "Global ranking monthly should expose window_start.")
require("window_end" in monthly_ranking_data, "Global ranking monthly should expose window_end.")
monthly_kd_status, monthly_kd_payload = read_payload( monthly_kd_status, monthly_kd_payload = read_payload(
"/api/ranking?timeframe=monthly&server_id=comunidad-hispana-01&metric=kd_ratio&limit=20" "/api/ranking?timeframe=monthly&server_id=comunidad-hispana-01&metric=kd_ratio&limit=20"
@@ -304,6 +421,9 @@ require(annual_ranking_data.get("timeframe") == "annual", "Global ranking annual
require(annual_ranking_data.get("metric") == "kills", "Global ranking annual metric should be kills.") require(annual_ranking_data.get("metric") == "kills", "Global ranking annual metric should be kills.")
require(annual_ranking_data.get("snapshot_status") in {"ready", "missing"}, "Global ranking annual snapshot_status should be ready or missing.") require(annual_ranking_data.get("snapshot_status") in {"ready", "missing"}, "Global ranking annual snapshot_status should be ready or missing.")
require(isinstance(annual_ranking_data.get("items"), list), "Global ranking annual items must be list.") require(isinstance(annual_ranking_data.get("items"), list), "Global ranking annual items must be list.")
require("generated_at" in annual_ranking_data, "Global ranking annual should expose generated_at.")
require("window_start" in annual_ranking_data, "Global ranking annual should expose window_start.")
require("window_end" in annual_ranking_data, "Global ranking annual should expose window_end.")
for ranking_payload in [ for ranking_payload in [
weekly_ranking_payload, weekly_ranking_payload,
@@ -367,6 +487,40 @@ missing_year_ranking_status, _ = read_payload(
) )
require(missing_year_ranking_status == 400, "Global ranking annual requests without year should return 400.") require(missing_year_ranking_status == 400, "Global ranking annual requests without year should return 400.")
fixture_db_path = build_snapshot_fixture()
try:
fixture_weekly_status, fixture_weekly_payload = read_payload(
"/api/ranking?timeframe=weekly&server_id=all&metric=kills&limit=20"
)
require(fixture_weekly_status == 200, "Fixture weekly ranking should return 200.")
fixture_weekly_data = fixture_weekly_payload.get("data") or {}
require(fixture_weekly_data.get("snapshot_status") == "ready", "Fixture weekly ranking should serve ready snapshot.")
require(fixture_weekly_data.get("fallback_used") is False, "Fixture weekly ranking should not use fallback.")
require((fixture_weekly_data.get("source") or {}).get("read_model") == "ranking-snapshot", "Fixture weekly ranking should identify snapshot read model.")
require(fixture_weekly_data.get("generated_at"), "Fixture weekly ranking should expose generated_at.")
fixture_monthly_status, fixture_monthly_payload = read_payload(
"/api/ranking?timeframe=monthly&server_id=comunidad-hispana-01&metric=kills_per_match&limit=20"
)
require(fixture_monthly_status == 200, "Fixture monthly ranking should return 200.")
fixture_monthly_data = fixture_monthly_payload.get("data") or {}
require(fixture_monthly_data.get("snapshot_status") == "ready", "Fixture monthly ranking should serve ready snapshot.")
require(fixture_monthly_data.get("fallback_used") is False, "Fixture monthly ranking should not use fallback.")
require((fixture_monthly_data.get("source") or {}).get("read_model") == "ranking-snapshot", "Fixture monthly ranking should identify snapshot read model.")
os.environ["HLL_BACKEND_RANKING_RUNTIME_FALLBACK_ENABLED"] = "false"
missing_snapshot_status, missing_snapshot_payload = read_payload(
"/api/ranking?timeframe=weekly&server_id=all&metric=deaths&limit=20"
)
require(missing_snapshot_status == 200, "Missing snapshot weekly ranking should return 200.")
missing_snapshot_data = missing_snapshot_payload.get("data") or {}
require(missing_snapshot_data.get("snapshot_status") == "missing", "Missing snapshot weekly ranking should expose missing snapshot_status.")
require(missing_snapshot_data.get("fallback_used") is False, "Missing snapshot weekly ranking should not use runtime fallback when disabled.")
require(isinstance(missing_snapshot_data.get("items"), list) and len(missing_snapshot_data.get("items")) == 0, "Missing snapshot weekly ranking should return empty items when fallback is disabled.")
finally:
os.environ["HLL_BACKEND_RANKING_RUNTIME_FALLBACK_ENABLED"] = "true"
cleanup_snapshot_fixture(fixture_db_path)
print(json.dumps({ print(json.dumps({
"checked": [ "checked": [
"health", "health",
@@ -374,6 +528,8 @@ print(json.dumps({
"stats-player-profile", "stats-player-profile",
"stats-annual-ranking", "stats-annual-ranking",
"global-ranking", "global-ranking",
"ranking-snapshot-ready",
"ranking-snapshot-missing",
], ],
"annual_snapshot_status": annual_data.get("snapshot_status"), "annual_snapshot_status": annual_data.get("snapshot_status"),
"global_ranking_annual_snapshot_status": annual_ranking_data.get("snapshot_status"), "global_ranking_annual_snapshot_status": annual_ranking_data.get("snapshot_status"),