Automate ranking snapshot refresh
This commit is contained in:
174
ai/tasks/done/TASK-197-automate-ranking-snapshot-refresh.md
Normal file
174
ai/tasks/done/TASK-197-automate-ranking-snapshot-refresh.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
---
|
||||||
|
id: TASK-197-automate-ranking-snapshot-refresh
|
||||||
|
title: Automate ranking snapshot refresh
|
||||||
|
status: done
|
||||||
|
type: backend
|
||||||
|
team: Backend Senior
|
||||||
|
supporting_teams:
|
||||||
|
- Arquitecto de Base de Datos
|
||||||
|
- Arquitecto Python
|
||||||
|
roadmap_item: foundation
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
# TASK-197 - Automate ranking snapshot refresh
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Automate the periodic refresh of weekly and monthly ranking snapshots in PostgreSQL so `/api/ranking` can keep serving `ranking-snapshot` read-model responses for the supported public combinations without relying on manual one-by-one generation.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`TASK-194`, `TASK-195` and `TASK-196` already validated the manual generator path in production:
|
||||||
|
|
||||||
|
- `generate-ranking-snapshot` works
|
||||||
|
- PostgreSQL is the default operational backend
|
||||||
|
- CLI JSON serialization is fixed
|
||||||
|
- the expected `36` combinations were generated successfully
|
||||||
|
- all generated rows ended with `snapshot_status=ready`
|
||||||
|
- `/api/ranking` returns `source.read_model=ranking-snapshot`
|
||||||
|
- `/api/ranking` returns `fallback_used=false` when a ready snapshot exists
|
||||||
|
- `limit_size=30` already covers the current UI limits `10`, `20` and `30`
|
||||||
|
|
||||||
|
The remaining gap is operational automation. The repository already has a periodic backend runner in `backend/app/historical_runner.py`, so the default direction for this task is to integrate ranking snapshot refresh there rather than invent a separate scheduler. If that integration becomes too invasive, the fallback is a controlled bulk CLI/manual helper such as `python -m app.rcon_historical_leaderboards refresh-ranking-snapshots --limit 30`, with the periodic integration left prepared for the next step.
|
||||||
|
|
||||||
|
Preserve the current product identity: Spanish-speaking HLL Vietnam community, military/Vietnam/tactical/sober visual direction and controlled repository evolution.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Read the listed files first.
|
||||||
|
2. Inspect the existing historical runner and ranking snapshot generator entrypoints.
|
||||||
|
3. Add the smallest safe backend mechanism that refreshes the weekly/monthly ranking snapshot matrix in PostgreSQL.
|
||||||
|
4. Generate the supported matrix with per-combination error reporting so one failure does not necessarily abort the entire refresh.
|
||||||
|
5. Keep the manual single-snapshot CLI working.
|
||||||
|
6. Preserve `/api/ranking` snapshot-first behavior and runtime fallback when a snapshot is missing.
|
||||||
|
7. Document the recommended refresh cadence and manual operational commands.
|
||||||
|
|
||||||
|
## Files to Read First
|
||||||
|
|
||||||
|
- `AGENTS.md`
|
||||||
|
- `ai/repo-context.md`
|
||||||
|
- `ai/architecture-index.md`
|
||||||
|
- `backend/app/rcon_historical_leaderboards.py`
|
||||||
|
- `backend/app/historical_runner.py`
|
||||||
|
- `backend/app/rcon_historical_worker.py`
|
||||||
|
- `backend/app/payloads.py`
|
||||||
|
- `docs/ranking-snapshot-read-model-plan.md`
|
||||||
|
- `scripts/run-stats-validation.ps1`
|
||||||
|
- `ai/tasks/done/TASK-194-add-weekly-monthly-ranking-snapshot-generator.md`
|
||||||
|
- `ai/tasks/done/TASK-195-fix-ranking-snapshot-generator-postgres-default.md`
|
||||||
|
- `ai/tasks/done/TASK-196-fix-ranking-snapshot-cli-json-serialization.md`
|
||||||
|
|
||||||
|
## Expected Files to Modify
|
||||||
|
|
||||||
|
- `backend/app/rcon_historical_leaderboards.py`
|
||||||
|
- `backend/app/historical_runner.py`
|
||||||
|
- `backend/app/payloads.py`
|
||||||
|
- `docs/ranking-snapshot-read-model-plan.md`
|
||||||
|
- `scripts/run-stats-validation.ps1`
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Keep the change minimal and backend-only.
|
||||||
|
- Do not modify frontend.
|
||||||
|
- Do not change design.
|
||||||
|
- Do not touch images or assets.
|
||||||
|
- Use PostgreSQL as the operational storage target.
|
||||||
|
- Do not depend on SQLite for the automated refresh path.
|
||||||
|
- Do not change the public `/api/ranking` contract except internal metadata already present if strictly needed.
|
||||||
|
- Do not break the manual `generate-ranking-snapshot` CLI.
|
||||||
|
- Keep runtime fallback as a safety net when a snapshot is missing.
|
||||||
|
- Do not reactivate Elo/MMR.
|
||||||
|
- Do not reintroduce Comunidad Hispana #03.
|
||||||
|
- Do not introduce an invasive scheduler architecture if the existing runner can safely host the refresh.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Before completing the task ensure:
|
||||||
|
|
||||||
|
- `powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1`
|
||||||
|
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
|
||||||
|
- the automated or bulk refresh path generates the expected `36` combinations:
|
||||||
|
- timeframes: `weekly`, `monthly`
|
||||||
|
- servers: `all`, `comunidad-hispana-01`, `comunidad-hispana-02`
|
||||||
|
- metrics: `kills`, `deaths`, `teamkills`, `matches_considered`, `kd_ratio`, `kills_per_match`
|
||||||
|
- limit: `30`
|
||||||
|
- validation proves per-combination failures are reported and do not necessarily stop every remaining combination
|
||||||
|
- validation proves the manual CLI `generate-ranking-snapshot` still works
|
||||||
|
- validation proves `/api/ranking` still serves `snapshot_status=ready` and `read_model=ranking-snapshot` when ready snapshots exist
|
||||||
|
- validation proves runtime fallback remains available for missing snapshots
|
||||||
|
- `git diff --name-only` matches the expected scope
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Implemented:
|
||||||
|
|
||||||
|
- `backend/app/rcon_historical_leaderboards.py`
|
||||||
|
- added `refresh_ranking_snapshots(...)` to generate the full weekly/monthly matrix in one run
|
||||||
|
- added bulk CLI entrypoint:
|
||||||
|
- `python -m app.rcon_historical_leaderboards refresh-ranking-snapshots --limit 30`
|
||||||
|
- preserved the existing manual CLI entrypoint:
|
||||||
|
- `python -m app.rcon_historical_leaderboards generate-ranking-snapshot --timeframe weekly --server-key all --metric kills --limit 30`
|
||||||
|
- `backend/app/historical_runner.py`
|
||||||
|
- integrated ranking snapshot refresh into the periodic historical backend cycle through `refresh_periodic_ranking_snapshots(...)`
|
||||||
|
- the cycle now reports `ranking_snapshot_result` separately from the existing historical snapshot result
|
||||||
|
- `docs/ranking-snapshot-read-model-plan.md`
|
||||||
|
- documents the new bulk command
|
||||||
|
- documents the runner integration direction
|
||||||
|
- documents `limit=30` as the operational default for the weekly/monthly matrix
|
||||||
|
- `scripts/run-stats-validation.ps1`
|
||||||
|
- validates the bulk CLI argument path
|
||||||
|
- validates that bulk refresh covers `36` combinations
|
||||||
|
- validates partial failure reporting without aborting the remaining combinations
|
||||||
|
- keeps validating the existing manual `generate-ranking-snapshot` path
|
||||||
|
|
||||||
|
Generated matrix:
|
||||||
|
|
||||||
|
- timeframes: `weekly`, `monthly`
|
||||||
|
- servers: `all`, `comunidad-hispana-01`, `comunidad-hispana-02`
|
||||||
|
- metrics: `kills`, `deaths`, `teamkills`, `matches_considered`, `kd_ratio`, `kills_per_match`
|
||||||
|
- limit: `30`
|
||||||
|
- total combinations per refresh cycle: `36`
|
||||||
|
|
||||||
|
Per-combination failure behavior:
|
||||||
|
|
||||||
|
- bulk refresh catches exceptions per combination
|
||||||
|
- the full refresh result returns `status=partial` when at least one combination fails and at least one succeeds
|
||||||
|
- each failed combination reports:
|
||||||
|
- `timeframe`
|
||||||
|
- `server_key`
|
||||||
|
- `metric`
|
||||||
|
- `error_type`
|
||||||
|
- `error`
|
||||||
|
- a single failure does not abort the remaining combinations
|
||||||
|
|
||||||
|
Recommended frequency:
|
||||||
|
|
||||||
|
- weekly current window: every `5` to `15` minutes
|
||||||
|
- monthly current window: every `15` to `30` minutes
|
||||||
|
- closed previous week / previous month windows: once after closure or after historical backfill
|
||||||
|
|
||||||
|
Validations executed:
|
||||||
|
|
||||||
|
- `python -m compileall backend/app/rcon_historical_leaderboards.py backend/app/historical_runner.py`
|
||||||
|
- `powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1`
|
||||||
|
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
|
||||||
|
- isolated real CLI validation against temporary SQLite files outside the workspace:
|
||||||
|
- `refresh-ranking-snapshots --limit 30` returned `combinations_expected=36`, `succeeded=36`, `failed=0`
|
||||||
|
- `generate-ranking-snapshot` still returned `status=ok` and `snapshot_status=ready`
|
||||||
|
|
||||||
|
Validation notes:
|
||||||
|
|
||||||
|
- live backend HTTP at `http://127.0.0.1:8000` was not available in this environment
|
||||||
|
- `/api/ranking` snapshot-ready and fallback behavior remained validated through the existing local route-contract checks in `scripts/run-stats-validation.ps1`
|
||||||
|
- no frontend, design, asset, Elo/MMR or Comunidad Hispana `#03` changes were made
|
||||||
|
|
||||||
|
Pending limitation kept out of scope:
|
||||||
|
|
||||||
|
- this task integrates the refresh into the existing periodic historical runner and adds a bulk CLI, but it does not introduce a separate external scheduler or deployment-specific cron wiring
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -29,6 +29,7 @@ from .historical_snapshots import (
|
|||||||
generate_and_persist_historical_snapshots,
|
generate_and_persist_historical_snapshots,
|
||||||
generate_and_persist_priority_historical_snapshots,
|
generate_and_persist_priority_historical_snapshots,
|
||||||
)
|
)
|
||||||
|
from .rcon_historical_leaderboards import refresh_ranking_snapshots
|
||||||
from .rcon_historical_storage import count_rcon_historical_samples_since
|
from .rcon_historical_storage import count_rcon_historical_samples_since
|
||||||
from .rcon_historical_worker import run_rcon_historical_capture
|
from .rcon_historical_worker import run_rcon_historical_capture
|
||||||
from .writer_lock import backend_writer_lock, build_writer_lock_holder
|
from .writer_lock import backend_writer_lock, build_writer_lock_holder
|
||||||
@@ -176,6 +177,10 @@ def _run_refresh_with_retries(
|
|||||||
rcon_capture_result=rcon_capture_result
|
rcon_capture_result=rcon_capture_result
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
ranking_snapshot_result = refresh_periodic_ranking_snapshots(
|
||||||
|
server_slug=server_slug,
|
||||||
|
run_number=run_number,
|
||||||
|
)
|
||||||
maintenance_result = _maybe_run_database_maintenance()
|
maintenance_result = _maybe_run_database_maintenance()
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
@@ -186,6 +191,7 @@ def _run_refresh_with_retries(
|
|||||||
"classic_fallback_reason": classic_fallback_reason,
|
"classic_fallback_reason": classic_fallback_reason,
|
||||||
"refresh_result": refresh_result,
|
"refresh_result": refresh_result,
|
||||||
"snapshot_result": snapshot_result,
|
"snapshot_result": snapshot_result,
|
||||||
|
"ranking_snapshot_result": ranking_snapshot_result,
|
||||||
"elo_mmr_result": elo_mmr_result,
|
"elo_mmr_result": elo_mmr_result,
|
||||||
"database_maintenance_result": maintenance_result,
|
"database_maintenance_result": maintenance_result,
|
||||||
}
|
}
|
||||||
@@ -251,6 +257,35 @@ def generate_historical_snapshots(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_periodic_ranking_snapshots(
|
||||||
|
*,
|
||||||
|
server_slug: str | None = None,
|
||||||
|
run_number: int = 1,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Refresh the public weekly/monthly ranking snapshot matrix for the current cycle."""
|
||||||
|
_emit_json_log(
|
||||||
|
{
|
||||||
|
"event": "ranking-snapshot-refresh-started",
|
||||||
|
"run_number": run_number,
|
||||||
|
"server_slug": server_slug,
|
||||||
|
"snapshot_scope": _describe_snapshot_scope(server_slug),
|
||||||
|
"ranking_snapshot_limit": 30,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = refresh_ranking_snapshots(limit=30)
|
||||||
|
return {
|
||||||
|
**result,
|
||||||
|
"run_number": run_number,
|
||||||
|
"refresh_interval_seconds": get_historical_refresh_interval_seconds(),
|
||||||
|
"server_slug": server_slug,
|
||||||
|
"generation_policy": "periodic-historical-refresh-cycle",
|
||||||
|
"recommended_frequency": {
|
||||||
|
"weekly": "5-15-minutes",
|
||||||
|
"monthly": "15-30-minutes",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _emit_json_log(payload: dict[str, Any]) -> None:
|
def _emit_json_log(payload: dict[str, Any]) -> None:
|
||||||
"""Print JSON logs that remain safe for Compose and log collectors."""
|
"""Print JSON logs that remain safe for Compose and log collectors."""
|
||||||
print(json.dumps(payload, ensure_ascii=True, default=str), flush=True)
|
print(json.dumps(payload, ensure_ascii=True, default=str), flush=True)
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ SNAPSHOT_GENERATOR_METRICS = (
|
|||||||
"kd_ratio",
|
"kd_ratio",
|
||||||
"kills_per_match",
|
"kills_per_match",
|
||||||
)
|
)
|
||||||
|
DEFAULT_RANKING_SNAPSHOT_REFRESH_LIMIT = 30
|
||||||
|
|
||||||
RANKING_SNAPSHOT_SQLITE_SCHEMA_SQL = """
|
RANKING_SNAPSHOT_SQLITE_SCHEMA_SQL = """
|
||||||
CREATE TABLE IF NOT EXISTS ranking_snapshots (
|
CREATE TABLE IF NOT EXISTS ranking_snapshots (
|
||||||
@@ -219,6 +220,98 @@ def generate_ranking_snapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_ranking_snapshots(
|
||||||
|
*,
|
||||||
|
limit: int = DEFAULT_RANKING_SNAPSHOT_REFRESH_LIMIT,
|
||||||
|
replace_existing: bool = True,
|
||||||
|
now: datetime | None = None,
|
||||||
|
db_path: Path | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Generate the full weekly/monthly ranking snapshot matrix with partial-failure reporting."""
|
||||||
|
normalized_limit = _normalize_limit(limit)
|
||||||
|
anchor = _as_utc(now or datetime.now(timezone.utc))
|
||||||
|
combinations = [
|
||||||
|
(timeframe, server_key, metric)
|
||||||
|
for timeframe in SNAPSHOT_GENERATOR_TIMEFRAMES
|
||||||
|
for server_key in SNAPSHOT_GENERATOR_SERVER_KEYS
|
||||||
|
for metric in SNAPSHOT_GENERATOR_METRICS
|
||||||
|
]
|
||||||
|
results: list[dict[str, object]] = []
|
||||||
|
succeeded = 0
|
||||||
|
failed = 0
|
||||||
|
skipped_regeneration = 0
|
||||||
|
|
||||||
|
for timeframe, server_key, metric in combinations:
|
||||||
|
try:
|
||||||
|
payload = generate_ranking_snapshot(
|
||||||
|
timeframe=timeframe,
|
||||||
|
server_key=server_key,
|
||||||
|
metric=metric,
|
||||||
|
limit=normalized_limit,
|
||||||
|
replace_existing=replace_existing,
|
||||||
|
now=anchor,
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
snapshot = payload.get("snapshot") if isinstance(payload, dict) else {}
|
||||||
|
skipped = bool(payload.get("skipped_regeneration")) if isinstance(payload, dict) else False
|
||||||
|
if skipped:
|
||||||
|
skipped_regeneration += 1
|
||||||
|
succeeded += 1
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"server_key": server_key,
|
||||||
|
"metric": metric,
|
||||||
|
"limit": normalized_limit,
|
||||||
|
"snapshot_id": snapshot.get("id") if isinstance(snapshot, dict) else None,
|
||||||
|
"snapshot_status": snapshot.get("snapshot_status")
|
||||||
|
if isinstance(snapshot, dict)
|
||||||
|
else None,
|
||||||
|
"window_start": snapshot.get("window_start") if isinstance(snapshot, dict) else None,
|
||||||
|
"window_end": snapshot.get("window_end") if isinstance(snapshot, dict) else None,
|
||||||
|
"generated_at": snapshot.get("generated_at") if isinstance(snapshot, dict) else None,
|
||||||
|
"ranked_players": int(payload.get("ranked_players") or 0),
|
||||||
|
"source_matches_count": int(payload.get("source_matches_count") or 0),
|
||||||
|
"skipped_regeneration": skipped,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - bulk refresh must report per-combination failures
|
||||||
|
failed += 1
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"server_key": server_key,
|
||||||
|
"metric": metric,
|
||||||
|
"limit": normalized_limit,
|
||||||
|
"error_type": type(exc).__name__,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
overall_status = "ok"
|
||||||
|
if failed and succeeded:
|
||||||
|
overall_status = "partial"
|
||||||
|
elif failed:
|
||||||
|
overall_status = "error"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": overall_status,
|
||||||
|
"generated_at": _to_iso(anchor),
|
||||||
|
"limit": normalized_limit,
|
||||||
|
"replace_existing": replace_existing,
|
||||||
|
"combinations_expected": len(combinations),
|
||||||
|
"totals": {
|
||||||
|
"combinations_expected": len(combinations),
|
||||||
|
"succeeded": succeeded,
|
||||||
|
"failed": failed,
|
||||||
|
"skipped_regeneration": skipped_regeneration,
|
||||||
|
},
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_latest_ranking_snapshot(
|
def get_latest_ranking_snapshot(
|
||||||
*,
|
*,
|
||||||
server_key: str | None = None,
|
server_key: str | None = None,
|
||||||
@@ -1275,7 +1368,25 @@ def _main(argv: list[str] | None = None) -> int:
|
|||||||
dest="replace_existing",
|
dest="replace_existing",
|
||||||
help="keep an existing snapshot for the selected exact window and metric scope",
|
help="keep an existing snapshot for the selected exact window and metric scope",
|
||||||
)
|
)
|
||||||
parser.set_defaults(command="generate-ranking-snapshot", replace_existing=True)
|
refresh_parser = subparsers.add_parser("refresh-ranking-snapshots")
|
||||||
|
refresh_parser.add_argument(
|
||||||
|
"--limit",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_RANKING_SNAPSHOT_REFRESH_LIMIT,
|
||||||
|
)
|
||||||
|
refresh_parser.add_argument(
|
||||||
|
"--sqlite-path",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="explicit local SQLite override; default operational mode uses PostgreSQL when configured",
|
||||||
|
)
|
||||||
|
refresh_parser.add_argument(
|
||||||
|
"--no-replace-existing",
|
||||||
|
action="store_false",
|
||||||
|
dest="replace_existing",
|
||||||
|
help="keep an existing snapshot for the selected exact window and metric scope",
|
||||||
|
)
|
||||||
|
parser.set_defaults(replace_existing=True)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
if args.command == "generate-ranking-snapshot":
|
if args.command == "generate-ranking-snapshot":
|
||||||
@@ -1297,6 +1408,22 @@ def _main(argv: list[str] | None = None) -> int:
|
|||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if args.command == "refresh-ranking-snapshots":
|
||||||
|
payload = refresh_ranking_snapshots(
|
||||||
|
limit=args.limit,
|
||||||
|
replace_existing=args.replace_existing,
|
||||||
|
db_path=args.sqlite_path,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{"status": "ok", "data": payload},
|
||||||
|
ensure_ascii=True,
|
||||||
|
indent=2,
|
||||||
|
default=_json_default,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
|
|||||||
@@ -253,6 +253,12 @@ Manual generator entrypoint:
|
|||||||
python -m app.rcon_historical_leaderboards generate-ranking-snapshot --timeframe weekly --server-key all --metric kills --limit 20
|
python -m app.rcon_historical_leaderboards generate-ranking-snapshot --timeframe weekly --server-key all --metric kills --limit 20
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Bulk refresh entrypoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m app.rcon_historical_leaderboards refresh-ranking-snapshots --limit 30
|
||||||
|
```
|
||||||
|
|
||||||
Operational default:
|
Operational default:
|
||||||
- when `HLL_BACKEND_DATABASE_URL` is configured, the CLI uses PostgreSQL by default
|
- when `HLL_BACKEND_DATABASE_URL` is configured, the CLI uses PostgreSQL by default
|
||||||
- SQLite is no longer the default operational target for snapshot generation
|
- SQLite is no longer the default operational target for snapshot generation
|
||||||
@@ -268,6 +274,12 @@ Docker form:
|
|||||||
docker compose exec backend python -m app.rcon_historical_leaderboards generate-ranking-snapshot --timeframe weekly --server-key all --metric kills --limit 20
|
docker compose exec backend python -m app.rcon_historical_leaderboards generate-ranking-snapshot --timeframe weekly --server-key all --metric kills --limit 20
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Bulk Docker form:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec backend python -m app.rcon_historical_leaderboards refresh-ranking-snapshots --limit 30
|
||||||
|
```
|
||||||
|
|
||||||
Operational expectation:
|
Operational expectation:
|
||||||
- the recommended Docker command should generate weekly/monthly snapshots in PostgreSQL, matching the `/api/ranking` production read path
|
- the recommended Docker command should generate weekly/monthly snapshots in PostgreSQL, matching the `/api/ranking` production read path
|
||||||
|
|
||||||
@@ -278,9 +290,10 @@ Supported manual parameters:
|
|||||||
- `limit`: positive integer, normally `20`
|
- `limit`: positive integer, normally `20`
|
||||||
|
|
||||||
Current implementation note:
|
Current implementation note:
|
||||||
- V1 generator is unitary per command invocation
|
- `generate-ranking-snapshot` remains unitary per command invocation for explicit manual control
|
||||||
- operators should run one command per required `(timeframe, server-key, metric)` combination
|
- `refresh-ranking-snapshots --limit 30` generates the full weekly/monthly public matrix in one run
|
||||||
- broad matrix generation can remain a later operational helper if scheduling needs justify it
|
- the periodic historical runner in `backend/app/historical_runner.py` should invoke that bulk refresh as part of the normal backend refresh cycle
|
||||||
|
- per-combination failures should be reported without aborting the entire matrix refresh
|
||||||
|
|
||||||
## Recommended Combinations
|
## Recommended Combinations
|
||||||
|
|
||||||
@@ -294,6 +307,10 @@ Minimum production matrix for parity with the public Ranking filters:
|
|||||||
|
|
||||||
That matrix requires `36` snapshot generations for each refresh cycle.
|
That matrix requires `36` snapshot generations for each refresh cycle.
|
||||||
|
|
||||||
|
Operational default limit:
|
||||||
|
- generate each combination with `limit=30`
|
||||||
|
- that stored `limit_size=30` covers the current UI request limits `10`, `20` and `30`
|
||||||
|
|
||||||
## Suggested Frequency
|
## Suggested Frequency
|
||||||
|
|
||||||
Suggested operator cadence:
|
Suggested operator cadence:
|
||||||
@@ -305,6 +322,7 @@ Operational guidance:
|
|||||||
- regenerate after materialized RCON/AdminLog data grows
|
- regenerate after materialized RCON/AdminLog data grows
|
||||||
- regenerate after manual backfill
|
- regenerate after manual backfill
|
||||||
- regenerate after metric SQL changes that affect ranking totals or ordering
|
- regenerate after metric SQL changes that affect ranking totals or ordering
|
||||||
|
- when using the periodic backend runner, keep ranking refresh attached to the same recurring cycle rather than a separate scheduler unless operational load proves otherwise
|
||||||
|
|
||||||
## Ready Vs Fallback
|
## Ready Vs Fallback
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ import app.rcon_historical_leaderboards as ranking_leaderboards
|
|||||||
initialize_ranking_snapshot_storage = ranking_leaderboards.initialize_ranking_snapshot_storage
|
initialize_ranking_snapshot_storage = ranking_leaderboards.initialize_ranking_snapshot_storage
|
||||||
generate_ranking_snapshot = ranking_leaderboards.generate_ranking_snapshot
|
generate_ranking_snapshot = ranking_leaderboards.generate_ranking_snapshot
|
||||||
get_latest_ranking_snapshot = ranking_leaderboards.get_latest_ranking_snapshot
|
get_latest_ranking_snapshot = ranking_leaderboards.get_latest_ranking_snapshot
|
||||||
|
refresh_ranking_snapshots = ranking_leaderboards.refresh_ranking_snapshots
|
||||||
|
|
||||||
|
|
||||||
def require(condition, message):
|
def require(condition, message):
|
||||||
@@ -289,6 +290,7 @@ def validate_postgres_ranking_snapshot_schema_path():
|
|||||||
|
|
||||||
def validate_ranking_snapshot_cli_defaults():
|
def validate_ranking_snapshot_cli_defaults():
|
||||||
original_generate_ranking_snapshot = ranking_leaderboards.generate_ranking_snapshot
|
original_generate_ranking_snapshot = ranking_leaderboards.generate_ranking_snapshot
|
||||||
|
original_refresh_ranking_snapshots = ranking_leaderboards.refresh_ranking_snapshots
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
def fake_generate_ranking_snapshot(**kwargs):
|
def fake_generate_ranking_snapshot(**kwargs):
|
||||||
@@ -345,8 +347,157 @@ def validate_ranking_snapshot_cli_defaults():
|
|||||||
captured.get("db_path") == Path("backend/data/hll_vietnam_dev.sqlite3"),
|
captured.get("db_path") == Path("backend/data/hll_vietnam_dev.sqlite3"),
|
||||||
"Ranking snapshot CLI should pass the explicit --sqlite-path override through to generate_ranking_snapshot.",
|
"Ranking snapshot CLI should pass the explicit --sqlite-path override through to generate_ranking_snapshot.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
captured.clear()
|
||||||
|
|
||||||
|
def fake_refresh_ranking_snapshots(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"generated_at": datetime(2026, 6, 9, 8, 0, 0, tzinfo=timezone.utc),
|
||||||
|
"combinations_expected": 36,
|
||||||
|
"totals": {"combinations_expected": 36, "succeeded": 36, "failed": 0, "skipped_regeneration": 0},
|
||||||
|
"results": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
ranking_leaderboards.refresh_ranking_snapshots = fake_refresh_ranking_snapshots
|
||||||
|
|
||||||
|
stdout_buffer = StringIO()
|
||||||
|
with redirect_stdout(stdout_buffer):
|
||||||
|
exit_code = ranking_leaderboards._main([
|
||||||
|
"refresh-ranking-snapshots",
|
||||||
|
"--limit", "30",
|
||||||
|
])
|
||||||
|
require(exit_code == 0, "Ranking snapshot bulk CLI should exit 0 for a valid command.")
|
||||||
|
require(
|
||||||
|
captured.get("db_path") is None,
|
||||||
|
"Ranking snapshot bulk CLI should use PostgreSQL-compatible db_path=None by default.",
|
||||||
|
)
|
||||||
|
require(
|
||||||
|
captured.get("limit") == 30,
|
||||||
|
"Ranking snapshot bulk CLI should preserve the requested limit.",
|
||||||
|
)
|
||||||
|
|
||||||
|
captured.clear()
|
||||||
|
stdout_buffer = StringIO()
|
||||||
|
with redirect_stdout(stdout_buffer):
|
||||||
|
exit_code = ranking_leaderboards._main([
|
||||||
|
"refresh-ranking-snapshots",
|
||||||
|
"--limit", "30",
|
||||||
|
"--sqlite-path", "backend/data/hll_vietnam_dev.sqlite3",
|
||||||
|
])
|
||||||
|
require(exit_code == 0, "Ranking snapshot bulk CLI with --sqlite-path should exit 0.")
|
||||||
|
require(
|
||||||
|
captured.get("db_path") == Path("backend/data/hll_vietnam_dev.sqlite3"),
|
||||||
|
"Ranking snapshot bulk CLI should pass the explicit --sqlite-path override through to refresh_ranking_snapshots.",
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
ranking_leaderboards.generate_ranking_snapshot = original_generate_ranking_snapshot
|
ranking_leaderboards.generate_ranking_snapshot = original_generate_ranking_snapshot
|
||||||
|
ranking_leaderboards.refresh_ranking_snapshots = original_refresh_ranking_snapshots
|
||||||
|
|
||||||
|
|
||||||
|
def validate_ranking_snapshot_bulk_refresh():
|
||||||
|
original_generate_ranking_snapshot = ranking_leaderboards.generate_ranking_snapshot
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_generate_ranking_snapshot(**kwargs):
|
||||||
|
calls.append(kwargs)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"snapshot": {
|
||||||
|
"id": len(calls),
|
||||||
|
"snapshot_status": "ready",
|
||||||
|
"window_start": "2026-06-01T00:00:00Z",
|
||||||
|
"window_end": "2026-06-09T00:00:00Z",
|
||||||
|
"generated_at": "2026-06-09T08:00:00Z",
|
||||||
|
},
|
||||||
|
"source_matches_count": 4,
|
||||||
|
"ranked_players": 3,
|
||||||
|
"skipped_regeneration": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
ranking_leaderboards.generate_ranking_snapshot = fake_generate_ranking_snapshot
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = refresh_ranking_snapshots(limit=30)
|
||||||
|
finally:
|
||||||
|
ranking_leaderboards.generate_ranking_snapshot = original_generate_ranking_snapshot
|
||||||
|
|
||||||
|
require(payload.get("status") == "ok", "Ranking snapshot bulk refresh should return ok when all combinations succeed.")
|
||||||
|
require(payload.get("combinations_expected") == 36, "Ranking snapshot bulk refresh should expect 36 combinations.")
|
||||||
|
totals = payload.get("totals") or {}
|
||||||
|
require(totals.get("succeeded") == 36, "Ranking snapshot bulk refresh should report 36 successful combinations.")
|
||||||
|
require(totals.get("failed") == 0, "Ranking snapshot bulk refresh should report zero failed combinations when all succeed.")
|
||||||
|
require(len(payload.get("results") or []) == 36, "Ranking snapshot bulk refresh should report 36 result entries.")
|
||||||
|
require(len(calls) == 36, "Ranking snapshot bulk refresh should invoke generate_ranking_snapshot 36 times.")
|
||||||
|
|
||||||
|
seen = {
|
||||||
|
(call.get("timeframe"), call.get("server_key"), call.get("metric"), call.get("limit"))
|
||||||
|
for call in calls
|
||||||
|
}
|
||||||
|
require(len(seen) == 36, "Ranking snapshot bulk refresh should cover 36 unique timeframe/server/metric combinations.")
|
||||||
|
require(
|
||||||
|
("weekly", "all-servers", "kills", 30) in seen,
|
||||||
|
"Ranking snapshot bulk refresh should include weekly/all/kills with limit 30.",
|
||||||
|
)
|
||||||
|
require(
|
||||||
|
("monthly", "comunidad-hispana-02", "kills_per_match", 30) in seen,
|
||||||
|
"Ranking snapshot bulk refresh should include monthly/comunidad-hispana-02/kills_per_match with limit 30.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_ranking_snapshot_bulk_partial_failure():
|
||||||
|
original_generate_ranking_snapshot = ranking_leaderboards.generate_ranking_snapshot
|
||||||
|
|
||||||
|
def fake_generate_ranking_snapshot(**kwargs):
|
||||||
|
if (
|
||||||
|
kwargs.get("timeframe") == "monthly"
|
||||||
|
and kwargs.get("server_key") == "comunidad-hispana-02"
|
||||||
|
and kwargs.get("metric") == "kd_ratio"
|
||||||
|
):
|
||||||
|
raise RuntimeError("forced bulk refresh validation failure")
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"snapshot": {
|
||||||
|
"id": 1,
|
||||||
|
"snapshot_status": "ready",
|
||||||
|
"window_start": "2026-06-01T00:00:00Z",
|
||||||
|
"window_end": "2026-06-09T00:00:00Z",
|
||||||
|
"generated_at": "2026-06-09T08:00:00Z",
|
||||||
|
},
|
||||||
|
"source_matches_count": 4,
|
||||||
|
"ranked_players": 3,
|
||||||
|
"skipped_regeneration": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
ranking_leaderboards.generate_ranking_snapshot = fake_generate_ranking_snapshot
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = refresh_ranking_snapshots(limit=30)
|
||||||
|
finally:
|
||||||
|
ranking_leaderboards.generate_ranking_snapshot = original_generate_ranking_snapshot
|
||||||
|
|
||||||
|
require(payload.get("status") == "partial", "Ranking snapshot bulk refresh should return partial when one combination fails.")
|
||||||
|
totals = payload.get("totals") or {}
|
||||||
|
require(totals.get("succeeded") == 35, "Ranking snapshot bulk refresh should continue after one failure and still report the remaining 35 successes.")
|
||||||
|
require(totals.get("failed") == 1, "Ranking snapshot bulk refresh should report exactly one failed combination in the forced partial-failure test.")
|
||||||
|
error_results = [
|
||||||
|
result
|
||||||
|
for result in (payload.get("results") or [])
|
||||||
|
if isinstance(result, dict) and result.get("status") == "error"
|
||||||
|
]
|
||||||
|
require(len(error_results) == 1, "Ranking snapshot bulk refresh should surface one explicit error result in the forced partial-failure test.")
|
||||||
|
forced_error = error_results[0]
|
||||||
|
require(
|
||||||
|
forced_error.get("timeframe") == "monthly"
|
||||||
|
and forced_error.get("server_key") == "comunidad-hispana-02"
|
||||||
|
and forced_error.get("metric") == "kd_ratio",
|
||||||
|
"Ranking snapshot bulk refresh should preserve the failing combination coordinates in error reporting.",
|
||||||
|
)
|
||||||
|
require(
|
||||||
|
forced_error.get("error_type") == "RuntimeError",
|
||||||
|
"Ranking snapshot bulk refresh should expose the failing error type.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_ranking_snapshot_postgres_selection():
|
def validate_ranking_snapshot_postgres_selection():
|
||||||
@@ -431,6 +582,8 @@ require(health_payload.get("status") == "ok", "/health payload should be ok.")
|
|||||||
validate_postgres_ranking_snapshot_schema_path()
|
validate_postgres_ranking_snapshot_schema_path()
|
||||||
validate_ranking_snapshot_cli_defaults()
|
validate_ranking_snapshot_cli_defaults()
|
||||||
validate_ranking_snapshot_postgres_selection()
|
validate_ranking_snapshot_postgres_selection()
|
||||||
|
validate_ranking_snapshot_bulk_refresh()
|
||||||
|
validate_ranking_snapshot_bulk_partial_failure()
|
||||||
|
|
||||||
kd_metric_sql, _, _ = ranking_leaderboards._resolve_metric_sql("kd_ratio")
|
kd_metric_sql, _, _ = ranking_leaderboards._resolve_metric_sql("kd_ratio")
|
||||||
require(
|
require(
|
||||||
@@ -803,7 +956,10 @@ print(json.dumps({
|
|||||||
"postgres-ranking-derived-metric-sql",
|
"postgres-ranking-derived-metric-sql",
|
||||||
"postgres-ranking-schema-path",
|
"postgres-ranking-schema-path",
|
||||||
"ranking-snapshot-cli-postgres-default",
|
"ranking-snapshot-cli-postgres-default",
|
||||||
|
"ranking-snapshot-bulk-cli",
|
||||||
"ranking-snapshot-postgres-selection",
|
"ranking-snapshot-postgres-selection",
|
||||||
|
"ranking-snapshot-bulk-refresh",
|
||||||
|
"ranking-snapshot-bulk-partial-failure",
|
||||||
"ranking-snapshot-generator",
|
"ranking-snapshot-generator",
|
||||||
"ranking-snapshot-ready",
|
"ranking-snapshot-ready",
|
||||||
"ranking-snapshot-missing",
|
"ranking-snapshot-missing",
|
||||||
|
|||||||
Reference in New Issue
Block a user