Add historical snapshot storage layer

This commit is contained in:
devRaGonSa
2026-03-21 13:13:42 +01:00
parent e94b6cc7f6
commit d466b51d68
6 changed files with 442 additions and 0 deletions

View File

@@ -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.

View File

@@ -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. - `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_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_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_runner.py` ejecuta refresh incremental periodico con reintentos basicos.
- `historical_storage.py` prepara la persistencia `historical_*` y las consultas agregadas iniciales. - `historical_storage.py` prepara la persistencia `historical_*` y las consultas agregadas iniciales.
- `main.py` contiene el entrypoint HTTP y la creacion del servidor. - `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_players`
- `historical_player_match_stats` - `historical_player_match_stats`
- `historical_ingestion_runs` - `historical_ingestion_runs`
- `historical_precomputed_snapshots`
Por defecto el archivo se crea en: 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 `docs/historical-domain-model.md` para el historico CRCON. Esta implementacion
no introduce ORM, migraciones ni una decision de almacenamiento productivo. 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 ## Bootstrap del colector
El backend incluye un bootstrap minimo para el futuro flujo de snapshots: El backend incluye un bootstrap minimo para el futuro flujo de snapshots:

View File

@@ -109,3 +109,18 @@ class HistoricalBackfillProgressSummary:
last_run_started_at: datetime | None last_run_started_at: datetime | None
last_run_completed_at: datetime | None last_run_completed_at: datetime | None
last_error: str | 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

View File

@@ -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)

View File

@@ -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}.")

View File

@@ -16,6 +16,7 @@ Esta capa cubre solo historico persistido en backend:
- identidad reutilizable de jugadores - identidad reutilizable de jugadores
- estadisticas de jugador por partida - estadisticas de jugador por partida
- trazabilidad de ejecuciones de ingesta - trazabilidad de ejecuciones de ingesta
- snapshots precalculados para lectura rapida
No sustituye ni modifica el flujo actual de snapshots live via A2S. No sustituye ni modifica el flujo actual de snapshots live via A2S.
@@ -76,6 +77,19 @@ uniforme.
- matches vistos - matches vistos
- inserts y updates - 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 ## Data Model
### `historical_servers` ### `historical_servers`
@@ -121,6 +135,20 @@ Metricas por jugador y partida con al menos:
Trazabilidad operativa para bootstrap y refresh incremental. 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 ## Idempotency Strategy
- servidores sembrados de forma declarativa y actualizables por `slug` - 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)` `(historical_match_id, historical_player_id)`
- el refresco incremental usa una ventana de solape temporal para volver a leer - el refresco incremental usa una ventana de solape temporal para volver a leer
partidas recientes y absorber cambios tardios sin rehacer todo el historico 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 ## Query Readiness
La estructura soporta ya consultas futuras como: La estructura soporta ya consultas futuras como:
- top kills de la ultima semana por servidor - top kills de la ultima semana por servidor
- top muertes, soporte y partidas de 100+ kills desde una capa cacheada
- partidas recientes por servidor - partidas recientes por servidor
- mapas jugados y frecuencia - mapas jugados y frecuencia
- agregados por jugador sobre ventanas temporales - agregados por jugador sobre ventanas temporales
@@ -144,6 +175,8 @@ La estructura soporta ya consultas futuras como:
- live state actual: `server_snapshots` via A2S - live state actual: `server_snapshots` via A2S
- historico persistido: `historical_*` via CRCON scoreboard JSON - 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 Ambas lineas comparten el mismo SQLite local de desarrollo para reducir
complejidad operativa, pero mantienen tablas y contratos separados. complejidad operativa, pero mantienen tablas y contratos separados.