diff --git a/ai/tasks/done/TASK-197-automate-ranking-snapshot-refresh.md b/ai/tasks/done/TASK-197-automate-ranking-snapshot-refresh.md new file mode 100644 index 0000000..85a8d78 --- /dev/null +++ b/ai/tasks/done/TASK-197-automate-ranking-snapshot-refresh.md @@ -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. diff --git a/backend/app/historical_runner.py b/backend/app/historical_runner.py index 10edced..74b72ee 100644 --- a/backend/app/historical_runner.py +++ b/backend/app/historical_runner.py @@ -29,6 +29,7 @@ from .historical_snapshots import ( generate_and_persist_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_worker import run_rcon_historical_capture 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 ), } + ranking_snapshot_result = refresh_periodic_ranking_snapshots( + server_slug=server_slug, + run_number=run_number, + ) maintenance_result = _maybe_run_database_maintenance() return { "status": "ok", @@ -186,6 +191,7 @@ def _run_refresh_with_retries( "classic_fallback_reason": classic_fallback_reason, "refresh_result": refresh_result, "snapshot_result": snapshot_result, + "ranking_snapshot_result": ranking_snapshot_result, "elo_mmr_result": elo_mmr_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: """Print JSON logs that remain safe for Compose and log collectors.""" print(json.dumps(payload, ensure_ascii=True, default=str), flush=True) diff --git a/backend/app/rcon_historical_leaderboards.py b/backend/app/rcon_historical_leaderboards.py index fe2aac4..7e547c9 100644 --- a/backend/app/rcon_historical_leaderboards.py +++ b/backend/app/rcon_historical_leaderboards.py @@ -44,6 +44,7 @@ SNAPSHOT_GENERATOR_METRICS = ( "kd_ratio", "kills_per_match", ) +DEFAULT_RANKING_SNAPSHOT_REFRESH_LIMIT = 30 RANKING_SNAPSHOT_SQLITE_SCHEMA_SQL = """ 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( *, server_key: str | None = None, @@ -1275,7 +1368,25 @@ def _main(argv: list[str] | None = None) -> int: dest="replace_existing", 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) if args.command == "generate-ranking-snapshot": @@ -1297,6 +1408,22 @@ def _main(argv: list[str] | None = None) -> int: ) 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() return 2 diff --git a/docs/ranking-snapshot-read-model-plan.md b/docs/ranking-snapshot-read-model-plan.md index 66036b8..65e31e4 100644 --- a/docs/ranking-snapshot-read-model-plan.md +++ b/docs/ranking-snapshot-read-model-plan.md @@ -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 ``` +Bulk refresh entrypoint: + +```bash +python -m app.rcon_historical_leaderboards refresh-ranking-snapshots --limit 30 +``` + Operational 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 @@ -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 ``` +Bulk Docker form: + +```bash +docker compose exec backend python -m app.rcon_historical_leaderboards refresh-ranking-snapshots --limit 30 +``` + Operational expectation: - 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` Current implementation note: -- V1 generator is unitary per command invocation -- operators should run one command per required `(timeframe, server-key, metric)` combination -- broad matrix generation can remain a later operational helper if scheduling needs justify it +- `generate-ranking-snapshot` remains unitary per command invocation for explicit manual control +- `refresh-ranking-snapshots --limit 30` generates the full weekly/monthly public matrix in one run +- 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 @@ -294,6 +307,10 @@ Minimum production matrix for parity with the public Ranking filters: 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 operator cadence: @@ -305,6 +322,7 @@ Operational guidance: - regenerate after materialized RCON/AdminLog data grows - regenerate after manual backfill - 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 diff --git a/scripts/run-stats-validation.ps1 b/scripts/run-stats-validation.ps1 index e0a1685..6db9bd2 100644 --- a/scripts/run-stats-validation.ps1 +++ b/scripts/run-stats-validation.ps1 @@ -132,6 +132,7 @@ import app.rcon_historical_leaderboards as ranking_leaderboards initialize_ranking_snapshot_storage = ranking_leaderboards.initialize_ranking_snapshot_storage generate_ranking_snapshot = ranking_leaderboards.generate_ranking_snapshot get_latest_ranking_snapshot = ranking_leaderboards.get_latest_ranking_snapshot +refresh_ranking_snapshots = ranking_leaderboards.refresh_ranking_snapshots def require(condition, message): @@ -289,6 +290,7 @@ def validate_postgres_ranking_snapshot_schema_path(): def validate_ranking_snapshot_cli_defaults(): original_generate_ranking_snapshot = ranking_leaderboards.generate_ranking_snapshot + original_refresh_ranking_snapshots = ranking_leaderboards.refresh_ranking_snapshots captured = {} 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"), "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: 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(): @@ -431,6 +582,8 @@ require(health_payload.get("status") == "ok", "/health payload should be ok.") validate_postgres_ranking_snapshot_schema_path() validate_ranking_snapshot_cli_defaults() 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") require( @@ -803,7 +956,10 @@ print(json.dumps({ "postgres-ranking-derived-metric-sql", "postgres-ranking-schema-path", "ranking-snapshot-cli-postgres-default", + "ranking-snapshot-bulk-cli", "ranking-snapshot-postgres-selection", + "ranking-snapshot-bulk-refresh", + "ranking-snapshot-bulk-partial-failure", "ranking-snapshot-generator", "ranking-snapshot-ready", "ranking-snapshot-missing",