Add ranking snapshot performance path
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
255
docs/ranking-snapshot-read-model-plan.md
Normal file
255
docs/ranking-snapshot-read-model-plan.md
Normal 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
|
||||||
262
docs/ranking-stats-performance-audit.md
Normal file
262
docs/ranking-stats-performance-audit.md
Normal 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.
|
||||||
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user