From 394f6e71d74db239fd0f793f8d8d654fc8462c16 Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Tue, 9 Jun 2026 08:30:37 +0200 Subject: [PATCH] Fix PostgreSQL ranking snapshot schema --- ...92-fix-postgres-ranking-snapshot-schema.md | 126 ++++++++++++++++++ backend/app/postgres_rcon_storage.py | 43 ++++++ backend/app/rcon_historical_leaderboards.py | 10 +- scripts/run-stats-validation.ps1 | 63 ++++++++- 4 files changed, 235 insertions(+), 7 deletions(-) create mode 100644 ai/tasks/done/TASK-192-fix-postgres-ranking-snapshot-schema.md diff --git a/ai/tasks/done/TASK-192-fix-postgres-ranking-snapshot-schema.md b/ai/tasks/done/TASK-192-fix-postgres-ranking-snapshot-schema.md new file mode 100644 index 0000000..cbff736 --- /dev/null +++ b/ai/tasks/done/TASK-192-fix-postgres-ranking-snapshot-schema.md @@ -0,0 +1,126 @@ +--- +id: TASK-192-fix-postgres-ranking-snapshot-schema +title: Fix PostgreSQL ranking snapshot schema +status: done +type: backend +team: Backend Senior +supporting_teams: + - Arquitecto de Base de Datos + - Arquitecto Python +roadmap_item: foundation +priority: high +--- + +# TASK-192 - Fix PostgreSQL ranking snapshot schema + +## Goal + +Apply a backend hotfix so weekly and monthly ranking snapshot storage initializes correctly in PostgreSQL without executing SQLite-only schema syntax. + +## Context + +Production `GET /api/ranking?timeframe=weekly&metric=kills&limit=20` currently fails because `backend/app/rcon_historical_leaderboards.py` executes SQLite DDL with `AUTOINCREMENT` through `psycopg`. HLL Vietnam operational storage must stay in PostgreSQL, including ranking snapshots, and this fix must preserve the current annual snapshot path and avoid any SQLite expansion for this functionality. + +Preserve the current product identity: Spanish-speaking HLL Vietnam community, military/Vietnam/tactical/sober visual direction and controlled repository evolution. + +## Steps + +1. Inspect the listed files first. +2. Create the PostgreSQL-compatible ranking snapshot schema following the existing backend storage pattern. +3. Keep annual snapshot storage intact and scoped to `rcon_annual_ranking_snapshots`. +4. Validate the backend path and document the root cause, fix and production verification steps. + +## Files to Read First + +- `AGENTS.md` +- `ai/repo-context.md` +- `ai/architecture-index.md` +- `backend/app/rcon_historical_leaderboards.py` +- `backend/app/postgres_rcon_storage.py` +- `backend/app/rcon_admin_log_materialization.py` +- `backend/app/rcon_annual_rankings.py` +- `backend/app/payloads.py` +- `backend/app/routes.py` +- `scripts/run-stats-validation.ps1` +- `ai/tasks/done/TASK-191-serve-ranking-from-snapshots-with-runtime-fallback.md` + +## Expected Files to Modify + +- `backend/app/rcon_historical_leaderboards.py` +- `backend/app/postgres_rcon_storage.py`, solo si el patrón del proyecto lo requiere +- `scripts/run-stats-validation.ps1`, solo si se añade validación de regresión +- `ai/tasks/done/TASK-192-fix-postgres-ranking-snapshot-schema.md` + +## Constraints + +- Keep the change minimal. +- Preserve HLL Vietnam project identity. +- Do not introduce unnecessary frameworks or dependencies. +- Do not create or depend on a separate SQLite database for ranking snapshots. +- Do not expand legacy SQLite compatibility routes for this functionality beyond what already exists. +- Do not modify frontend, visual logic, Elo/MMR or Comunidad Hispana #03 handling. +- Keep annual ranking on `rcon_annual_ranking_snapshots`. +- Do not overwrite repository-specific context with generic platform template text. + +## Validation + +Before completing the task ensure: + +- `node --check frontend/assets/js/ranking.js` +- `node --check frontend/assets/js/stats.js` +- `powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- local validation proves `initialize_ranking_snapshot_storage` does not contain or execute `AUTOINCREMENT` on the PostgreSQL path +- local validation proves PostgreSQL schema defines `ranking_snapshots` and `ranking_snapshot_items` with PostgreSQL-compatible syntax +- `/api/ranking?timeframe=weekly&metric=kills&limit=20` no longer fails because of table creation +- `/api/ranking?timeframe=annual&metric=kills&limit=20&year=2026` keeps working +- `git diff --name-only` matches the expected scope + +If local PostgreSQL is unavailable, document that in Outcome and validate by code inspection plus existing scripts. + +## Outcome + +- Root cause: + - `backend/app/rcon_historical_leaderboards.py` defined `ranking_snapshots` / `ranking_snapshot_items` with SQLite syntax and executed that same DDL through `psycopg` on the PostgreSQL path. + - Production failure matched the SQLite-only token exactly: `AUTOINCREMENT`. +- Confirmation: + - The failing path was `build_global_ranking_payload` -> `get_latest_ranking_snapshot` -> `initialize_ranking_snapshot_storage`. + - The hotfix removes PostgreSQL execution of the SQLite snapshot DDL and routes PostgreSQL initialization through the central PostgreSQL schema initializer. +- Applied change: + - Kept the SQLite snapshot schema local to the SQLite path in `backend/app/rcon_historical_leaderboards.py`. + - Added PostgreSQL-compatible `ranking_snapshots` and `ranking_snapshot_items` tables plus indexes to `backend/app/postgres_rcon_storage.py`. + - Used `BIGSERIAL` primary keys and PostgreSQL-compatible `BIGINT`, `DOUBLE PRECISION`, `TIMESTAMPTZ`, foreign keys and index syntax. + - Added regression validation in `scripts/run-stats-validation.ps1` to prove the PostgreSQL path does not depend on `AUTOINCREMENT` and that it delegates to `initialize_postgres_rcon_storage`. +- Expected PostgreSQL tables: + - `ranking_snapshots` + - `ranking_snapshot_items` + - Annual ranking remains on: + - `rcon_annual_ranking_snapshots` + - `rcon_annual_ranking_snapshot_items` +- Validations executed: + - `node --check frontend/assets/js/ranking.js` + - `node --check frontend/assets/js/stats.js` + - `powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1` + - `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` + - Local regression validation now proves: + - PostgreSQL schema text contains `ranking_snapshots` and `ranking_snapshot_items` + - PostgreSQL schema text does not contain `AUTOINCREMENT` + - `initialize_ranking_snapshot_storage()` delegates to PostgreSQL schema initialization on the PostgreSQL path + - `/api/ranking?timeframe=annual&year=2026&server_id=all&metric=kills&limit=20` still resolves +- Environment note: + - Local backend HTTP at `http://127.0.0.1:8000` was not available during validation. + - Validation therefore completed by local Python imports, route resolution and existing scripts, which passed. +- How to validate in production: + - Deploy the backend hotfix. + - Confirm PostgreSQL contains `ranking_snapshots` and `ranking_snapshot_items`. + - Request: + - `/api/ranking?timeframe=weekly&metric=kills&limit=20` + - `/api/ranking?timeframe=monthly&metric=kills&limit=20` + - `/api/ranking?timeframe=annual&metric=kills&limit=20&year=2026` + - Verify weekly/monthly no longer fail during table initialization and annual remains unchanged. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split the work into follow-up tasks if limits are exceeded. diff --git a/backend/app/postgres_rcon_storage.py b/backend/app/postgres_rcon_storage.py index 43f7761..d508b3d 100644 --- a/backend/app/postgres_rcon_storage.py +++ b/backend/app/postgres_rcon_storage.py @@ -221,6 +221,43 @@ CREATE TABLE IF NOT EXISTS rcon_annual_ranking_snapshot_items ( UNIQUE(snapshot_id, player_id) ); +CREATE TABLE IF NOT EXISTS ranking_snapshots ( + id BIGSERIAL PRIMARY KEY, + 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 TIMESTAMPTZ 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 TABLE IF NOT EXISTS ranking_snapshot_items ( + id BIGSERIAL PRIMARY KEY, + snapshot_id BIGINT 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 DOUBLE PRECISION 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 DOUBLE PRECISION NOT NULL DEFAULT 0.0, + kills_per_match DOUBLE PRECISION NOT NULL DEFAULT 0.0, + UNIQUE(snapshot_id, ranking_position), + UNIQUE(snapshot_id, player_id) +); + CREATE TABLE IF NOT EXISTS rcon_scoreboard_match_candidates ( id BIGSERIAL PRIMARY KEY, server_slug TEXT NOT NULL, @@ -279,6 +316,12 @@ CREATE INDEX IF NOT EXISTS idx_rcon_annual_snapshot_items_snapshot ON rcon_annual_ranking_snapshot_items(snapshot_id, ranking_position); CREATE INDEX IF NOT EXISTS idx_rcon_annual_snapshot_items_player ON rcon_annual_ranking_snapshot_items(snapshot_id, player_id); +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 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); CREATE INDEX IF NOT EXISTS idx_rcon_scoreboard_candidates_server_end ON rcon_scoreboard_match_candidates(server_slug, ended_at DESC, started_at DESC); """ diff --git a/backend/app/rcon_historical_leaderboards.py b/backend/app/rcon_historical_leaderboards.py index db25874..f0c6056 100644 --- a/backend/app/rcon_historical_leaderboards.py +++ b/backend/app/rcon_historical_leaderboards.py @@ -29,7 +29,7 @@ LeaderboardMetric = Literal[ "support", ] -RANKING_SNAPSHOT_SCHEMA_SQL = """ +RANKING_SNAPSHOT_SQLITE_SCHEMA_SQL = """ CREATE TABLE IF NOT EXISTS ranking_snapshots ( id INTEGER PRIMARY KEY AUTOINCREMENT, timeframe TEXT NOT NULL, @@ -82,16 +82,14 @@ 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 + from .postgres_rcon_storage import initialize_postgres_rcon_storage - with connect_postgres() as connection: - with connection.cursor() as cursor: - cursor.execute(RANKING_SNAPSHOT_SCHEMA_SQL) + initialize_postgres_rcon_storage() return resolved_path with closing(connect_sqlite_writer(resolved_path)) as connection: with connection: - connection.executescript(RANKING_SNAPSHOT_SCHEMA_SQL) + connection.executescript(RANKING_SNAPSHOT_SQLITE_SCHEMA_SQL) return resolved_path diff --git a/scripts/run-stats-validation.ps1 b/scripts/run-stats-validation.ps1 index 81a7fc2..31655a7 100644 --- a/scripts/run-stats-validation.ps1 +++ b/scripts/run-stats-validation.ps1 @@ -123,7 +123,10 @@ from pathlib import Path sys.path.insert(0, "backend") from app.routes import resolve_get_payload -from app.rcon_historical_leaderboards import initialize_ranking_snapshot_storage +import app.postgres_rcon_storage as postgres_rcon_storage +import app.rcon_historical_leaderboards as ranking_leaderboards + +initialize_ranking_snapshot_storage = ranking_leaderboards.initialize_ranking_snapshot_storage def require(condition, message): @@ -233,6 +236,52 @@ def build_snapshot_fixture(): return db_path +def validate_postgres_ranking_snapshot_schema_path(): + require( + "AUTOINCREMENT" not in postgres_rcon_storage.RCON_SCHEMA_SQL, + "PostgreSQL schema must not contain AUTOINCREMENT.", + ) + require( + "CREATE TABLE IF NOT EXISTS ranking_snapshots" in postgres_rcon_storage.RCON_SCHEMA_SQL, + "PostgreSQL schema should define ranking_snapshots.", + ) + require( + "CREATE TABLE IF NOT EXISTS ranking_snapshot_items" in postgres_rcon_storage.RCON_SCHEMA_SQL, + "PostgreSQL schema should define ranking_snapshot_items.", + ) + require( + "BIGSERIAL PRIMARY KEY" in postgres_rcon_storage.RCON_SCHEMA_SQL, + "PostgreSQL snapshot schema should use BIGSERIAL primary keys.", + ) + + original_initialize_materialized = ranking_leaderboards.initialize_rcon_materialized_storage + original_use_postgres_storage = ranking_leaderboards.use_postgres_rcon_storage + original_initialize_postgres_storage = postgres_rcon_storage.initialize_postgres_rcon_storage + calls = {"postgres_init": 0} + + ranking_leaderboards.initialize_rcon_materialized_storage = ( + lambda db_path=None: Path("backend/data/hll_vietnam_dev.sqlite3") + ) + ranking_leaderboards.use_postgres_rcon_storage = lambda explicit_sqlite_path=None: True + + def fake_initialize_postgres_storage(): + calls["postgres_init"] += 1 + + postgres_rcon_storage.initialize_postgres_rcon_storage = fake_initialize_postgres_storage + + try: + initialize_ranking_snapshot_storage() + finally: + ranking_leaderboards.initialize_rcon_materialized_storage = original_initialize_materialized + ranking_leaderboards.use_postgres_rcon_storage = original_use_postgres_storage + postgres_rcon_storage.initialize_postgres_rcon_storage = original_initialize_postgres_storage + + require( + calls["postgres_init"] == 1, + "PostgreSQL ranking snapshot initialization should delegate to initialize_postgres_rcon_storage exactly once.", + ) + + def cleanup_snapshot_fixture(db_path): connection = sqlite3.connect(db_path) with connection: @@ -256,6 +305,8 @@ health_status, health_payload = read_payload("/health") require(health_status == 200, "Route resolver /health should return 200.") require(health_payload.get("status") == "ok", "/health payload should be ok.") +validate_postgres_ranking_snapshot_schema_path() + search_status, search_payload = read_payload("/api/stats/players/search?q=regression-check&limit=5") require(search_status == 200, "Stats player search should return 200 for a valid query.") search_data = search_payload.get("data") or {} @@ -425,6 +476,15 @@ require("generated_at" in annual_ranking_data, "Global ranking annual should exp 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.") +annual_2026_status, annual_2026_payload = read_payload( + "/api/ranking?timeframe=annual&year=2026&server_id=all&metric=kills&limit=20" +) +require(annual_2026_status == 200, "Global ranking annual 2026 route should return 200.") +require( + ((annual_2026_payload.get("data") or {}).get("snapshot_status") in {"ready", "missing"}), + "Global ranking annual 2026 should expose ready/missing snapshot status.", +) + for ranking_payload in [ weekly_ranking_payload, weekly_deaths_payload, @@ -528,6 +588,7 @@ print(json.dumps({ "stats-player-profile", "stats-annual-ranking", "global-ranking", + "postgres-ranking-schema-path", "ranking-snapshot-ready", "ranking-snapshot-missing", ],