Automate ranking snapshot refresh

This commit is contained in:
devRaGonSa
2026-06-09 09:56:51 +02:00
parent 958609c74f
commit 3834662b23
5 changed files with 514 additions and 4 deletions

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

View File

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

View File

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

View File

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

View File

@@ -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",