Add historical snapshot generation runner

This commit is contained in:
devRaGonSa
2026-03-21 13:16:33 +01:00
parent d466b51d68
commit 97331a76d4
6 changed files with 342 additions and 11 deletions

View File

@@ -0,0 +1,59 @@
# TASK-043-historical-snapshots-generation-runner
## Goal
Implementar un generador/runner de snapshots históricos que calcule y refresque periódicamente resumen, tops y partidas recientes para cada servidor de la comunidad.
## Context
Una vez existe almacenamiento de snapshots, hace falta una capa operativa que genere esos snapshots a partir del histórico persistido. El objetivo es que la página histórica no dependa de cálculos pesados en tiempo real, sino de datos precalculados actualizados periódicamente.
## Steps
1. Revisar los agregados históricos actuales y la nueva capa de snapshots.
2. Implementar un runner o flujo de generación que produzca snapshots para cada servidor.
3. Generar, como mínimo:
- resumen de servidor
- top kills 7d
- top muertes 7d
- top partidas con más de 100 kills por jugador 7d
- top puntos de soporte 7d
- partidas recientes
4. Añadir metadatos de generación y estado.
5. Definir una frecuencia razonable de refresco, por ejemplo 10 o 15 minutos.
6. Documentar cómo ejecutar el runner manualmente y cómo operarlo de forma periódica.
7. No migrar todavía toda la UI en esta task.
8. 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/config.py
- backend/app/historical_storage.py
- backend/app/historical_snapshots.py
- backend/app/historical_snapshot_storage.py
- backend/app/historical_runner.py
## Expected Files to Modify
- backend/README.md
- backend/app/config.py
- backend/app/historical_runner.py
- backend/app/historical_snapshots.py
- backend/app/historical_snapshot_storage.py
- opcionalmente nuevos módulos auxiliares de generación
## Constraints
- No romper el live status.
- No crear UI nueva en esta task.
- No hacer cambios destructivos.
- Mantener el trabajo centrado en generación periódica de snapshots.
## Validation
- Existe un runner que genera snapshots de resumen, tops y partidas recientes.
- Los snapshots se pueden refrescar periódicamente.
- La documentación explica cómo ejecutarlo.
- 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 240 líneas cambiadas.

View File

@@ -70,6 +70,7 @@ Variables opcionales:
- `HLL_HISTORICAL_CRCON_REQUEST_RETRIES` - `HLL_HISTORICAL_CRCON_REQUEST_RETRIES`
- `HLL_HISTORICAL_CRCON_RETRY_DELAY_SECONDS` - `HLL_HISTORICAL_CRCON_RETRY_DELAY_SECONDS`
- `HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS` - `HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS`
- `HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS`
- `HLL_HISTORICAL_REFRESH_MAX_RETRIES` - `HLL_HISTORICAL_REFRESH_MAX_RETRIES`
- `HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS` - `HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS`
@@ -558,23 +559,34 @@ Los reintentos de cada request JSON pueden ajustarse sin tocar codigo con:
- `HLL_HISTORICAL_CRCON_RETRY_DELAY_SECONDS` - `HLL_HISTORICAL_CRCON_RETRY_DELAY_SECONDS`
El runner `python -m app.historical_runner` deja ese refresh incremental listo El runner `python -m app.historical_runner` deja ese refresh incremental listo
para ejecucion local repetida sin depender de infraestructura externa. Por para ejecucion local repetida sin depender de infraestructura externa y
regenera snapshots historicos precalculados tras cada refresh correcto. Por
defecto: defecto:
- refresca cada `1800` segundos - refresca y recompone snapshots cada `900` segundos
- reintenta hasta `2` veces tras un fallo - reintenta hasta `2` veces tras un fallo
- espera `30` segundos entre reintentos - espera `30` segundos entre reintentos
- reutiliza el registro de `historical_ingestion_runs` para dejar trazabilidad - reutiliza el registro de `historical_ingestion_runs` para dejar trazabilidad
de ultimo refresh, resultado y errores basicos de ultimo refresh, resultado y errores basicos
- persiste por servidor:
- `server-summary`
- `weekly-leaderboard` para `kills`, `deaths`, `support` y `matches_over_100_kills`
- `recent-matches`
Flags utiles del runner: Flags utiles del runner:
- `--server comunidad-hispana-01` para limitar a un servidor - `--server comunidad-hispana-01` para limitar a un servidor
- `--interval 900` para aumentar frecuencia local - `--interval 900` para fijar la frecuencia recomendada de snapshots
- `--retries 1` para reducir reintentos - `--retries 1` para reducir reintentos
- `--retry-delay 10` para bajar la espera entre fallos - `--retry-delay 10` para bajar la espera entre fallos
- `--max-runs 1` para una validacion puntual sin bucle indefinido - `--max-runs 1` para una validacion puntual sin bucle indefinido
Variables utiles del runner:
- `HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS`
- `HLL_HISTORICAL_REFRESH_MAX_RETRIES`
- `HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS`
Al inicializar la persistencia local, el backend normaliza tambien la identidad Al inicializar la persistencia local, el backend normaliza tambien la identidad
historica ya guardada: historica ya guardada:

View File

@@ -16,6 +16,7 @@ DEFAULT_HISTORICAL_CRCON_DETAIL_WORKERS = 8
DEFAULT_HISTORICAL_CRCON_REQUEST_RETRIES = 3 DEFAULT_HISTORICAL_CRCON_REQUEST_RETRIES = 3
DEFAULT_HISTORICAL_CRCON_RETRY_DELAY_SECONDS = 0.5 DEFAULT_HISTORICAL_CRCON_RETRY_DELAY_SECONDS = 0.5
DEFAULT_HISTORICAL_REFRESH_INTERVAL_SECONDS = 1800 DEFAULT_HISTORICAL_REFRESH_INTERVAL_SECONDS = 1800
DEFAULT_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS = 900
DEFAULT_HISTORICAL_REFRESH_MAX_RETRIES = 2 DEFAULT_HISTORICAL_REFRESH_MAX_RETRIES = 2
DEFAULT_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS = 30 DEFAULT_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS = 30
DEFAULT_ALLOWED_ORIGINS = ( DEFAULT_ALLOWED_ORIGINS = (
@@ -145,12 +146,17 @@ def get_historical_crcon_retry_delay_seconds() -> float:
def get_historical_refresh_interval_seconds() -> int: def get_historical_refresh_interval_seconds() -> int:
"""Return the default interval used by the historical refresh loop.""" """Return the default interval used by the historical refresh loop."""
configured_value = os.getenv( configured_value = os.getenv(
"HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS", "HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS",
str(DEFAULT_HISTORICAL_REFRESH_INTERVAL_SECONDS), os.getenv(
"HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS",
str(DEFAULT_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS),
),
) )
interval_seconds = int(configured_value) interval_seconds = int(configured_value)
if interval_seconds <= 0: if interval_seconds <= 0:
raise ValueError("HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS must be positive.") raise ValueError(
"HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS must be positive."
)
return interval_seconds return interval_seconds

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import time import time
from datetime import datetime, timezone
from typing import Any from typing import Any
from .config import ( from .config import (
@@ -13,6 +14,8 @@ from .config import (
get_historical_refresh_retry_delay_seconds, get_historical_refresh_retry_delay_seconds,
) )
from .historical_ingestion import run_incremental_refresh from .historical_ingestion import run_incremental_refresh
from .historical_snapshot_storage import persist_historical_snapshot_batch
from .historical_snapshots import build_all_historical_snapshots
def run_periodic_historical_refresh( def run_periodic_historical_refresh(
@@ -25,7 +28,7 @@ def run_periodic_historical_refresh(
page_size: int | None = None, page_size: int | None = None,
max_runs: int | None = None, max_runs: int | None = None,
) -> None: ) -> None:
"""Run periodic historical refreshes until interrupted or the limit is reached.""" """Run periodic historical refreshes and rebuild persisted snapshots."""
completed_runs = 0 completed_runs = 0
print( print(
"Starting historical refresh loop " "Starting historical refresh loop "
@@ -65,16 +68,18 @@ def _run_refresh_with_retries(
while True: while True:
attempt += 1 attempt += 1
try: try:
result = run_incremental_refresh( refresh_result = run_incremental_refresh(
server_slug=server_slug, server_slug=server_slug,
max_pages=max_pages, max_pages=max_pages,
page_size=page_size, page_size=page_size,
) )
snapshot_result = generate_historical_snapshots(server_slug=server_slug)
return { return {
"status": "ok", "status": "ok",
"attempts_used": attempt, "attempts_used": attempt,
"max_retries": max_retries, "max_retries": max_retries,
"result": result, "refresh_result": refresh_result,
"snapshot_result": snapshot_result,
} }
except Exception as exc: except Exception as exc:
if attempt > max_retries: if attempt > max_retries:
@@ -88,16 +93,42 @@ def _run_refresh_with_retries(
time.sleep(retry_delay_seconds) time.sleep(retry_delay_seconds)
def generate_historical_snapshots(
*,
server_slug: str | None = None,
) -> dict[str, Any]:
"""Build and persist precomputed snapshots for one server or all servers."""
generated_at = datetime.now(timezone.utc)
snapshots = build_all_historical_snapshots(
server_key=server_slug,
generated_at=generated_at,
)
persisted_records = persist_historical_snapshot_batch(snapshots)
snapshots_by_server: dict[str, int] = {}
for record in persisted_records:
snapshots_by_server.setdefault(record.server_key, 0)
snapshots_by_server[record.server_key] += 1
return {
"generated_at": generated_at.isoformat().replace("+00:00", "Z"),
"server_slug": server_slug,
"snapshot_count": len(persisted_records),
"servers_processed": len(snapshots_by_server),
"snapshots_by_server": snapshots_by_server,
"refresh_interval_seconds": get_historical_refresh_interval_seconds(),
}
def main() -> None: def main() -> None:
"""Allow local scheduled historical refresh execution without external infra.""" """Allow local scheduled historical refresh execution without external infra."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Run periodic historical CRCON refreshes for HLL Vietnam.", description="Run periodic historical refreshes and regenerate snapshots for HLL Vietnam.",
) )
parser.add_argument( parser.add_argument(
"--interval", "--interval",
type=int, type=int,
default=get_historical_refresh_interval_seconds(), default=get_historical_refresh_interval_seconds(),
help="Seconds to wait between incremental refresh runs.", help="Seconds to wait between refresh-plus-snapshot runs.",
) )
parser.add_argument( parser.add_argument(
"--retries", "--retries",

View File

@@ -123,6 +123,31 @@ def persist_historical_snapshot(
) )
def persist_historical_snapshot_batch(
snapshots: list[dict[str, object]],
*,
db_path: Path | None = None,
) -> list[HistoricalSnapshotRecord]:
"""Persist a batch of snapshots generated in one runner cycle."""
records: list[HistoricalSnapshotRecord] = []
for snapshot in snapshots:
records.append(
persist_historical_snapshot(
server_key=str(snapshot["server_key"]),
snapshot_type=str(snapshot["snapshot_type"]),
payload=snapshot["payload"],
metric=snapshot.get("metric"),
window=snapshot.get("window"),
generated_at=snapshot.get("generated_at"),
source_range_start=snapshot.get("source_range_start"),
source_range_end=snapshot.get("source_range_end"),
is_stale=bool(snapshot.get("is_stale", False)),
db_path=db_path,
)
)
return records
def get_historical_snapshot( def get_historical_snapshot(
*, *,
server_key: str, server_key: str,

View File

@@ -2,6 +2,15 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from .historical_storage import (
list_historical_server_summaries,
list_historical_servers,
list_recent_historical_matches,
list_weekly_leaderboard,
)
SNAPSHOT_TYPE_SERVER_SUMMARY = "server-summary" SNAPSHOT_TYPE_SERVER_SUMMARY = "server-summary"
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD = "weekly-leaderboard" SNAPSHOT_TYPE_WEEKLY_LEADERBOARD = "weekly-leaderboard"
@@ -23,9 +32,17 @@ SUPPORTED_LEADERBOARD_METRICS = frozenset(
"matches_over_100_kills", "matches_over_100_kills",
} }
) )
SNAPSHOT_LEADERBOARD_METRICS = (
"kills",
"deaths",
"matches_over_100_kills",
"support",
)
DEFAULT_SNAPSHOT_WINDOW = "all-time" DEFAULT_SNAPSHOT_WINDOW = "all-time"
DEFAULT_WEEKLY_SNAPSHOT_WINDOW = "7d" DEFAULT_WEEKLY_SNAPSHOT_WINDOW = "7d"
DEFAULT_WEEKLY_LEADERBOARD_LIMIT = 10
DEFAULT_RECENT_MATCHES_LIMIT = 20
def validate_snapshot_identity( def validate_snapshot_identity(
@@ -44,3 +61,184 @@ def validate_snapshot_identity(
if metric is not None: if metric is not None:
raise ValueError(f"Metric is only supported for {SNAPSHOT_TYPE_WEEKLY_LEADERBOARD}.") raise ValueError(f"Metric is only supported for {SNAPSHOT_TYPE_WEEKLY_LEADERBOARD}.")
def list_snapshot_server_keys(*, db_path: Path | None = None) -> list[str]:
"""Return the historical server slugs that should receive persisted snapshots."""
return [
str(item["slug"])
for item in list_historical_servers(db_path=db_path)
if item.get("slug")
]
def build_historical_server_snapshots(
*,
server_key: str,
generated_at: datetime | None = None,
leaderboard_limit: int = DEFAULT_WEEKLY_LEADERBOARD_LIMIT,
recent_matches_limit: int = DEFAULT_RECENT_MATCHES_LIMIT,
db_path: Path | None = None,
) -> list[dict[str, object]]:
"""Build all precomputed historical snapshots required for one server."""
generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc))
snapshots = [_build_server_summary_snapshot(server_key, generated_at_value, db_path=db_path)]
for metric in SNAPSHOT_LEADERBOARD_METRICS:
snapshots.append(
_build_weekly_leaderboard_snapshot(
server_key,
metric,
generated_at_value,
limit=leaderboard_limit,
db_path=db_path,
)
)
snapshots.append(
_build_recent_matches_snapshot(
server_key,
generated_at_value,
limit=recent_matches_limit,
db_path=db_path,
)
)
return snapshots
def build_all_historical_snapshots(
*,
server_key: str | None = None,
generated_at: datetime | None = None,
leaderboard_limit: int = DEFAULT_WEEKLY_LEADERBOARD_LIMIT,
recent_matches_limit: int = DEFAULT_RECENT_MATCHES_LIMIT,
db_path: Path | None = None,
) -> list[dict[str, object]]:
"""Build the full snapshot set for one server or for all configured servers."""
target_server_keys = [server_key] if server_key else list_snapshot_server_keys(db_path=db_path)
snapshots: list[dict[str, object]] = []
for target_server_key in target_server_keys:
snapshots.extend(
build_historical_server_snapshots(
server_key=target_server_key,
generated_at=generated_at,
leaderboard_limit=leaderboard_limit,
recent_matches_limit=recent_matches_limit,
db_path=db_path,
)
)
return snapshots
def _build_server_summary_snapshot(
server_key: str,
generated_at: datetime,
*,
db_path: Path | None = None,
) -> dict[str, object]:
summary_items = list_historical_server_summaries(server_slug=server_key, db_path=db_path)
summary_item = summary_items[0] if summary_items else {}
time_range = summary_item.get("time_range") if isinstance(summary_item, dict) else {}
return {
"server_key": server_key,
"snapshot_type": SNAPSHOT_TYPE_SERVER_SUMMARY,
"metric": None,
"window": DEFAULT_SNAPSHOT_WINDOW,
"generated_at": generated_at,
"source_range_start": _parse_optional_timestamp(time_range.get("start")),
"source_range_end": _parse_optional_timestamp(time_range.get("end")),
"is_stale": False,
"payload": {
"server_key": server_key,
"generated_at": _to_iso(generated_at),
"item": summary_item,
},
}
def _build_weekly_leaderboard_snapshot(
server_key: str,
metric: str,
generated_at: datetime,
*,
limit: int,
db_path: Path | None = None,
) -> dict[str, object]:
leaderboard_result = list_weekly_leaderboard(
limit=limit,
server_id=server_key,
metric=metric,
db_path=db_path,
)
return {
"server_key": server_key,
"snapshot_type": SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
"metric": metric,
"window": DEFAULT_WEEKLY_SNAPSHOT_WINDOW,
"generated_at": generated_at,
"source_range_start": _parse_optional_timestamp(leaderboard_result.get("window_start")),
"source_range_end": _parse_optional_timestamp(leaderboard_result.get("window_end")),
"is_stale": False,
"payload": {
"server_key": server_key,
"metric": metric,
"limit": limit,
"generated_at": _to_iso(generated_at),
**leaderboard_result,
},
}
def _build_recent_matches_snapshot(
server_key: str,
generated_at: datetime,
*,
limit: int,
db_path: Path | None = None,
) -> dict[str, object]:
items = list_recent_historical_matches(
limit=limit,
server_slug=server_key,
db_path=db_path,
)
closed_points = [
_parse_optional_timestamp(item.get("closed_at"))
for item in items
if isinstance(item, dict) and item.get("closed_at")
]
return {
"server_key": server_key,
"snapshot_type": SNAPSHOT_TYPE_RECENT_MATCHES,
"metric": None,
"window": DEFAULT_SNAPSHOT_WINDOW,
"generated_at": generated_at,
"source_range_start": min(closed_points) if closed_points else None,
"source_range_end": max(closed_points) if closed_points else None,
"is_stale": False,
"payload": {
"server_key": server_key,
"limit": limit,
"generated_at": _to_iso(generated_at),
"items": items,
},
}
def _parse_optional_timestamp(value: object) -> datetime | None:
if not isinstance(value, str) or not value.strip():
return None
normalized = value.strip().replace("Z", "+00:00")
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _to_iso(value: datetime) -> str:
return _as_utc(value).isoformat().replace("+00:00", "Z")