Schedule public snapshot refreshes and optimize historical loading
This commit is contained in:
140
ai/tasks/done/TASK-220-schedule-public-snapshot-refreshes.md
Normal file
140
ai/tasks/done/TASK-220-schedule-public-snapshot-refreshes.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -21,6 +21,11 @@ DEFAULT_HISTORICAL_CRCON_RETRY_DELAY_SECONDS = 0.5
|
||||
DEFAULT_HISTORICAL_REFRESH_INTERVAL_SECONDS = 1800
|
||||
DEFAULT_HISTORICAL_REFRESH_OVERLAP_HOURS = 12
|
||||
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_RETRY_DELAY_SECONDS = 30
|
||||
DEFAULT_HISTORICAL_FULL_SNAPSHOT_EVERY_RUNS = 4
|
||||
@@ -510,6 +515,58 @@ def get_rcon_historical_capture_retry_delay_seconds() -> int:
|
||||
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:
|
||||
"""Return whether the worker runs the normal historical pipeline or live capture only."""
|
||||
configured_mode = os.getenv("HLL_RCON_CAPTURE_MODE")
|
||||
|
||||
@@ -6,8 +6,9 @@ import argparse
|
||||
import json
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, time as datetime_time, timedelta, timezone
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from .config import (
|
||||
DEFAULT_DB_MAINTENANCE_INTERVAL_SECONDS,
|
||||
@@ -20,6 +21,11 @@ from .config import (
|
||||
get_historical_refresh_max_retries,
|
||||
get_historical_refresh_retry_delay_seconds,
|
||||
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 .elo_mmr_engine import rebuild_elo_mmr_models
|
||||
@@ -28,8 +34,15 @@ from .historical_ingestion import run_incremental_refresh
|
||||
from .historical_snapshots import (
|
||||
generate_and_persist_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 SNAPSHOT_GENERATOR_SERVER_KEYS
|
||||
from .rcon_historical_player_stats import (
|
||||
refresh_player_period_stats,
|
||||
refresh_player_search_index,
|
||||
@@ -44,6 +57,14 @@ DEFAULT_HISTORICAL_SERVER_SCOPE = (
|
||||
"comunidad-hispana-02",
|
||||
)
|
||||
_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(
|
||||
@@ -73,23 +94,41 @@ def run_periodic_historical_refresh(
|
||||
)
|
||||
print("Press Ctrl+C to stop.")
|
||||
|
||||
last_historical_refresh_started_at: datetime | None = None
|
||||
loop_tick_seconds = _resolve_runner_tick_seconds(interval_seconds)
|
||||
try:
|
||||
while max_runs is None or completed_runs < max_runs:
|
||||
completed_runs += 1
|
||||
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,
|
||||
now = datetime.now(timezone.utc)
|
||||
historical_refresh_due = _is_interval_due(
|
||||
last_run_at=last_historical_refresh_started_at,
|
||||
interval_seconds=interval_seconds,
|
||||
now=now,
|
||||
)
|
||||
_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:
|
||||
break
|
||||
|
||||
time.sleep(interval_seconds)
|
||||
time.sleep(loop_tick_seconds)
|
||||
except KeyboardInterrupt:
|
||||
print("\nHistorical refresh loop stopped by user.")
|
||||
|
||||
@@ -193,6 +232,11 @@ def _run_refresh_with_retries(
|
||||
server_slug=server_slug,
|
||||
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()
|
||||
return {
|
||||
"status": _resolve_refresh_cycle_status(
|
||||
@@ -201,6 +245,7 @@ def _run_refresh_with_retries(
|
||||
player_search_index_result=player_search_index_result,
|
||||
player_period_stats_result=player_period_stats_result,
|
||||
ranking_snapshot_result=ranking_snapshot_result,
|
||||
recent_matches_event_result=recent_matches_event_result,
|
||||
elo_mmr_result=elo_mmr_result,
|
||||
database_maintenance_result=maintenance_result,
|
||||
),
|
||||
@@ -215,6 +260,7 @@ def _run_refresh_with_retries(
|
||||
"player_search_index_result": player_search_index_result,
|
||||
"player_period_stats_result": player_period_stats_result,
|
||||
"ranking_snapshot_result": ranking_snapshot_result,
|
||||
"recent_matches_event_result": recent_matches_event_result,
|
||||
"elo_mmr_result": elo_mmr_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:
|
||||
statuses = [
|
||||
str(result.get("status") or "").strip().lower()
|
||||
|
||||
@@ -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(
|
||||
server_key: str,
|
||||
generated_at: datetime,
|
||||
|
||||
@@ -1338,17 +1338,6 @@ def build_leaderboard_snapshot_payload(
|
||||
payload = snapshot.get("payload") if snapshot else {}
|
||||
items = payload.get("items") if isinstance(payload, dict) else None
|
||||
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
|
||||
return {
|
||||
"status": "ok",
|
||||
@@ -1391,12 +1380,8 @@ def build_leaderboard_snapshot_payload(
|
||||
"snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None,
|
||||
"limit": limit,
|
||||
"runtime_enrichment": {
|
||||
"applied": runtime_enrichment_applied,
|
||||
"reason": (
|
||||
"snapshot-items-missing-total-time-seconds"
|
||||
if runtime_enrichment_applied
|
||||
else None
|
||||
),
|
||||
"applied": False,
|
||||
"reason": "disabled-on-public-snapshot-path",
|
||||
},
|
||||
**_resolve_historical_fallback_policy(
|
||||
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 {}
|
||||
items = payload.get("items") if isinstance(payload, dict) else None
|
||||
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 {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
|
||||
@@ -6,7 +6,6 @@ from http import HTTPStatus
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from .config import get_historical_data_source_kind
|
||||
from .payloads import (
|
||||
build_global_ranking_payload,
|
||||
build_stats_player_profile_payload,
|
||||
@@ -46,7 +45,6 @@ from .payloads import (
|
||||
build_weekly_top_kills_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
|
||||
|
||||
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")
|
||||
if timeframe not in {"weekly", "monthly"}:
|
||||
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(
|
||||
limit=limit,
|
||||
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]
|
||||
if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}:
|
||||
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(
|
||||
limit=limit,
|
||||
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]
|
||||
if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}:
|
||||
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(
|
||||
limit=limit,
|
||||
server_id=server_id,
|
||||
|
||||
@@ -14,8 +14,21 @@ from app.config import (
|
||||
get_historical_refresh_interval_seconds,
|
||||
get_historical_refresh_max_retries,
|
||||
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.postgres_display_storage import _json_payload_default
|
||||
from app.rcon_historical_read_model import (
|
||||
@@ -51,6 +64,108 @@ class HistoricalSnapshotRefreshTests(unittest.TestCase):
|
||||
):
|
||||
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:
|
||||
start = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 5, 21, 11, 30, tzinfo=timezone.utc)
|
||||
|
||||
@@ -45,7 +45,7 @@ services:
|
||||
build:
|
||||
context: ./backend
|
||||
container_name: hll-vietnam-historical-runner
|
||||
command: ["python", "-m", "app.historical_runner", "--hourly"]
|
||||
command: ["python", "-m", "app.historical_runner"]
|
||||
env_file:
|
||||
- ./backend/.env.example
|
||||
environment:
|
||||
@@ -53,6 +53,11 @@ services:
|
||||
HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_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_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:-[{"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:
|
||||
|
||||
87
docs/public-snapshot-refresh-schedule.md
Normal file
87
docs/public-snapshot-refresh-schedule.md
Normal 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
|
||||
```
|
||||
@@ -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 RECENT_MATCHES_POLL_INTERVAL_MS = 60000;
|
||||
const RECENT_MATCHES_LIMIT = 100;
|
||||
const DEFAULT_RECENT_MATCHES_PAGE_SIZE = 10;
|
||||
const RECENT_MATCHES_PAGE_SIZES = Object.freeze([10, 25, 50, 100]);
|
||||
@@ -26,6 +27,9 @@
|
||||
void refreshDynamicRecentMatches();
|
||||
}, delay);
|
||||
});
|
||||
window.setInterval(() => {
|
||||
void refreshDynamicRecentMatches();
|
||||
}, RECENT_MATCHES_POLL_INTERVAL_MS);
|
||||
|
||||
document.querySelectorAll("[data-server-slug]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
|
||||
@@ -175,7 +175,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
`Preparando datos ${activeTimeframeConfig.shortLabel}...`,
|
||||
);
|
||||
resetRecentMatchesPagination();
|
||||
recentListNode.innerHTML = "";
|
||||
renderRecentMatchesLoading(recentListNode);
|
||||
recentNoteNode.textContent = buildRecentMatchesNote(activeServerSlug);
|
||||
setState(recentStateNode, "Cargando partidas recientes...");
|
||||
setSnapshotMeta(recentSnapshotMetaNode, "Cargando datos de partidas...");
|
||||
@@ -637,6 +637,8 @@ function hydrateWeeklyLeaderboard(
|
||||
function hydrateRecentMatches(result, stateNode, listNode, snapshotMetaNode) {
|
||||
const emptyState = getHistoricalEmptyState(activeServerSlug);
|
||||
if (result.status !== "fulfilled") {
|
||||
resetRecentMatchesPagination();
|
||||
listNode.innerHTML = "";
|
||||
setState(stateNode, "No se pudieron cargar las partidas recientes.", true);
|
||||
setSnapshotMeta(snapshotMetaNode, "Error al leer los datos de partidas.");
|
||||
return;
|
||||
@@ -666,6 +668,34 @@ function hydrateRecentMatches(result, stateNode, listNode, snapshotMetaNode) {
|
||||
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) {
|
||||
if (!listNode) {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user