diff --git a/ai/tasks/done/TASK-042-historical-snapshots-storage-and-model.md b/ai/tasks/done/TASK-042-historical-snapshots-storage-and-model.md new file mode 100644 index 0000000..abeac65 --- /dev/null +++ b/ai/tasks/done/TASK-042-historical-snapshots-storage-and-model.md @@ -0,0 +1,68 @@ +# TASK-042-historical-snapshots-storage-and-model + +## Goal +Diseñar e implementar una capa de almacenamiento de snapshots históricos precalculados para el proyecto, preparada para servir rápidamente resumen, rankings y partidas recientes sin recalcular agregados pesados en tiempo real. + +## Context +La página histórica ya funciona, pero parte de la información puede requerir agregaciones costosas si se calculan en el momento de cada carga o al cambiar de pestaña. El objetivo es introducir una capa de snapshots precalculados y persistidos que permita responder de forma rápida y estable. Esto debe incluir no solo el resumen del servidor, sino también los tops/rankings semanales. + +## Steps +1. Revisar la estructura histórica actual y los endpoints/API que hoy calculan resumen, rankings y partidas recientes. +2. Diseñar un modelo de snapshot persistido que soporte, como mínimo: + - resumen de servidor + - top kills semanales + - top muertes semanales + - top partidas con más de 100 kills por jugador + - top puntos de soporte semanales + - partidas recientes +3. Definir para cada snapshot metadatos claros, por ejemplo: + - server_key + - snapshot_type + - metric + - window + - payload_json + - generated_at + - source_range_start + - source_range_end + - is_stale +4. Implementar el almacenamiento local de snapshots de forma coherente con la persistencia histórica actual. +5. Documentar la estructura y propósito de esta nueva capa. +6. No migrar todavía toda la UI a snapshots en esta task. +7. Al completar la implementación: + - dejar el repositorio consistente + - hacer commit + - hacer push al remoto si el entorno lo permite + +## Files to Read First +- AGENTS.md +- backend/README.md +- backend/app/historical_models.py +- backend/app/historical_storage.py +- backend/app/payloads.py +- backend/app/routes.py +- docs/historical-domain-model.md + +## Expected Files to Modify +- backend/README.md +- backend/app/historical_models.py +- backend/app/historical_storage.py +- opcionalmente nuevos módulos, por ejemplo: + - backend/app/historical_snapshots.py + - backend/app/historical_snapshot_storage.py +- opcionalmente documentación técnica adicional + +## Constraints +- No basar esta capa en A2S. +- No crear todavía nueva UI en esta task. +- No hacer cambios destructivos. +- Mantener el trabajo centrado en almacenamiento y modelo de snapshots. + +## Validation +- Existe un modelo de snapshot persistido claro. +- El modelo cubre resumen, rankings y partidas recientes. +- La estructura está lista para ser rellenada periódicamente. +- Los cambios quedan committeados y se hace push al remoto si el entorno lo permite. + +## Change Budget +- Preferir menos de 6 archivos modificados o creados. +- Preferir menos de 220 líneas cambiadas. diff --git a/backend/README.md b/backend/README.md index 80c6aef..ade7aaf 100644 --- a/backend/README.md +++ b/backend/README.md @@ -164,6 +164,8 @@ colector. Si todavia no hay snapshots guardados, responden `status: "ok"` con - `config.py` centraliza host, puerto y allowlist minima de origenes locales. - `historical_ingestion.py` consulta la capa JSON publica de CRCON para bootstrap y refresh incremental. - `historical_models.py` fija las entidades historicas minimas del dominio. +- `historical_snapshots.py` fija los tipos y selectores validos de snapshots historicos precalculados. +- `historical_snapshot_storage.py` persiste snapshots historicos precalculados listos para lectura rapida. - `historical_runner.py` ejecuta refresh incremental periodico con reintentos basicos. - `historical_storage.py` prepara la persistencia `historical_*` y las consultas agregadas iniciales. - `main.py` contiene el entrypoint HTTP y la creacion del servidor. @@ -192,6 +194,7 @@ solo libreria estandar de Python. Esta base minima sigue el modelo logico de: - `historical_players` - `historical_player_match_stats` - `historical_ingestion_runs` +- `historical_precomputed_snapshots` Por defecto el archivo se crea en: @@ -209,6 +212,32 @@ La base logica sigue documentada en `docs/historical-domain-model.md` para el historico CRCON. Esta implementacion no introduce ORM, migraciones ni una decision de almacenamiento productivo. +## Snapshots historicos precalculados + +La capa historica incluye ahora una tabla adicional `historical_precomputed_snapshots` +en el mismo SQLite local para persistir payloads ya agregados y servirlos sin +recalculo pesado en cada request. Esta capa esta preparada para guardar: + +- `server-summary` +- `weekly-leaderboard` con metricas `kills`, `deaths`, `support` y `matches_over_100_kills` +- `recent-matches` + +Cada snapshot conserva metadatos operativos minimos: + +- `server_key` +- `snapshot_type` +- `metric` +- `window` +- `payload_json` +- `generated_at` +- `source_range_start` +- `source_range_end` +- `is_stale` + +La persistencia usa `upsert` por combinacion de servidor, tipo, metrica y +ventana para que la siguiente task pueda refrescar estos snapshots de forma +periodica sin duplicar filas. + ## Bootstrap del colector El backend incluye un bootstrap minimo para el futuro flujo de snapshots: diff --git a/backend/app/historical_models.py b/backend/app/historical_models.py index 97e1817..8566f82 100644 --- a/backend/app/historical_models.py +++ b/backend/app/historical_models.py @@ -109,3 +109,18 @@ class HistoricalBackfillProgressSummary: last_run_started_at: datetime | None last_run_completed_at: datetime | None last_error: str | None + + +@dataclass(frozen=True, slots=True) +class HistoricalSnapshotRecord: + """Persisted precomputed historical snapshot ready for lightweight reads.""" + + server_key: str + snapshot_type: str + metric: str | None + window: str | None + payload_json: str + generated_at: datetime + source_range_start: datetime | None + source_range_end: datetime | None + is_stale: bool diff --git a/backend/app/historical_snapshot_storage.py b/backend/app/historical_snapshot_storage.py new file mode 100644 index 0000000..225d625 --- /dev/null +++ b/backend/app/historical_snapshot_storage.py @@ -0,0 +1,251 @@ +"""SQLite persistence for precomputed historical snapshots.""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path + +from .config import get_storage_path +from .historical_models import HistoricalSnapshotRecord +from .historical_snapshots import validate_snapshot_identity +from .historical_storage import initialize_historical_storage + + +def initialize_historical_snapshot_storage(*, db_path: Path | None = None) -> Path: + """Create the snapshot table used by precomputed historical payloads.""" + resolved_path = initialize_historical_storage(db_path=db_path or get_storage_path()) + + with _connect(resolved_path) as connection: + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS historical_precomputed_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_key TEXT NOT NULL, + snapshot_type TEXT NOT NULL, + metric TEXT NOT NULL DEFAULT '', + window TEXT NOT NULL DEFAULT '', + payload_json TEXT NOT NULL, + generated_at TEXT NOT NULL, + source_range_start TEXT, + source_range_end TEXT, + is_stale INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(server_key, snapshot_type, metric, window) + ); + + CREATE INDEX IF NOT EXISTS idx_historical_precomputed_snapshots_lookup + ON historical_precomputed_snapshots( + server_key, + snapshot_type, + metric, + window, + generated_at DESC + ); + """ + ) + + return resolved_path + + +def persist_historical_snapshot( + *, + server_key: str, + snapshot_type: str, + payload: dict[str, object] | list[object], + metric: str | None = None, + window: str | None = None, + generated_at: datetime | None = None, + source_range_start: datetime | None = None, + source_range_end: datetime | None = None, + is_stale: bool = False, + db_path: Path | None = None, +) -> HistoricalSnapshotRecord: + """Insert or replace one persisted historical snapshot.""" + if not server_key.strip(): + raise ValueError("server_key is required for historical snapshots.") + + validate_snapshot_identity(snapshot_type=snapshot_type, metric=metric) + resolved_path = initialize_historical_snapshot_storage(db_path=db_path) + generated_at_value = generated_at or datetime.now(timezone.utc) + payload_json = json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + normalized_metric = metric or "" + normalized_window = window or "" + + with _connect(resolved_path) as connection: + connection.execute( + """ + INSERT INTO historical_precomputed_snapshots ( + server_key, + snapshot_type, + metric, + window, + payload_json, + generated_at, + source_range_start, + source_range_end, + is_stale + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(server_key, snapshot_type, metric, window) + DO UPDATE SET + payload_json = excluded.payload_json, + generated_at = excluded.generated_at, + source_range_start = excluded.source_range_start, + source_range_end = excluded.source_range_end, + is_stale = excluded.is_stale, + updated_at = CURRENT_TIMESTAMP + """, + ( + server_key.strip(), + snapshot_type, + normalized_metric, + normalized_window, + payload_json, + _to_iso(generated_at_value), + _to_iso(source_range_start), + _to_iso(source_range_end), + 1 if is_stale else 0, + ), + ) + + return HistoricalSnapshotRecord( + server_key=server_key.strip(), + snapshot_type=snapshot_type, + metric=metric, + window=window, + payload_json=payload_json, + generated_at=_as_utc(generated_at_value), + source_range_start=_as_utc(source_range_start), + source_range_end=_as_utc(source_range_end), + is_stale=is_stale, + ) + + +def get_historical_snapshot( + *, + server_key: str, + snapshot_type: str, + metric: str | None = None, + window: str | None = None, + db_path: Path | None = None, +) -> dict[str, object] | None: + """Return one persisted snapshot and decoded payload, if present.""" + validate_snapshot_identity(snapshot_type=snapshot_type, metric=metric) + resolved_path = initialize_historical_snapshot_storage(db_path=db_path) + + with _connect(resolved_path) as connection: + row = connection.execute( + """ + SELECT + server_key, + snapshot_type, + metric, + window, + payload_json, + generated_at, + source_range_start, + source_range_end, + is_stale + FROM historical_precomputed_snapshots + WHERE server_key = ? + AND snapshot_type = ? + AND metric = ? + AND window = ? + """, + (server_key, snapshot_type, metric or "", window or ""), + ).fetchone() + + if row is None: + return None + + payload = json.loads(row["payload_json"]) + return { + "server_key": row["server_key"], + "snapshot_type": row["snapshot_type"], + "metric": row["metric"] or None, + "window": row["window"] or None, + "generated_at": row["generated_at"], + "source_range_start": row["source_range_start"], + "source_range_end": row["source_range_end"], + "is_stale": bool(row["is_stale"]), + "payload": payload, + } + + +def list_historical_snapshots( + *, + server_key: str | None = None, + snapshot_type: str | None = None, + db_path: Path | None = None, +) -> list[dict[str, object]]: + """List persisted snapshots for validation and operational inspection.""" + resolved_path = initialize_historical_snapshot_storage(db_path=db_path) + where_parts: list[str] = [] + params: list[object] = [] + + if server_key: + where_parts.append("server_key = ?") + params.append(server_key) + if snapshot_type: + validate_snapshot_identity(snapshot_type=snapshot_type) + where_parts.append("snapshot_type = ?") + params.append(snapshot_type) + + where_sql = "" + if where_parts: + where_sql = "WHERE " + " AND ".join(where_parts) + + with _connect(resolved_path) as connection: + rows = connection.execute( + f""" + SELECT + server_key, + snapshot_type, + metric, + window, + generated_at, + source_range_start, + source_range_end, + is_stale + FROM historical_precomputed_snapshots + {where_sql} + ORDER BY server_key ASC, snapshot_type ASC, generated_at DESC + """, + params, + ).fetchall() + + return [ + { + "server_key": row["server_key"], + "snapshot_type": row["snapshot_type"], + "metric": row["metric"] or None, + "window": row["window"] or None, + "generated_at": row["generated_at"], + "source_range_start": row["source_range_start"], + "source_range_end": row["source_range_end"], + "is_stale": bool(row["is_stale"]), + } + for row in rows + ] + + +def _connect(db_path: Path) -> sqlite3.Connection: + connection = sqlite3.connect(db_path) + connection.row_factory = sqlite3.Row + return connection + + +def _to_iso(value: datetime | None) -> str | None: + if value is None: + return None + return _as_utc(value).isoformat().replace("+00:00", "Z") + + +def _as_utc(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) diff --git a/backend/app/historical_snapshots.py b/backend/app/historical_snapshots.py new file mode 100644 index 0000000..a078dc3 --- /dev/null +++ b/backend/app/historical_snapshots.py @@ -0,0 +1,46 @@ +"""Definitions for persisted precomputed historical snapshots.""" + +from __future__ import annotations + + +SNAPSHOT_TYPE_SERVER_SUMMARY = "server-summary" +SNAPSHOT_TYPE_WEEKLY_LEADERBOARD = "weekly-leaderboard" +SNAPSHOT_TYPE_RECENT_MATCHES = "recent-matches" + +SUPPORTED_SNAPSHOT_TYPES = frozenset( + { + SNAPSHOT_TYPE_SERVER_SUMMARY, + SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, + SNAPSHOT_TYPE_RECENT_MATCHES, + } +) + +SUPPORTED_LEADERBOARD_METRICS = frozenset( + { + "kills", + "deaths", + "support", + "matches_over_100_kills", + } +) + +DEFAULT_SNAPSHOT_WINDOW = "all-time" +DEFAULT_WEEKLY_SNAPSHOT_WINDOW = "7d" + + +def validate_snapshot_identity( + *, + snapshot_type: str, + metric: str | None = None, +) -> None: + """Validate the persisted snapshot selectors accepted by the storage layer.""" + if snapshot_type not in SUPPORTED_SNAPSHOT_TYPES: + raise ValueError(f"Unsupported historical snapshot type: {snapshot_type}") + + if snapshot_type == SNAPSHOT_TYPE_WEEKLY_LEADERBOARD: + if metric not in SUPPORTED_LEADERBOARD_METRICS: + raise ValueError(f"Unsupported historical snapshot metric: {metric}") + return + + if metric is not None: + raise ValueError(f"Metric is only supported for {SNAPSHOT_TYPE_WEEKLY_LEADERBOARD}.") diff --git a/docs/historical-domain-model.md b/docs/historical-domain-model.md index b751835..9938b4e 100644 --- a/docs/historical-domain-model.md +++ b/docs/historical-domain-model.md @@ -16,6 +16,7 @@ Esta capa cubre solo historico persistido en backend: - identidad reutilizable de jugadores - estadisticas de jugador por partida - trazabilidad de ejecuciones de ingesta +- snapshots precalculados para lectura rapida No sustituye ni modifica el flujo actual de snapshots live via A2S. @@ -76,6 +77,19 @@ uniforme. - matches vistos - inserts y updates +### Precomputed Snapshot + +- tabla: `historical_precomputed_snapshots` +- clave estable: + - `server_key` + - `snapshot_type` + - `metric` + - `window` +- razon: + - permite exponer resumen, rankings y partidas recientes sin recalcular + agregados pesados en cada request + - mantiene metadatos operativos sobre frescura y rango fuente + ## Data Model ### `historical_servers` @@ -121,6 +135,20 @@ Metricas por jugador y partida con al menos: Trazabilidad operativa para bootstrap y refresh incremental. +### `historical_precomputed_snapshots` + +Payloads JSON precalculados listos para lectura rapida desde API/UI con: + +- `server_key` +- `snapshot_type` +- `metric` +- `window` +- `payload_json` +- `generated_at` +- `source_range_start` +- `source_range_end` +- `is_stale` + ## Idempotency Strategy - servidores sembrados de forma declarativa y actualizables por `slug` @@ -130,12 +158,15 @@ Trazabilidad operativa para bootstrap y refresh incremental. `(historical_match_id, historical_player_id)` - el refresco incremental usa una ventana de solape temporal para volver a leer partidas recientes y absorber cambios tardios sin rehacer todo el historico +- los snapshots precalculados usan `UPSERT` por identidad logica para refrescar + el payload sin crear duplicados ## Query Readiness La estructura soporta ya consultas futuras como: - top kills de la ultima semana por servidor +- top muertes, soporte y partidas de 100+ kills desde una capa cacheada - partidas recientes por servidor - mapas jugados y frecuencia - agregados por jugador sobre ventanas temporales @@ -144,6 +175,8 @@ La estructura soporta ya consultas futuras como: - live state actual: `server_snapshots` via A2S - historico persistido: `historical_*` via CRCON scoreboard JSON +- snapshots precalculados: `historical_precomputed_snapshots` sobre el mismo + historico persistido Ambas lineas comparten el mismo SQLite local de desarrollo para reducir complejidad operativa, pero mantienen tablas y contratos separados.