Schedule public snapshot refreshes and optimize historical loading

This commit is contained in:
devRaGonSa
2026-06-10 14:32:41 +02:00
parent 1d2af10458
commit ae5355c326
12 changed files with 1128 additions and 105 deletions

View File

@@ -0,0 +1,140 @@
---
id: TASK-220
title: Schedule public snapshot refreshes
status: done
type: backend
team: Backend Senior
supporting_teams: ["Arquitecto Python"]
roadmap_item: foundation
priority: high
---
# TASK-220 - Schedule public snapshot refreshes
## Goal
Configure automatic regeneration of public snapshots and read models at appropriate times, so public pages do not depend on heavy runtime calculations.
## Context
Public historical, ranking and stats pages should read precomputed data. Heavy regeneration should happen inside the internal runner during low-load windows, while weekly/monthly ranking and recent matches stay fresh at shorter intervals.
Preserve the current product identity: Spanish-speaking HLL Vietnam community, military/Vietnam/tactical/sober visual direction and controlled repository evolution.
## Steps
1. Inspect the listed files first.
2. Document how the current refresh paths work.
3. Add environment-driven scheduling for daily full refresh, frequent ranking refresh and recent match snapshot refresh.
4. Keep refreshes idempotent and non-overlapping.
5. Document Portainer configuration and manual emergency commands.
6. Validate Python compilation and scheduler helpers.
## Files to Read First
- `AGENTS.md`
- `ai/repo-context.md`
- `ai/architecture-index.md`
- `ai/orchestrator/backend-senior.md`
- `ai/orchestrator/python-architect.md`
- `backend/app/historical_runner.py`
- `backend/app/rcon_historical_leaderboards.py`
- `backend/app/rcon_annual_rankings.py`
- `backend/app/rcon_historical_player_stats.py`
- `backend/app/historical_snapshot_storage.py`
- `backend/app/historical_snapshots.py`
- `backend/app/payloads.py`
- `backend/app/config.py`
- `backend/app/main.py`
- `docker-compose.yml`
- `README.md`
- `docs/PERFORMANCE_PUBLIC_QUERY_AUDIT.md`
## Expected Files to Modify
- `backend/app/config.py`
- `backend/app/historical_runner.py`
- `backend/app/historical_snapshots.py`
- `backend/app/routes.py`
- `backend/tests/test_historical_snapshot_refresh.py`
- `docker-compose.yml`
- `docs/public-snapshot-refresh-schedule.md`
- `ai/tasks/in-progress/TASK-220-schedule-public-snapshot-refreshes.md`
## Constraints
- Do not execute `ai-platform run`.
- Do not push or commit.
- Do not touch frontend except documentation if unavoidable.
- Do not touch weapon assets, SVGs or physical images.
- Do not touch `ai/system-metrics.md`.
- Do not reactivate Elo/MMR.
- Do not reintroduce Comunidad Hispana #03.
- Keep public requests on read models/snapshots for rankings.
- Prefer the internal runner over host cron.
## Validation
Before completing the task ensure:
- Run `python -m compileall` for modified backend modules.
- Run relevant existing backend tests if applicable.
- Validate scheduler helper behavior for the next 06:00 Europe/Madrid.
- Confirm public route modules still compile.
- Review `git diff --name-only`.
- Confirm no frontend, weapon assets, SVGs, physical images or `ai/system-metrics.md` were modified by this task.
## Outcome
Completed.
Current-state analysis:
- `ranking_snapshots` weekly/monthly were generated by `refresh_ranking_snapshots()` and invoked from `historical_runner` once per historical runner cycle. Compose forced `--hourly`, so public rankings could wait up to an hour despite the code recommending 5-15 minute freshness.
- Annual ranking snapshots existed through `generate_annual_ranking_snapshot()` but were only manual/CLI-driven, not part of the runner schedule.
- `player_search_index` and `player_period_stats` were refreshed from `historical_runner` once per runner cycle.
- Recent matches were included in full/priority historical snapshots, but there was no dedicated short-cadence refresh. RCON capture exposed `materialized_matches_inserted`, which can be used as a finished-match hook.
- `historical_runner` already existed and is the right place for internal scheduling; no host cron is required.
- Public historical snapshot leaderboard routes in RCON mode were using a runtime materialized fast path despite being snapshot endpoints.
Implemented strategy:
- Added public refresh env configuration with safe defaults.
- Kept the internal runner as scheduler and removed the Compose `--hourly` override so the runner can tick at the shortest public interval while still throttling the heavier historical cycle separately.
- Added daily public full refresh logic, due once per local day after `06:00 Europe/Madrid`.
- Added frequent weekly/monthly ranking snapshot refresh at `HLL_PUBLIC_RANKING_REFRESH_INTERVAL_SECONDS`.
- Added recent-match-only snapshot generation and scheduled it at `HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS`.
- Added immediate recent-match refresh when RCON capture reports newly materialized finished matches.
- Added structured start/end logs, durations and per-step results. Per-step failures are captured without stopping neighboring refreshes or the runner.
- Added in-process non-overlap guards for public refresh classes.
- Changed public historical leaderboard snapshot routes to read persisted snapshots instead of the RCON runtime fast path.
- Documented Portainer configuration and emergency commands in `docs/public-snapshot-refresh-schedule.md`.
Validation:
- `python -m compileall backend/app/config.py backend/app/historical_runner.py backend/app/historical_snapshots.py backend/app/routes.py`
- `python -m compileall backend/app backend/tests`
- `$env:PYTHONPATH='backend'; python -m unittest backend.tests.test_historical_snapshot_refresh`
- `$env:PYTHONPATH='backend'; python -m unittest backend.tests.test_annual_ranking_payload`
- Dry-run scheduler helper check:
- `2026-06-10T03:30:00Z` -> next refresh `2026-06-10T04:00:00Z` (`06:00 Europe/Madrid`)
- `2026-06-10T05:00:00Z` -> next refresh `2026-06-11T04:00:00Z`
- Route dry-run with patched builder confirmed `/api/historical/snapshots/leaderboard` resolves through `build_leaderboard_snapshot_payload`.
- `git diff --name-only` reviewed.
Exclusions:
- Did not execute `ai-platform run`.
- Did not push or commit.
- Did not modify frontend files as part of this task.
- Did not modify physical images, weapon assets, SVGs or `ai/system-metrics.md`.
- Did not reactivate Elo/MMR.
- Did not reintroduce Comunidad Hispana #03.
Note: the worktree still contains pre-existing unrelated changes in `ai/system-metrics.md`, `frontend/assets/css/styles.css`, clan image files and weapon/SVG assets. They were not touched by this task.
## Change Budget
- Prefer fewer than 5 modified files when feasible.
- Prefer changes under 200 lines when feasible.
- Split follow-up tasks if scope grows.

View File

@@ -0,0 +1,133 @@
---
id: TASK-221
title: Fix Historico loading and recent matches performance
status: in-progress
type: frontend
team: Frontend Senior
supporting_teams: ["Backend Senior"]
roadmap_item: foundation
priority: high
---
# TASK-221 - Fix Historico loading and recent matches performance
## Goal
Make the Historico screen load weekly/monthly tops and recent matches quickly from snapshots/read models, without blank limbo states or public-request heavy calculations.
## Context
The Historico page currently shows slow weekly/monthly top loading and recent matches can stay visually empty for several seconds. These public views should read precomputed snapshots or simple read models, and latest matches should be refreshed by the runner when matches finish or by short polling.
Preserve the current product identity: Spanish-speaking HLL Vietnam community, military/Vietnam/tactical/sober visual direction and controlled repository evolution.
## Steps
1. Inventory the exact frontend endpoints and backend functions used by Historico.
2. Identify frontend loading states that produce blank UI or unnecessary waits.
3. Ensure weekly/monthly top endpoints use snapshot-only public reads.
4. Ensure recent matches endpoint avoids RCON, scoreboard and heavy recalculation in public requests.
5. Validate frontend syntax, backend compilation and relevant tests.
## Files to Read First
- `AGENTS.md`
- `ai/repo-context.md`
- `ai/architecture-index.md`
- `ai/orchestrator/frontend-senior.md`
- `ai/orchestrator/backend-senior.md`
- `frontend/historico.html`
- `frontend/assets/js/historico.js`
- `frontend/assets/css/historico.css`
- `backend/app/routes.py`
- `backend/app/payloads.py`
- `backend/app/historical_snapshot_storage.py`
- `backend/app/historical_snapshots.py`
- `backend/app/rcon_historical_read_model.py`
- `backend/app/postgres_rcon_storage.py`
## Expected Files to Modify
- `frontend/assets/js/historico.js`
- `frontend/assets/js/historico-recent-live.js`
- `backend/app/payloads.py`
- `backend/tests/test_historical_snapshot_refresh.py`
- `ai/tasks/in-progress/TASK-221-fix-historico-loading-and-recent-matches-performance.md`
## Constraints
- Do not execute `ai-platform run`.
- Do not push or commit.
- Do not touch weapon assets, SVGs or physical images.
- Do not touch `ai/system-metrics.md`.
- Do not reactivate Elo/MMR.
- Do not reintroduce Comunidad Hispana #03.
- Do not include unrelated prior changes.
## Validation
- `node --check frontend/assets/js/historico.js`
- `python -m compileall` for modified backend modules.
- Relevant backend tests.
- Measure or provide measurement commands for weekly top, monthly top and recent matches.
- Confirm public requests do not use RCON, scoreboard fallback or runtime leaderboard aggregation.
- Confirm no protected files/assets were touched by this task.
## Outcome
Done.
Inventory:
- Weekly top: `GET /api/historical/snapshots/leaderboard?server=<slug>&timeframe=weekly&metric=<metric>&limit=10`, called from `frontend/assets/js/historico.js`. Backend route: `routes.resolve_get_payload` -> `build_leaderboard_snapshot_payload`. Expected data: precomputed historical snapshot via `historical_snapshot_storage.get_historical_snapshot`, stored in `displayed_historical_snapshots` on PostgreSQL or JSON fallback. Public request must not call RCON, scoreboard, storage initialization or runtime leaderboard aggregation.
- Monthly top: `GET /api/historical/snapshots/leaderboard?server=<slug>&timeframe=monthly&metric=<metric>&limit=10`, same frontend/backend path as weekly with `timeframe=monthly`. Expected data: same precomputed snapshot path.
- Recent matches primary snapshot: `GET /api/historical/snapshots/recent-matches?server=<slug>&limit=100`, called from `frontend/assets/js/historico.js`. Backend route: `build_recent_historical_matches_snapshot_payload`. Expected data: precomputed `recent-matches` snapshot via `displayed_historical_snapshots` or JSON fallback. Generated out of band from materialized RCON matches.
- Recent matches live updater: before this task `frontend/assets/js/historico-recent-live.js` also called `GET /api/historical/recent-matches?server=<slug>&limit=100`. Backend route: `build_recent_historical_matches_payload`, which can use the RCON historical read model and public scoreboard fallback. It now calls `GET /api/historical/snapshots/recent-matches?server=<slug>&limit=100`.
- Server summary: `GET /api/historical/snapshots/server-summary?server=<slug>`, called from `frontend/assets/js/historico.js`. Backend route: `build_historical_server_summary_snapshot_payload`, using precomputed snapshots only.
Cause:
- The public leaderboard snapshot builder could still runtime-enrich items missing `total_time_seconds` by calling `_load_runtime_leaderboard_items`, which delegates to weekly/monthly runtime leaderboard builders.
- The public recent-matches snapshot builder could complete a partial snapshot by calling `list_recent_historical_matches`, adding scoreboard/RCON-backed work to a public request.
- `historico-recent-live.js` was overwriting the same recent-matches panel from `/api/historical/recent-matches`, bypassing the snapshot path used by `historico.js`.
- The initial recent-matches render cleared the list before data arrived, and on errors it could leave loading placeholders visible.
Implemented strategy:
- Leaderboard snapshot public requests now return only persisted snapshot items and mark runtime enrichment as disabled on the public snapshot path.
- Recent-matches snapshot public requests now return only persisted snapshot items; no public scoreboard/RCON completion is attempted.
- The live recent-matches frontend updater now reads `/api/historical/snapshots/recent-matches` and polls every 60 seconds, relying on the runner/snapshot refresh to pick up finished matches.
- The recent-matches panel now renders compact loading placeholders instead of a blank area, and clears them on error.
Timing:
- HTTP endpoint measurement was not possible because `http://127.0.0.1:8000/health` timed out in the local environment.
- In-process snapshot payload timing with mocked persisted snapshots:
- weekly snapshot payload: `0.009 ms` average over 1000 calls
- monthly snapshot payload: `0.007 ms` average over 1000 calls
- recent matches snapshot payload: `0.006 ms` average over 1000 calls
- Commands to measure real endpoints when the backend is running:
- `Measure-Command { Invoke-WebRequest -UseBasicParsing "http://127.0.0.1:8000/api/historical/snapshots/leaderboard?server=all-servers&timeframe=weekly&metric=kills&limit=10" }`
- `Measure-Command { Invoke-WebRequest -UseBasicParsing "http://127.0.0.1:8000/api/historical/snapshots/leaderboard?server=all-servers&timeframe=monthly&metric=kills&limit=10" }`
- `Measure-Command { Invoke-WebRequest -UseBasicParsing "http://127.0.0.1:8000/api/historical/snapshots/recent-matches?server=all-servers&limit=100" }`
Validation:
- `node --check frontend/assets/js/historico.js`
- `node --check frontend/assets/js/historico-recent-live.js`
- `python -m compileall backend/app/payloads.py`
- `$env:PYTHONPATH='backend'; python -m unittest backend.tests.test_historical_snapshot_refresh`
- Local visual inspection with Chrome headless against `frontend/historico.html`. Browser plugin invocation failed because `iab` was unavailable; Playwright CLI was present but browser binaries were not installed, so Chrome headless was used. With backend unavailable, Historico showed compact error states and no blank recent-matches limbo.
Exclusions:
- Did not execute `ai-platform run`.
- Did not push or commit.
- This task did not touch weapon assets, SVGs, physical images or `ai/system-metrics.md`.
- Did not reactivate Elo/MMR.
- Did not reintroduce Comunidad Hispana #03.
## Change Budget
- Prefer fewer than 5 modified files.
- Prefer changes under 200 lines when feasible.

View File

@@ -21,6 +21,11 @@ DEFAULT_HISTORICAL_CRCON_RETRY_DELAY_SECONDS = 0.5
DEFAULT_HISTORICAL_REFRESH_INTERVAL_SECONDS = 1800 DEFAULT_HISTORICAL_REFRESH_INTERVAL_SECONDS = 1800
DEFAULT_HISTORICAL_REFRESH_OVERLAP_HOURS = 12 DEFAULT_HISTORICAL_REFRESH_OVERLAP_HOURS = 12
DEFAULT_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS = 900 DEFAULT_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS = 900
DEFAULT_PUBLIC_FULL_REFRESH_ENABLED = True
DEFAULT_PUBLIC_FULL_REFRESH_TIME = "06:00"
DEFAULT_PUBLIC_FULL_REFRESH_TIMEZONE = "Europe/Madrid"
DEFAULT_PUBLIC_RANKING_REFRESH_INTERVAL_SECONDS = 900
DEFAULT_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS = 60
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_HISTORICAL_FULL_SNAPSHOT_EVERY_RUNS = 4 DEFAULT_HISTORICAL_FULL_SNAPSHOT_EVERY_RUNS = 4
@@ -510,6 +515,58 @@ def get_rcon_historical_capture_retry_delay_seconds() -> int:
return retry_delay_seconds return retry_delay_seconds
def get_public_full_refresh_enabled() -> bool:
"""Return whether the runner should execute the daily public full refresh."""
return _read_bool_env(
"HLL_PUBLIC_FULL_REFRESH_ENABLED",
default=DEFAULT_PUBLIC_FULL_REFRESH_ENABLED,
)
def get_public_full_refresh_time() -> str:
"""Return the local HH:MM time for the daily public full refresh."""
configured_value = os.getenv(
"HLL_PUBLIC_FULL_REFRESH_TIME",
DEFAULT_PUBLIC_FULL_REFRESH_TIME,
).strip()
parts = configured_value.split(":")
if len(parts) != 2:
raise ValueError("HLL_PUBLIC_FULL_REFRESH_TIME must use HH:MM format.")
hour, minute = (int(part) for part in parts)
if hour < 0 or hour > 23 or minute < 0 or minute > 59:
raise ValueError("HLL_PUBLIC_FULL_REFRESH_TIME must use HH:MM format.")
return f"{hour:02d}:{minute:02d}"
def get_public_full_refresh_timezone() -> str:
"""Return the IANA timezone for the daily public full refresh."""
configured_value = os.getenv(
"HLL_PUBLIC_FULL_REFRESH_TIMEZONE",
DEFAULT_PUBLIC_FULL_REFRESH_TIMEZONE,
).strip()
if not configured_value:
raise ValueError("HLL_PUBLIC_FULL_REFRESH_TIMEZONE cannot be empty.")
return configured_value
def get_public_ranking_refresh_interval_seconds() -> int:
"""Return how often weekly/monthly public ranking snapshots should refresh."""
return _read_int_env(
"HLL_PUBLIC_RANKING_REFRESH_INTERVAL_SECONDS",
str(DEFAULT_PUBLIC_RANKING_REFRESH_INTERVAL_SECONDS),
minimum=1,
)
def get_public_recent_matches_refresh_interval_seconds() -> int:
"""Return how often recent-match public snapshots should refresh without event hooks."""
return _read_int_env(
"HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS",
str(DEFAULT_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS),
minimum=1,
)
def get_rcon_capture_mode() -> str: def get_rcon_capture_mode() -> str:
"""Return whether the worker runs the normal historical pipeline or live capture only.""" """Return whether the worker runs the normal historical pipeline or live capture only."""
configured_mode = os.getenv("HLL_RCON_CAPTURE_MODE") configured_mode = os.getenv("HLL_RCON_CAPTURE_MODE")

View File

@@ -6,8 +6,9 @@ import argparse
import json import json
import time import time
import traceback import traceback
from datetime import datetime, timezone from datetime import date, datetime, time as datetime_time, timedelta, timezone
from typing import Any from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from .config import ( from .config import (
DEFAULT_DB_MAINTENANCE_INTERVAL_SECONDS, DEFAULT_DB_MAINTENANCE_INTERVAL_SECONDS,
@@ -20,6 +21,11 @@ from .config import (
get_historical_refresh_max_retries, get_historical_refresh_max_retries,
get_historical_refresh_retry_delay_seconds, get_historical_refresh_retry_delay_seconds,
get_historical_data_source_kind, get_historical_data_source_kind,
get_public_full_refresh_enabled,
get_public_full_refresh_time,
get_public_full_refresh_timezone,
get_public_ranking_refresh_interval_seconds,
get_public_recent_matches_refresh_interval_seconds,
) )
from .database_maintenance import run_database_maintenance_cleanup from .database_maintenance import run_database_maintenance_cleanup
from .elo_mmr_engine import rebuild_elo_mmr_models from .elo_mmr_engine import rebuild_elo_mmr_models
@@ -28,8 +34,15 @@ from .historical_ingestion import run_incremental_refresh
from .historical_snapshots import ( 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,
generate_and_persist_recent_historical_snapshots,
)
from .historical_storage import ALL_SERVERS_SLUG
from .rcon_annual_rankings import (
SUPPORTED_ANNUAL_RANKING_METRICS,
generate_annual_ranking_snapshot,
) )
from .rcon_historical_leaderboards import refresh_ranking_snapshots from .rcon_historical_leaderboards import refresh_ranking_snapshots
from .rcon_historical_leaderboards import SNAPSHOT_GENERATOR_SERVER_KEYS
from .rcon_historical_player_stats import ( from .rcon_historical_player_stats import (
refresh_player_period_stats, refresh_player_period_stats,
refresh_player_search_index, refresh_player_search_index,
@@ -44,6 +57,14 @@ DEFAULT_HISTORICAL_SERVER_SCOPE = (
"comunidad-hispana-02", "comunidad-hispana-02",
) )
_LAST_DATABASE_MAINTENANCE_RUN_AT: datetime | None = None _LAST_DATABASE_MAINTENANCE_RUN_AT: datetime | None = None
_LAST_PUBLIC_FULL_REFRESH_LOCAL_DATE: date | None = None
_LAST_PUBLIC_RANKING_REFRESH_AT: datetime | None = None
_LAST_PUBLIC_RECENT_MATCHES_REFRESH_AT: datetime | None = None
_PUBLIC_REFRESH_IN_PROGRESS: set[str] = set()
PUBLIC_FULL_REFRESH_YEAR = 2026
PUBLIC_REFRESH_LOCK_FULL = "public-full-refresh"
PUBLIC_REFRESH_LOCK_RANKING = "public-ranking-refresh"
PUBLIC_REFRESH_LOCK_RECENT_MATCHES = "public-recent-matches-refresh"
def run_periodic_historical_refresh( def run_periodic_historical_refresh(
@@ -73,23 +94,41 @@ def run_periodic_historical_refresh(
) )
print("Press Ctrl+C to stop.") print("Press Ctrl+C to stop.")
last_historical_refresh_started_at: datetime | None = None
loop_tick_seconds = _resolve_runner_tick_seconds(interval_seconds)
try: try:
while max_runs is None or completed_runs < max_runs: while max_runs is None or completed_runs < max_runs:
completed_runs += 1 now = datetime.now(timezone.utc)
payload = _run_refresh_with_retries( historical_refresh_due = _is_interval_due(
max_retries=max_retries, last_run_at=last_historical_refresh_started_at,
retry_delay_seconds=retry_delay_seconds, interval_seconds=interval_seconds,
server_slug=server_slug, now=now,
max_pages=max_pages,
page_size=page_size,
run_number=completed_runs,
) )
_emit_json_log({"run": completed_runs, **payload}) if historical_refresh_due:
completed_runs += 1
last_historical_refresh_started_at = now
payload = _run_refresh_with_retries(
max_retries=max_retries,
retry_delay_seconds=retry_delay_seconds,
server_slug=server_slug,
max_pages=max_pages,
page_size=page_size,
run_number=completed_runs,
)
_record_public_refreshes_from_cycle(payload, now=now)
_emit_json_log({"run": completed_runs, **payload})
else:
payload = _maybe_run_public_read_model_refreshes(
run_number=completed_runs,
now=now,
)
if payload["status"] != "skipped":
_emit_json_log({"run": completed_runs, **payload})
if max_runs is not None and completed_runs >= max_runs: if max_runs is not None and completed_runs >= max_runs:
break break
time.sleep(interval_seconds) time.sleep(loop_tick_seconds)
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nHistorical refresh loop stopped by user.") print("\nHistorical refresh loop stopped by user.")
@@ -193,6 +232,11 @@ def _run_refresh_with_retries(
server_slug=server_slug, server_slug=server_slug,
run_number=run_number, run_number=run_number,
) )
recent_matches_event_result = _maybe_refresh_recent_matches_after_capture(
rcon_capture_result=rcon_capture_result,
server_slug=server_slug,
run_number=run_number,
)
maintenance_result = _maybe_run_database_maintenance() maintenance_result = _maybe_run_database_maintenance()
return { return {
"status": _resolve_refresh_cycle_status( "status": _resolve_refresh_cycle_status(
@@ -201,6 +245,7 @@ def _run_refresh_with_retries(
player_search_index_result=player_search_index_result, player_search_index_result=player_search_index_result,
player_period_stats_result=player_period_stats_result, player_period_stats_result=player_period_stats_result,
ranking_snapshot_result=ranking_snapshot_result, ranking_snapshot_result=ranking_snapshot_result,
recent_matches_event_result=recent_matches_event_result,
elo_mmr_result=elo_mmr_result, elo_mmr_result=elo_mmr_result,
database_maintenance_result=maintenance_result, database_maintenance_result=maintenance_result,
), ),
@@ -215,6 +260,7 @@ def _run_refresh_with_retries(
"player_search_index_result": player_search_index_result, "player_search_index_result": player_search_index_result,
"player_period_stats_result": player_period_stats_result, "player_period_stats_result": player_period_stats_result,
"ranking_snapshot_result": ranking_snapshot_result, "ranking_snapshot_result": ranking_snapshot_result,
"recent_matches_event_result": recent_matches_event_result,
"elo_mmr_result": elo_mmr_result, "elo_mmr_result": elo_mmr_result,
"database_maintenance_result": maintenance_result, "database_maintenance_result": maintenance_result,
} }
@@ -415,6 +461,456 @@ def refresh_periodic_player_period_stats(
} }
def _maybe_run_public_read_model_refreshes(
*,
run_number: int,
now: datetime | None = None,
) -> dict[str, Any]:
"""Run due public read-model refreshes without doing the heavy RCON capture cycle."""
anchor = _as_utc(now or datetime.now(timezone.utc))
results: dict[str, Any] = {}
if _is_public_full_refresh_due(anchor):
results["public_full_refresh_result"] = _run_non_overlapping_public_refresh(
PUBLIC_REFRESH_LOCK_FULL,
lambda: refresh_public_full_read_models(run_number=run_number, now=anchor),
)
if _is_public_interval_refresh_due(
last_run_at=_LAST_PUBLIC_RANKING_REFRESH_AT,
interval_seconds=get_public_ranking_refresh_interval_seconds(),
now=anchor,
):
results["ranking_snapshot_result"] = _run_non_overlapping_public_refresh(
PUBLIC_REFRESH_LOCK_RANKING,
lambda: refresh_public_ranking_snapshots(run_number=run_number, now=anchor),
)
if _is_public_interval_refresh_due(
last_run_at=_LAST_PUBLIC_RECENT_MATCHES_REFRESH_AT,
interval_seconds=get_public_recent_matches_refresh_interval_seconds(),
now=anchor,
):
results["recent_matches_snapshot_result"] = _run_non_overlapping_public_refresh(
PUBLIC_REFRESH_LOCK_RECENT_MATCHES,
lambda: refresh_public_recent_matches_snapshots(
run_number=run_number,
now=anchor,
trigger="polling-interval",
),
)
if not results:
return {
"event": "public-read-model-refresh-scheduler-skipped",
"status": "skipped",
"reason": "no-public-refresh-due",
}
return {
"event": "public-read-model-refresh-scheduler-completed",
"status": _resolve_refresh_cycle_status(**results),
**results,
}
def refresh_public_full_read_models(
*,
run_number: int,
now: datetime | None = None,
) -> dict[str, Any]:
"""Refresh the full public read-model set intended for the low-load nightly window."""
global _LAST_PUBLIC_FULL_REFRESH_LOCAL_DATE
anchor = _as_utc(now or datetime.now(timezone.utc))
local_anchor = anchor.astimezone(_get_public_refresh_zone())
steps: dict[str, Any] = {}
started = time.perf_counter()
_emit_json_log(
{
"event": "public-full-refresh-started",
"run_number": run_number,
"scheduled_local_date": local_anchor.date().isoformat(),
"scheduled_time": get_public_full_refresh_time(),
"timezone": get_public_full_refresh_timezone(),
}
)
steps["historical_snapshots"] = _run_public_refresh_step(
"historical-snapshots-full",
lambda: generate_and_persist_historical_snapshots(generated_at=anchor),
)
steps["ranking_snapshots"] = _run_public_refresh_step(
"ranking-snapshots-weekly-monthly",
lambda: refresh_periodic_ranking_snapshots(run_number=run_number),
)
steps["annual_ranking_snapshots"] = _run_public_refresh_step(
"annual-ranking-snapshots-2026",
lambda: refresh_public_annual_ranking_snapshots(year=PUBLIC_FULL_REFRESH_YEAR),
)
steps["player_search_index"] = _run_public_refresh_step(
"player-search-index",
refresh_player_search_index,
)
steps["player_period_stats"] = _run_public_refresh_step(
"player-period-stats",
refresh_player_period_stats,
)
status = _resolve_refresh_cycle_status(**steps)
if status in {"ok", "partial"}:
_LAST_PUBLIC_FULL_REFRESH_LOCAL_DATE = local_anchor.date()
result = {
"status": status,
"run_number": run_number,
"generated_at": _to_iso(anchor),
"duration_ms": _elapsed_ms(started),
"timezone": get_public_full_refresh_timezone(),
"scheduled_time": get_public_full_refresh_time(),
"steps": steps,
}
_emit_json_log({"event": "public-full-refresh-completed", **result})
return result
def refresh_public_ranking_snapshots(
*,
run_number: int,
now: datetime | None = None,
) -> dict[str, Any]:
"""Refresh weekly/monthly public ranking snapshots on the short cadence."""
global _LAST_PUBLIC_RANKING_REFRESH_AT
anchor = _as_utc(now or datetime.now(timezone.utc))
started = time.perf_counter()
_emit_json_log(
{
"event": "public-ranking-refresh-started",
"run_number": run_number,
"interval_seconds": get_public_ranking_refresh_interval_seconds(),
}
)
result = refresh_periodic_ranking_snapshots(run_number=run_number)
status = str(result.get("status") or "ok")
if status != "error":
_LAST_PUBLIC_RANKING_REFRESH_AT = anchor
completed = {
**result,
"duration_ms": _elapsed_ms(started),
"generated_at": _to_iso(anchor),
"interval_seconds": get_public_ranking_refresh_interval_seconds(),
"generation_policy": "public-ranking-short-cadence",
}
_emit_json_log({"event": "public-ranking-refresh-completed", **completed})
return completed
def refresh_public_recent_matches_snapshots(
*,
run_number: int,
now: datetime | None = None,
trigger: str,
server_slug: str | None = None,
) -> dict[str, Any]:
"""Refresh recent-match snapshots after match-finalization evidence or short polling."""
global _LAST_PUBLIC_RECENT_MATCHES_REFRESH_AT
anchor = _as_utc(now or datetime.now(timezone.utc))
started = time.perf_counter()
_emit_json_log(
{
"event": "public-recent-matches-refresh-started",
"run_number": run_number,
"trigger": trigger,
"server_slug": server_slug,
"interval_seconds": get_public_recent_matches_refresh_interval_seconds(),
}
)
try:
result = generate_and_persist_recent_historical_snapshots(
server_key=server_slug,
generated_at=anchor,
)
except Exception as exc: # noqa: BLE001 - scheduler must keep the runner alive
result = {
"status": "error",
"error_type": type(exc).__name__,
"error": str(exc),
"traceback": traceback.format_exc(),
}
status = str(result.get("status") or "ok")
if status != "error":
_LAST_PUBLIC_RECENT_MATCHES_REFRESH_AT = anchor
completed = {
**result,
"duration_ms": _elapsed_ms(started),
"generated_at": _to_iso(anchor),
"trigger": trigger,
"server_slug": server_slug,
"interval_seconds": get_public_recent_matches_refresh_interval_seconds(),
}
_emit_json_log({"event": "public-recent-matches-refresh-completed", **completed})
return completed
def refresh_public_annual_ranking_snapshots(
*,
year: int = PUBLIC_FULL_REFRESH_YEAR,
limit: int = 20,
) -> dict[str, Any]:
"""Refresh supported annual ranking snapshots for all public scopes."""
combinations = [
(server_key, metric)
for server_key in SNAPSHOT_GENERATOR_SERVER_KEYS
for metric in SUPPORTED_ANNUAL_RANKING_METRICS
]
results: list[dict[str, Any]] = []
succeeded = 0
failed = 0
for server_key, metric in combinations:
try:
payload = generate_annual_ranking_snapshot(
year=year,
server_key=None if server_key == ALL_SERVERS_SLUG else server_key,
metric=metric,
limit=limit,
replace_existing=True,
)
snapshot = payload.get("snapshot") if isinstance(payload, dict) else {}
succeeded += 1
results.append(
{
"status": "ok",
"year": year,
"server_key": server_key,
"metric": metric,
"snapshot_id": snapshot.get("id") 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),
}
)
except Exception as exc: # noqa: BLE001 - report per-snapshot failures
failed += 1
results.append(
{
"status": "error",
"year": year,
"server_key": server_key,
"metric": metric,
"error_type": type(exc).__name__,
"error": str(exc),
}
)
status = "ok"
if failed and succeeded:
status = "partial"
elif failed:
status = "error"
return {
"status": status,
"year": year,
"limit": limit,
"combinations_expected": len(combinations),
"totals": {
"succeeded": succeeded,
"failed": failed,
},
"results": results,
}
def get_next_public_full_refresh_at(
*,
now: datetime | None = None,
) -> datetime:
"""Return the next configured daily public full refresh in UTC."""
anchor = _as_utc(now or datetime.now(timezone.utc))
zone = _get_public_refresh_zone()
local_anchor = anchor.astimezone(zone)
hour, minute = (int(part) for part in get_public_full_refresh_time().split(":"))
candidate = datetime.combine(
local_anchor.date(),
datetime_time(hour=hour, minute=minute),
tzinfo=zone,
)
if local_anchor >= candidate:
candidate += timedelta(days=1)
return candidate.astimezone(timezone.utc)
def _maybe_refresh_recent_matches_after_capture(
*,
rcon_capture_result: dict[str, Any],
server_slug: str | None,
run_number: int,
) -> dict[str, Any]:
if not _rcon_capture_materialized_finished_match(rcon_capture_result):
return {
"status": "skipped",
"reason": "no-materialized-finished-match-detected",
"trigger": "rcon-capture",
}
return _run_non_overlapping_public_refresh(
PUBLIC_REFRESH_LOCK_RECENT_MATCHES,
lambda: refresh_public_recent_matches_snapshots(
run_number=run_number,
trigger="rcon-capture-materialized-match",
server_slug=server_slug,
),
)
def _run_public_refresh_step(
step_name: str,
callback: Any,
) -> dict[str, Any]:
started = time.perf_counter()
_emit_json_log({"event": "public-refresh-step-started", "step": step_name})
try:
result = callback()
except Exception as exc: # noqa: BLE001 - keep neighboring refreshes running
result = {
"status": "error",
"error_type": type(exc).__name__,
"error": str(exc),
"traceback": traceback.format_exc(),
}
completed = {
**result,
"step": step_name,
"duration_ms": _elapsed_ms(started),
}
_emit_json_log({"event": "public-refresh-step-completed", **completed})
return completed
def _run_non_overlapping_public_refresh(
refresh_key: str,
callback: Any,
) -> dict[str, Any]:
if refresh_key in _PUBLIC_REFRESH_IN_PROGRESS:
return {
"status": "skipped",
"reason": "refresh-already-in-progress",
"refresh_key": refresh_key,
}
_PUBLIC_REFRESH_IN_PROGRESS.add(refresh_key)
try:
return callback()
finally:
_PUBLIC_REFRESH_IN_PROGRESS.discard(refresh_key)
def _record_public_refreshes_from_cycle(
payload: dict[str, Any],
*,
now: datetime,
) -> None:
global _LAST_PUBLIC_RANKING_REFRESH_AT, _LAST_PUBLIC_RECENT_MATCHES_REFRESH_AT
ranking_result = payload.get("ranking_snapshot_result")
if isinstance(ranking_result, dict) and ranking_result.get("status") != "error":
_LAST_PUBLIC_RANKING_REFRESH_AT = now
recent_result = payload.get("recent_matches_event_result")
if isinstance(recent_result, dict) and recent_result.get("status") not in {
"error",
"skipped",
}:
_LAST_PUBLIC_RECENT_MATCHES_REFRESH_AT = now
def _is_public_full_refresh_due(now: datetime) -> bool:
if not get_public_full_refresh_enabled():
return False
local_now = now.astimezone(_get_public_refresh_zone())
hour, minute = (int(part) for part in get_public_full_refresh_time().split(":"))
scheduled_today = datetime.combine(
local_now.date(),
datetime_time(hour=hour, minute=minute),
tzinfo=local_now.tzinfo,
)
return (
local_now >= scheduled_today
and _LAST_PUBLIC_FULL_REFRESH_LOCAL_DATE != local_now.date()
)
def _is_public_interval_refresh_due(
*,
last_run_at: datetime | None,
interval_seconds: int,
now: datetime,
) -> bool:
return _is_interval_due(
last_run_at=last_run_at,
interval_seconds=interval_seconds,
now=now,
)
def _is_interval_due(
*,
last_run_at: datetime | None,
interval_seconds: int,
now: datetime,
) -> bool:
if last_run_at is None:
return True
elapsed_seconds = (now - _as_utc(last_run_at)).total_seconds()
return elapsed_seconds >= interval_seconds
def _resolve_runner_tick_seconds(interval_seconds: int) -> int:
intervals = [
interval_seconds,
get_public_ranking_refresh_interval_seconds(),
get_public_recent_matches_refresh_interval_seconds(),
]
return max(1, min(intervals))
def _get_public_refresh_zone() -> ZoneInfo:
timezone_name = get_public_full_refresh_timezone()
try:
return ZoneInfo(timezone_name)
except ZoneInfoNotFoundError as error:
raise ValueError(
f"HLL_PUBLIC_FULL_REFRESH_TIMEZONE is not a valid IANA timezone: {timezone_name}"
) from error
def _rcon_capture_materialized_finished_match(
rcon_capture_result: dict[str, Any],
) -> bool:
totals = rcon_capture_result.get("totals")
if isinstance(totals, dict) and int(totals.get("materialized_matches_inserted") or 0) > 0:
return True
targets = rcon_capture_result.get("targets")
if not isinstance(targets, list):
return False
for target in targets:
if not isinstance(target, dict):
continue
if int(target.get("materialized_matches_inserted") or 0) > 0:
return True
return False
def _elapsed_ms(started: float) -> int:
return int((time.perf_counter() - started) * 1000)
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")
def _resolve_refresh_cycle_status(**results: dict[str, Any]) -> str: def _resolve_refresh_cycle_status(**results: dict[str, Any]) -> str:
statuses = [ statuses = [
str(result.get("status") or "").strip().lower() str(result.get("status") or "").strip().lower()

View File

@@ -392,6 +392,49 @@ def generate_and_persist_priority_historical_snapshots(
} }
def generate_and_persist_recent_historical_snapshots(
*,
server_key: str | None = None,
generated_at: datetime | None = None,
recent_matches_limit: int = DEFAULT_RECENT_MATCHES_LIMIT,
db_path: Path | None = None,
) -> dict[str, object]:
"""Build and persist only the public recent-matches snapshots."""
from .historical_snapshot_storage import persist_historical_snapshot_batch
generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc))
recent_matches_limit = _normalize_snapshot_limit(
"recent_matches_limit",
recent_matches_limit,
)
snapshots = [
_build_recent_matches_snapshot(
target_server_key,
generated_at_value,
limit=recent_matches_limit,
db_path=db_path,
)
for target_server_key in _resolve_snapshot_target_keys(
server_key=server_key,
db_path=db_path,
)
]
persisted_records = persist_historical_snapshot_batch(snapshots, db_path=db_path)
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": _to_iso(generated_at_value),
"server_slug": server_key,
"snapshot_policy": "recent-matches-only",
"snapshot_count": len(persisted_records),
"servers_processed": len(snapshots_by_server),
"snapshots_by_server": snapshots_by_server,
}
def _build_server_summary_snapshot( def _build_server_summary_snapshot(
server_key: str, server_key: str,
generated_at: datetime, generated_at: datetime,

View File

@@ -1338,17 +1338,6 @@ def build_leaderboard_snapshot_payload(
payload = snapshot.get("payload") if snapshot else {} payload = snapshot.get("payload") if snapshot else {}
items = payload.get("items") if isinstance(payload, dict) else None items = payload.get("items") if isinstance(payload, dict) else None
sliced_items = list(items[:limit]) if isinstance(items, list) else [] sliced_items = list(items[:limit]) if isinstance(items, list) else []
runtime_enrichment_applied = False
if _leaderboard_snapshot_items_need_playtime_enrichment(sliced_items):
runtime_items = _load_runtime_leaderboard_items(
limit=limit,
server_id=server_id,
metric=metric,
timeframe=normalized_timeframe,
)
if runtime_items:
sliced_items = runtime_items[:limit]
runtime_enrichment_applied = True
is_all_servers = server_id == ALL_SERVERS_SLUG is_all_servers = server_id == ALL_SERVERS_SLUG
return { return {
"status": "ok", "status": "ok",
@@ -1391,12 +1380,8 @@ def build_leaderboard_snapshot_payload(
"snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None, "snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None,
"limit": limit, "limit": limit,
"runtime_enrichment": { "runtime_enrichment": {
"applied": runtime_enrichment_applied, "applied": False,
"reason": ( "reason": "disabled-on-public-snapshot-path",
"snapshot-items-missing-total-time-seconds"
if runtime_enrichment_applied
else None
),
}, },
**_resolve_historical_fallback_policy( **_resolve_historical_fallback_policy(
fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet", fallback_reason="rcon-historical-read-model-does-not-support-historical-snapshots-yet",
@@ -1450,55 +1435,6 @@ def build_recent_historical_matches_snapshot_payload(
payload = snapshot.get("payload") if snapshot else {} payload = snapshot.get("payload") if snapshot else {}
items = payload.get("items") if isinstance(payload, dict) else None items = payload.get("items") if isinstance(payload, dict) else None
sliced_items = list(items[:limit]) if isinstance(items, list) else [] sliced_items = list(items[:limit]) if isinstance(items, list) else []
if (
get_historical_data_source_kind() == SOURCE_KIND_RCON
and 0 < len(sliced_items) < limit
):
fallback_items = list_recent_historical_matches(limit=limit, server_slug=server_slug)
merged_items = _merge_recent_match_items(
primary_items=sliced_items,
fallback_items=fallback_items,
limit=limit,
)
if len(merged_items) > len(sliced_items):
return {
"status": "ok",
"data": {
"title": "Snapshot historico de partidas recientes por servidor",
"context": "historical-recent-matches-snapshot",
"source": "historical-precomputed-snapshots",
"server_slug": server_slug,
"found": snapshot is not None,
**_build_historical_snapshot_metadata(snapshot),
"snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None,
"limit": limit,
**build_source_policy(
primary_source=SOURCE_KIND_RCON,
selected_source="hybrid-rcon-plus-public-scoreboard",
fallback_used=True,
fallback_reason="rcon-historical-recent-matches-did-not-reach-requested-limit",
source_attempts=[
build_source_attempt(
source=SOURCE_KIND_RCON,
role="primary",
status="success",
reason="recent-matches-snapshot-served-by-rcon-competitive-model",
),
build_source_attempt(
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
role="fallback",
status="success",
reason="recent-matches-snapshot-completed-from-public-scoreboard",
message=(
f"RCON snapshot returned {len(sliced_items)} items, completed to "
f"{len(merged_items)} of requested {limit}."
),
),
],
),
"items": merged_items,
},
}
return { return {
"status": "ok", "status": "ok",
"data": { "data": {

View File

@@ -6,7 +6,6 @@ from http import HTTPStatus
from datetime import datetime, timezone from datetime import datetime, timezone
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from .config import get_historical_data_source_kind
from .payloads import ( from .payloads import (
build_global_ranking_payload, build_global_ranking_payload,
build_stats_player_profile_payload, build_stats_player_profile_payload,
@@ -46,7 +45,6 @@ from .payloads import (
build_weekly_top_kills_payload, build_weekly_top_kills_payload,
build_stats_player_search_payload, build_stats_player_search_payload,
) )
from .rcon_historical_leaderboards import build_rcon_materialized_leaderboard_snapshot_payload
from .scoreboard_origins import get_trusted_public_scoreboard_origin from .scoreboard_origins import get_trusted_public_scoreboard_origin
RANKING_METRICS = { RANKING_METRICS = {
@@ -305,13 +303,6 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter") return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter")
if timeframe not in {"weekly", "monthly"}: if timeframe not in {"weekly", "monthly"}:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter") return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter")
if get_historical_data_source_kind() == "rcon":
return HTTPStatus.OK, build_rcon_materialized_leaderboard_snapshot_payload(
limit=limit,
server_id=server_id,
metric=metric,
timeframe=timeframe,
)
return HTTPStatus.OK, build_leaderboard_snapshot_payload( return HTTPStatus.OK, build_leaderboard_snapshot_payload(
limit=limit, limit=limit,
server_id=server_id, server_id=server_id,
@@ -328,13 +319,6 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
metric = params.get("metric", ["kills"])[0] metric = params.get("metric", ["kills"])[0]
if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}: if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter") return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter")
if get_historical_data_source_kind() == "rcon":
return HTTPStatus.OK, build_rcon_materialized_leaderboard_snapshot_payload(
limit=limit,
server_id=server_id,
metric=metric,
timeframe="monthly",
)
return HTTPStatus.OK, build_monthly_leaderboard_snapshot_payload( return HTTPStatus.OK, build_monthly_leaderboard_snapshot_payload(
limit=limit, limit=limit,
server_id=server_id, server_id=server_id,
@@ -385,13 +369,6 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
metric = params.get("metric", ["kills"])[0] metric = params.get("metric", ["kills"])[0]
if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}: if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter") return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter")
if get_historical_data_source_kind() == "rcon":
return HTTPStatus.OK, build_rcon_materialized_leaderboard_snapshot_payload(
limit=limit,
server_id=server_id,
metric=metric,
timeframe="weekly",
)
return HTTPStatus.OK, build_weekly_leaderboard_snapshot_payload( return HTTPStatus.OK, build_weekly_leaderboard_snapshot_payload(
limit=limit, limit=limit,
server_id=server_id, server_id=server_id,

View File

@@ -14,8 +14,21 @@ from app.config import (
get_historical_refresh_interval_seconds, get_historical_refresh_interval_seconds,
get_historical_refresh_max_retries, get_historical_refresh_max_retries,
get_historical_refresh_retry_delay_seconds, get_historical_refresh_retry_delay_seconds,
get_public_full_refresh_enabled,
get_public_full_refresh_time,
get_public_full_refresh_timezone,
get_public_ranking_refresh_interval_seconds,
get_public_recent_matches_refresh_interval_seconds,
)
from app.payloads import (
build_leaderboard_snapshot_payload,
build_recent_historical_matches_snapshot_payload,
)
from app.historical_runner import (
_run_refresh_with_retries,
get_next_public_full_refresh_at,
run_periodic_historical_refresh,
) )
from app.historical_runner import _run_refresh_with_retries, run_periodic_historical_refresh
from app.historical_snapshots import _normalize_snapshot_limit from app.historical_snapshots import _normalize_snapshot_limit
from app.postgres_display_storage import _json_payload_default from app.postgres_display_storage import _json_payload_default
from app.rcon_historical_read_model import ( from app.rcon_historical_read_model import (
@@ -51,6 +64,108 @@ class HistoricalSnapshotRefreshTests(unittest.TestCase):
): ):
get_historical_refresh_interval_seconds() get_historical_refresh_interval_seconds()
def test_public_refresh_env_values_are_parsed_before_use(self) -> None:
with patch.dict(
os.environ,
{
"HLL_PUBLIC_FULL_REFRESH_ENABLED": "true",
"HLL_PUBLIC_FULL_REFRESH_TIME": "06:00",
"HLL_PUBLIC_FULL_REFRESH_TIMEZONE": "Europe/Madrid",
"HLL_PUBLIC_RANKING_REFRESH_INTERVAL_SECONDS": "900",
"HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS": "60",
},
clear=False,
):
self.assertTrue(get_public_full_refresh_enabled())
self.assertEqual(get_public_full_refresh_time(), "06:00")
self.assertEqual(get_public_full_refresh_timezone(), "Europe/Madrid")
self.assertEqual(get_public_ranking_refresh_interval_seconds(), 900)
self.assertEqual(get_public_recent_matches_refresh_interval_seconds(), 60)
def test_next_public_full_refresh_uses_madrid_six_am(self) -> None:
with patch.dict(
os.environ,
{
"HLL_PUBLIC_FULL_REFRESH_TIME": "06:00",
"HLL_PUBLIC_FULL_REFRESH_TIMEZONE": "Europe/Madrid",
},
clear=False,
):
next_refresh = get_next_public_full_refresh_at(
now=datetime(2026, 6, 10, 3, 30, tzinfo=timezone.utc),
)
self.assertEqual(
next_refresh,
datetime(2026, 6, 10, 4, 0, tzinfo=timezone.utc),
)
def test_historical_leaderboard_snapshot_does_not_runtime_enrich_public_request(self) -> None:
snapshot = {
"generated_at": "2026-06-10T04:00:00Z",
"source_range_start": "2026-06-09T00:00:00Z",
"source_range_end": "2026-06-10T00:00:00Z",
"is_stale": False,
"payload": {
"items": [
{
"ranking_position": 1,
"player": {"name": "Player One"},
"metric_value": 12,
"matches_considered": 1,
"kills": 12,
}
],
"window_start": "2026-06-09T00:00:00Z",
"window_end": "2026-06-10T00:00:00Z",
"limit": 10,
},
}
with (
patch("app.payloads._get_historical_snapshot_record", return_value=snapshot),
patch("app.payloads._load_runtime_leaderboard_items") as runtime_loader,
):
payload = build_leaderboard_snapshot_payload(
server_id="all-servers",
timeframe="weekly",
metric="kills",
limit=10,
)
runtime_loader.assert_not_called()
self.assertEqual(payload["data"]["items"][0]["metric_value"], 12)
self.assertFalse(payload["data"]["runtime_enrichment"]["applied"])
def test_recent_matches_snapshot_does_not_complete_from_public_scoreboard(self) -> None:
snapshot = {
"generated_at": "2026-06-10T04:00:00Z",
"source_range_start": "2026-06-09T21:00:00Z",
"source_range_end": "2026-06-09T22:00:00Z",
"is_stale": False,
"payload": {
"items": [
{
"match_id": "match-1",
"closed_at": "2026-06-09T22:00:00Z",
}
],
"limit": 100,
},
}
with (
patch("app.payloads._get_historical_snapshot_record", return_value=snapshot),
patch("app.payloads.get_historical_data_source_kind", return_value="rcon"),
patch("app.payloads.list_recent_historical_matches") as fallback_loader,
):
payload = build_recent_historical_matches_snapshot_payload(
server_slug="all-servers",
limit=100,
)
fallback_loader.assert_not_called()
self.assertEqual(len(payload["data"]["items"]), 1)
self.assertFalse(payload["data"].get("fallback_used", False))
def test_rcon_coverage_accepts_postgres_datetime_values(self) -> None: def test_rcon_coverage_accepts_postgres_datetime_values(self) -> None:
start = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc) start = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc)
end = datetime(2026, 5, 21, 11, 30, tzinfo=timezone.utc) end = datetime(2026, 5, 21, 11, 30, tzinfo=timezone.utc)

View File

@@ -45,7 +45,7 @@ services:
build: build:
context: ./backend context: ./backend
container_name: hll-vietnam-historical-runner container_name: hll-vietnam-historical-runner
command: ["python", "-m", "app.historical_runner", "--hourly"] command: ["python", "-m", "app.historical_runner"]
env_file: env_file:
- ./backend/.env.example - ./backend/.env.example
environment: environment:
@@ -53,6 +53,11 @@ services:
HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon} HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon}
HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon} HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon}
HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20} HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20}
HLL_PUBLIC_FULL_REFRESH_ENABLED: ${HLL_PUBLIC_FULL_REFRESH_ENABLED:-true}
HLL_PUBLIC_FULL_REFRESH_TIME: ${HLL_PUBLIC_FULL_REFRESH_TIME:-06:00}
HLL_PUBLIC_FULL_REFRESH_TIMEZONE: ${HLL_PUBLIC_FULL_REFRESH_TIMEZONE:-Europe/Madrid}
HLL_PUBLIC_RANKING_REFRESH_INTERVAL_SECONDS: ${HLL_PUBLIC_RANKING_REFRESH_INTERVAL_SECONDS:-900}
HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS: ${HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS:-60}
HLL_BACKEND_RCON_TARGETS: >- HLL_BACKEND_RCON_TARGETS: >-
${HLL_BACKEND_RCON_TARGETS:-[{"name":"Comunidad Hispana #01","slug":"comunidad-hispana-01","external_server_id":"comunidad-hispana-01","host":"152.114.195.174","port":7779,"password":"replace-me-01","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #02","slug":"comunidad-hispana-02","external_server_id":"comunidad-hispana-02","host":"152.114.195.150","port":7879,"password":"replace-me-02","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null}]} ${HLL_BACKEND_RCON_TARGETS:-[{"name":"Comunidad Hispana #01","slug":"comunidad-hispana-01","external_server_id":"comunidad-hispana-01","host":"152.114.195.174","port":7779,"password":"replace-me-01","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #02","slug":"comunidad-hispana-02","external_server_id":"comunidad-hispana-02","host":"152.114.195.150","port":7879,"password":"replace-me-02","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null}]}
depends_on: depends_on:

View File

@@ -0,0 +1,87 @@
# Public Snapshot Refresh Schedule
## Goal
Public pages should read precomputed PostgreSQL read models or persisted public snapshots. Ranking requests must not regenerate snapshots and must not query RCON directly.
## Runner Scheduling
The internal `historical-runner` owns public refresh scheduling. Host cron is not required.
Daily full refresh:
- Controlled by `HLL_PUBLIC_FULL_REFRESH_ENABLED`.
- Runs once per local day after `HLL_PUBLIC_FULL_REFRESH_TIME` in `HLL_PUBLIC_FULL_REFRESH_TIMEZONE`.
- Default: `06:00 Europe/Madrid`.
- Intended for the lowest RCON and database load window.
The daily full refresh rebuilds:
- full historical public snapshots/read models from `generate_and_persist_historical_snapshots()`;
- weekly/monthly `ranking_snapshots`;
- annual 2026 ranking snapshots for `kills`, `deaths`, `teamkills`, `matches_considered`, `kd_ratio` and `kills_per_match`;
- `player_search_index`;
- `player_period_stats`.
Frequent refreshes:
- `HLL_PUBLIC_RANKING_REFRESH_INTERVAL_SECONDS`, default `900`, refreshes weekly/monthly public ranking snapshots.
- `HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS`, default `60`, refreshes recent-match snapshots when no direct finished-match hook fires.
- When the RCON capture cycle reports newly materialized finished matches, the runner refreshes recent-match snapshots immediately.
Refreshes are idempotent: existing ranking snapshots are replaced for the same window/scope, player read models are rebuilt per scope, and persisted historical snapshots are replaced/upserted by snapshot identity.
## Portainer
Run the `historical-runner` service from the Compose stack with the advanced profile. The service runs:
```powershell
python -m app.historical_runner
```
Recommended Portainer environment values:
```text
HLL_PUBLIC_FULL_REFRESH_ENABLED=true
HLL_PUBLIC_FULL_REFRESH_TIME=06:00
HLL_PUBLIC_FULL_REFRESH_TIMEZONE=Europe/Madrid
HLL_PUBLIC_RANKING_REFRESH_INTERVAL_SECONDS=900
HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS=60
```
Do not add host cron unless the internal runner cannot be used in the deployment. If a sidecar is ever required, it should call the same Python modules, not duplicate SQL logic.
## Manual Emergency Commands
One-off full runner cycle:
```powershell
docker compose exec historical-runner python -m app.historical_runner --max-runs 1
```
Weekly/monthly ranking snapshots:
```powershell
docker compose exec historical-runner python -m app.rcon_historical_leaderboards refresh-ranking-snapshots --limit 30
```
Annual ranking snapshot, one metric/scope:
```powershell
docker compose exec historical-runner python -m app.rcon_annual_rankings generate --year 2026 --metric kills --server-key all
```
Player read models:
```powershell
docker compose exec historical-runner python -m app.rcon_historical_player_stats refresh-player-search-index
docker compose exec historical-runner python -m app.rcon_historical_player_stats refresh-player-period-stats
```
Operational checks:
```powershell
docker compose ps historical-runner
docker compose logs --tail=200 historical-runner
docker compose exec backend python -m app.storage_diagnostics
```

View File

@@ -1,6 +1,7 @@
(() => { (() => {
const RECENT_MATCHES_ENDPOINT = "/api/historical/recent-matches"; const RECENT_MATCHES_ENDPOINT = "/api/historical/snapshots/recent-matches";
const REFRESH_DELAYS_MS = [150, 1000, 3000, 6000]; const REFRESH_DELAYS_MS = [150, 1000, 3000, 6000];
const RECENT_MATCHES_POLL_INTERVAL_MS = 60000;
const RECENT_MATCHES_LIMIT = 100; const RECENT_MATCHES_LIMIT = 100;
const DEFAULT_RECENT_MATCHES_PAGE_SIZE = 10; const DEFAULT_RECENT_MATCHES_PAGE_SIZE = 10;
const RECENT_MATCHES_PAGE_SIZES = Object.freeze([10, 25, 50, 100]); const RECENT_MATCHES_PAGE_SIZES = Object.freeze([10, 25, 50, 100]);
@@ -26,6 +27,9 @@
void refreshDynamicRecentMatches(); void refreshDynamicRecentMatches();
}, delay); }, delay);
}); });
window.setInterval(() => {
void refreshDynamicRecentMatches();
}, RECENT_MATCHES_POLL_INTERVAL_MS);
document.querySelectorAll("[data-server-slug]").forEach((button) => { document.querySelectorAll("[data-server-slug]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
@@ -360,4 +364,4 @@
.replaceAll('"', "&quot;") .replaceAll('"', "&quot;")
.replaceAll("'", "&#039;"); .replaceAll("'", "&#039;");
} }
})(); })();

View File

@@ -175,7 +175,7 @@ document.addEventListener("DOMContentLoaded", () => {
`Preparando datos ${activeTimeframeConfig.shortLabel}...`, `Preparando datos ${activeTimeframeConfig.shortLabel}...`,
); );
resetRecentMatchesPagination(); resetRecentMatchesPagination();
recentListNode.innerHTML = ""; renderRecentMatchesLoading(recentListNode);
recentNoteNode.textContent = buildRecentMatchesNote(activeServerSlug); recentNoteNode.textContent = buildRecentMatchesNote(activeServerSlug);
setState(recentStateNode, "Cargando partidas recientes..."); setState(recentStateNode, "Cargando partidas recientes...");
setSnapshotMeta(recentSnapshotMetaNode, "Cargando datos de partidas..."); setSnapshotMeta(recentSnapshotMetaNode, "Cargando datos de partidas...");
@@ -637,6 +637,8 @@ function hydrateWeeklyLeaderboard(
function hydrateRecentMatches(result, stateNode, listNode, snapshotMetaNode) { function hydrateRecentMatches(result, stateNode, listNode, snapshotMetaNode) {
const emptyState = getHistoricalEmptyState(activeServerSlug); const emptyState = getHistoricalEmptyState(activeServerSlug);
if (result.status !== "fulfilled") { if (result.status !== "fulfilled") {
resetRecentMatchesPagination();
listNode.innerHTML = "";
setState(stateNode, "No se pudieron cargar las partidas recientes.", true); setState(stateNode, "No se pudieron cargar las partidas recientes.", true);
setSnapshotMeta(snapshotMetaNode, "Error al leer los datos de partidas."); setSnapshotMeta(snapshotMetaNode, "Error al leer los datos de partidas.");
return; return;
@@ -666,6 +668,34 @@ function hydrateRecentMatches(result, stateNode, listNode, snapshotMetaNode) {
stateNode.hidden = true; stateNode.hidden = true;
} }
function renderRecentMatchesLoading(listNode) {
if (!listNode) {
return;
}
listNode.innerHTML = Array.from({ length: 3 }, (_, index) => `
<article class="historical-match-card historical-match-card--clean" aria-hidden="true">
<div class="historical-match-card__top historical-match-card__top--clean">
<h3 class="historical-match-card__title">Cargando partida ${index + 1}</h3>
</div>
<div class="historical-match-meta historical-match-meta--clean">
<article>
<p class="historical-match-meta__label">Mapa</p>
<strong>Preparando registro</strong>
</article>
<article>
<p class="historical-match-meta__label">Cierre</p>
<strong>...</strong>
</article>
<article>
<p class="historical-match-meta__label">Resultado</p>
<strong>...</strong>
</article>
</div>
</article>
`).join("");
}
function initializeRecentMatchesPagination(listNode) { function initializeRecentMatchesPagination(listNode) {
if (!listNode) { if (!listNode) {
return null; return null;