feat: add rcon historical backfill for leaderboard windows

This commit is contained in:
2026-05-23 13:51:16 +02:00
parent a215b382db
commit 0d061803a0
9 changed files with 1017 additions and 58 deletions

View File

@@ -35,6 +35,9 @@ DEFAULT_PLAYER_EVENT_REFRESH_RETRY_DELAY_SECONDS = 30
DEFAULT_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS = 600
DEFAULT_RCON_HISTORICAL_CAPTURE_MAX_RETRIES = 2
DEFAULT_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS = 15
DEFAULT_RCON_BACKFILL_CHUNK_HOURS = 6
DEFAULT_RCON_BACKFILL_SLEEP_SECONDS = 1.0
DEFAULT_RCON_BACKFILL_MAX_DAYS_BACK = 45
DEFAULT_SQLITE_WRITER_TIMEOUT_SECONDS = 30.0
DEFAULT_SQLITE_BUSY_TIMEOUT_MS = 30000
DEFAULT_WRITER_LOCK_TIMEOUT_SECONDS = 120.0
@@ -484,6 +487,33 @@ def get_rcon_historical_capture_retry_delay_seconds() -> int:
return retry_delay_seconds
def get_rcon_backfill_chunk_hours() -> int:
"""Return the AdminLog backfill chunk size in hours."""
return _read_int_env(
"HLL_RCON_BACKFILL_CHUNK_HOURS",
str(DEFAULT_RCON_BACKFILL_CHUNK_HOURS),
minimum=1,
)
def get_rcon_backfill_sleep_seconds() -> float:
"""Return the delay between AdminLog backfill RCON requests."""
return _read_float_env(
"HLL_RCON_BACKFILL_SLEEP_SECONDS",
str(DEFAULT_RCON_BACKFILL_SLEEP_SECONDS),
minimum=0,
)
def get_rcon_backfill_max_days_back() -> int:
"""Return the maximum AdminLog backfill lookback horizon in days."""
return _read_int_env(
"HLL_RCON_BACKFILL_MAX_DAYS_BACK",
str(DEFAULT_RCON_BACKFILL_MAX_DAYS_BACK),
minimum=1,
)
def get_a2s_targets_payload() -> str | None:
"""Return the optional JSON payload that overrides local A2S targets."""
raw_payload = os.getenv(DEFAULT_A2S_TARGETS_ENV_VAR)

View File

@@ -67,7 +67,6 @@ SUPPORTED_LEADERBOARD_METRICS = frozenset(
PREWARM_SNAPSHOT_SERVER_KEYS = (
"comunidad-hispana-01",
"comunidad-hispana-02",
"comunidad-hispana-03",
ALL_SERVERS_SLUG,
)
PREWARM_LEADERBOARD_METRICS = ("kills",)
@@ -435,12 +434,24 @@ def _build_weekly_leaderboard_snapshot(
limit: int,
db_path: Path | None = None,
) -> dict[str, object]:
leaderboard_result = list_weekly_leaderboard(
limit=limit,
server_id=server_key,
metric=metric,
db_path=db_path,
)
if get_historical_data_source_kind() == SOURCE_KIND_RCON:
from .rcon_historical_leaderboards import list_rcon_materialized_leaderboard
leaderboard_result = list_rcon_materialized_leaderboard(
limit=limit,
server_key=server_key,
metric=metric,
timeframe="weekly",
db_path=db_path,
now=generated_at,
)
else:
leaderboard_result = list_weekly_leaderboard(
limit=limit,
server_id=server_key,
metric=metric,
db_path=db_path,
)
return {
"server_key": server_key,
"snapshot_type": SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
@@ -468,12 +479,24 @@ def _build_monthly_leaderboard_snapshot(
limit: int,
db_path: Path | None = None,
) -> dict[str, object]:
leaderboard_result = list_monthly_leaderboard(
limit=limit,
server_id=server_key,
metric=metric,
db_path=db_path,
)
if get_historical_data_source_kind() == SOURCE_KIND_RCON:
from .rcon_historical_leaderboards import list_rcon_materialized_leaderboard
leaderboard_result = list_rcon_materialized_leaderboard(
limit=limit,
server_key=server_key,
metric=metric,
timeframe="monthly",
db_path=db_path,
now=generated_at,
)
else:
leaderboard_result = list_monthly_leaderboard(
limit=limit,
server_id=server_key,
metric=metric,
db_path=db_path,
)
return {
"server_key": server_key,
"snapshot_type": SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,

View File

@@ -0,0 +1,484 @@
"""Explicit RCON/AdminLog historical backfill command."""
from __future__ import annotations
import argparse
import json
import time
from dataclasses import dataclass
from contextlib import closing
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Iterable
from .config import (
get_rcon_backfill_chunk_hours,
get_rcon_backfill_max_days_back,
get_rcon_backfill_sleep_seconds,
get_rcon_request_timeout_seconds,
use_postgres_rcon_storage,
)
from .historical_runner import generate_historical_snapshots
from .historical_storage import ALL_SERVERS_SLUG
from .rcon_admin_log_materialization import (
MATCH_RESULT_SOURCE,
initialize_rcon_materialized_storage,
materialize_rcon_admin_log,
)
from .rcon_admin_log_storage import persist_rcon_admin_log_entries
from .rcon_client import HllRconConnection, RconServerTarget, build_rcon_target_key, load_rcon_targets
from .rcon_historical_leaderboards import list_rcon_materialized_leaderboard
from .sqlite_utils import connect_sqlite_readonly
from .writer_lock import backend_writer_lock, build_writer_lock_holder
DEFAULT_ALLOWED_SERVER_KEYS = frozenset({"comunidad-hispana-01", "comunidad-hispana-02"})
EXCLUDED_BY_DEFAULT_SERVER_KEYS = frozenset({"comunidad-hispana-03"})
@dataclass(frozen=True, slots=True)
class BackfillWindow:
start: datetime
end: datetime
@property
def lookback_seconds(self) -> int:
now = datetime.now(timezone.utc)
return max(1, int((now - self.start).total_seconds()))
def run_rcon_historical_backfill(
*,
servers: str | None = None,
from_value: str | None = None,
to_value: str | None = None,
ensure_recent_matches: int | None = None,
ensure_current_month: bool = False,
ensure_leaderboard_windows: bool = False,
chunk_hours: int | None = None,
sleep_seconds: float | None = None,
max_days_back: int | None = None,
dry_run: bool = False,
regenerate_snapshots: bool = False,
db_path: Path | None = None,
) -> dict[str, object]:
"""Backfill AdminLog events and materialized RCON matches on explicit operator command."""
anchor = datetime.now(timezone.utc)
resolved_chunk_hours = chunk_hours or get_rcon_backfill_chunk_hours()
resolved_sleep_seconds = (
get_rcon_backfill_sleep_seconds() if sleep_seconds is None else sleep_seconds
)
resolved_max_days_back = max_days_back or get_rcon_backfill_max_days_back()
selected_targets = select_backfill_targets(servers)
recent_before = count_recent_materialized_closed_matches(db_path=db_path)
monthly_before = _window_diagnostic("monthly", db_path=db_path, now=anchor)
weekly_before = _window_diagnostic("weekly", db_path=db_path, now=anchor)
requested_range = _resolve_requested_range(
anchor=anchor,
from_value=from_value,
to_value=to_value,
ensure_recent_matches=ensure_recent_matches,
ensure_current_month=ensure_current_month,
ensure_leaderboard_windows=ensure_leaderboard_windows,
max_days_back=resolved_max_days_back,
)
windows = _build_backfill_windows(
start=requested_range["start"],
end=requested_range["end"],
chunk_hours=resolved_chunk_hours,
)
result: dict[str, object] = {
"status": "dry-run" if dry_run else "ok",
"dry_run": dry_run,
"servers_processed": [build_rcon_target_key(target) for target in selected_targets],
"requested_range": {
"from": _to_iso(requested_range["start"]),
"to": _to_iso(requested_range["end"]),
"reason": requested_range["reason"],
"admin_log_api": "lookback-only",
},
"actual_windows_scanned": [],
"events_seen": 0,
"events_inserted": 0,
"duplicate_events": 0,
"matches_materialized": 0,
"matches_updated": 0,
"player_stats_materialized": 0,
"player_stats_updated": 0,
"recent_materialized_closed_match_count_before": recent_before,
"recent_materialized_closed_match_count_after": recent_before,
"monthly_selected_window_before": monthly_before,
"monthly_selected_window": monthly_before,
"weekly_selected_window_before": weekly_before,
"weekly_selected_window": weekly_before,
"snapshot_regeneration_result": None,
"errors": [],
}
if dry_run:
result["actual_windows_scanned"] = [
_serialize_window(window) for window in _limit_windows_for_recent_need(
windows,
ensure_recent_matches=ensure_recent_matches,
db_path=db_path,
)
]
return result
try:
with backend_writer_lock(
holder=build_writer_lock_holder("app.rcon_historical_backfill")
):
windows_to_scan = _limit_windows_for_recent_need(
windows,
ensure_recent_matches=ensure_recent_matches,
db_path=db_path,
)
for window in windows_to_scan:
for target in selected_targets:
window_result = _scan_target_window(target, window)
result["actual_windows_scanned"].append(window_result["window"])
result["events_seen"] = int(result["events_seen"]) + int(
window_result["events_seen"]
)
result["events_inserted"] = int(result["events_inserted"]) + int(
window_result["events_inserted"]
)
result["duplicate_events"] = int(result["duplicate_events"]) + int(
window_result["duplicate_events"]
)
if window_result.get("error"):
result["errors"].append(window_result["error"])
if resolved_sleep_seconds > 0:
time.sleep(resolved_sleep_seconds)
materialized = materialize_rcon_admin_log(db_path=db_path)
result["matches_materialized"] = int(result["matches_materialized"]) + int(
materialized.get("matches_materialized") or 0
)
result["matches_updated"] = int(result["matches_updated"]) + int(
materialized.get("matches_updated") or 0
)
result["player_stats_materialized"] = int(
result["player_stats_materialized"]
) + int(materialized.get("player_stats_materialized") or 0)
result["player_stats_updated"] = int(result["player_stats_updated"]) + int(
materialized.get("player_stats_updated") or 0
)
if ensure_recent_matches and count_recent_materialized_closed_matches(
db_path=db_path
) >= ensure_recent_matches:
break
if regenerate_snapshots:
result["snapshot_regeneration_result"] = generate_historical_snapshots(
server_slug=None,
run_number=1,
)
except Exception as exc: # noqa: BLE001 - CLI reports structured operator diagnostics
result["status"] = "error"
result["errors"].append({"error_type": type(exc).__name__, "message": str(exc)})
recent_after = count_recent_materialized_closed_matches(db_path=db_path)
result["recent_materialized_closed_match_count_after"] = recent_after
result["monthly_selected_window"] = _window_diagnostic("monthly", db_path=db_path, now=anchor)
result["weekly_selected_window"] = _window_diagnostic("weekly", db_path=db_path, now=anchor)
if result["errors"] and result["status"] == "ok":
result["status"] = "partial"
return result
def select_backfill_targets(servers: str | None) -> list[RconServerTarget]:
"""Load configured RCON targets and apply safe server selection rules."""
configured_targets = list(load_rcon_targets())
if not configured_targets:
raise RuntimeError("No RCON targets configured in HLL_BACKEND_RCON_TARGETS.")
by_key = {build_rcon_target_key(target): target for target in configured_targets}
requested_keys = _parse_server_keys(servers)
if requested_keys:
unknown = sorted(key for key in requested_keys if key not in by_key)
if unknown:
raise ValueError(f"Unknown RCON server key(s): {', '.join(unknown)}")
return [by_key[key] for key in requested_keys]
selected = [
target
for key, target in by_key.items()
if key in DEFAULT_ALLOWED_SERVER_KEYS and key not in EXCLUDED_BY_DEFAULT_SERVER_KEYS
]
if not selected:
raise RuntimeError(
"No default backfill targets selected. Pass --servers with configured keys explicitly."
)
return selected
def count_recent_materialized_closed_matches(
*,
server_key: str | None = None,
db_path: Path | None = None,
) -> int:
"""Count materialized closed AdminLog matches available for recent-match UI."""
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
scope_sql = ""
params: list[object] = [MATCH_RESULT_SOURCE]
if server_key and server_key != ALL_SERVERS_SLUG:
scope_sql = "AND (target_key = ? OR external_server_id = ?)"
params.extend([server_key, server_key])
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat
connection_scope = connect_postgres_compat()
else:
connection_scope = closing(connect_sqlite_readonly(resolved_path))
with connection_scope as connection:
row = connection.execute(
f"""
SELECT COUNT(*) AS count
FROM rcon_materialized_matches
WHERE source_basis = ?
AND ended_at IS NOT NULL
{scope_sql}
""",
params,
).fetchone()
return int(row["count"] or 0) if row else 0
def _scan_target_window(target: RconServerTarget, window: BackfillWindow) -> dict[str, object]:
target_metadata = _serialize_target(target)
serialized_window = _serialize_window(window)
try:
with HllRconConnection(timeout_seconds=get_rcon_request_timeout_seconds()) as connection:
connection.connect(host=target.host, port=target.port, password=target.password)
payload = connection.execute_json(
"GetAdminLog",
{
"LogBackTrackTime": window.lookback_seconds,
"Filters": [],
},
)
entries = payload.get("entries")
if not isinstance(entries, list):
entries = []
normalized_entries = [entry for entry in entries if isinstance(entry, dict)]
delta = persist_rcon_admin_log_entries(
target=target_metadata,
entries=normalized_entries,
)
return {"window": serialized_window, "error": None, **delta}
except Exception as exc: # noqa: BLE001 - per-window errors must not hide neighboring windows
return {
"window": serialized_window,
"events_seen": 0,
"events_inserted": 0,
"duplicate_events": 0,
"error": {
**target_metadata,
**serialized_window,
"error_type": type(exc).__name__,
"message": str(exc),
},
}
def _resolve_requested_range(
*,
anchor: datetime,
from_value: str | None,
to_value: str | None,
ensure_recent_matches: int | None,
ensure_current_month: bool,
ensure_leaderboard_windows: bool,
max_days_back: int,
) -> dict[str, object]:
end = _parse_datetime_argument(to_value, default=anchor)
starts = []
reasons = []
if from_value:
starts.append(_parse_datetime_argument(from_value, default=anchor))
reasons.append("explicit-range")
if ensure_current_month:
starts.append(_month_start(anchor))
reasons.append("ensure-current-month")
if ensure_leaderboard_windows:
starts.append(_previous_month_start(_month_start(anchor)))
starts.append(_week_start(anchor) - timedelta(days=7))
reasons.append("ensure-leaderboard-windows")
if ensure_recent_matches:
starts.append(anchor - timedelta(days=max_days_back))
reasons.append(f"ensure-recent-matches-{ensure_recent_matches}")
if not starts:
starts.append(anchor - timedelta(days=max_days_back))
reasons.append("default-max-days-back")
start = max(min(starts), anchor - timedelta(days=max_days_back))
return {"start": start, "end": end, "reason": ",".join(reasons)}
def _build_backfill_windows(
*,
start: datetime,
end: datetime,
chunk_hours: int,
) -> list[BackfillWindow]:
windows: list[BackfillWindow] = []
cursor = _as_utc(end)
lower = _as_utc(start)
chunk = timedelta(hours=chunk_hours)
while cursor > lower:
window_start = max(lower, cursor - chunk)
windows.append(BackfillWindow(start=window_start, end=cursor))
cursor = window_start
return windows
def _limit_windows_for_recent_need(
windows: list[BackfillWindow],
*,
ensure_recent_matches: int | None,
db_path: Path | None,
) -> list[BackfillWindow]:
if not ensure_recent_matches:
return windows
if count_recent_materialized_closed_matches(db_path=db_path) >= ensure_recent_matches:
return []
return windows
def _window_diagnostic(
timeframe: str,
*,
db_path: Path | None,
now: datetime,
) -> dict[str, object]:
payload = list_rcon_materialized_leaderboard(
server_key=ALL_SERVERS_SLUG,
timeframe=timeframe,
metric="kills",
limit=1,
db_path=db_path,
now=now,
)
return {
"window_kind": payload.get("window_kind"),
"window_label": payload.get("window_label"),
"window_start": payload.get("window_start"),
"window_end": payload.get("window_end"),
"selection_reason": payload.get("selection_reason"),
"current_week_closed_matches": payload.get("current_week_closed_matches"),
"previous_week_closed_matches": payload.get("previous_week_closed_matches"),
"selected_month_start": payload.get("selected_month_start"),
"selected_month_end": payload.get("selected_month_end"),
"current_month_closed_matches": payload.get("current_month_closed_matches"),
"previous_month_closed_matches": payload.get("previous_month_closed_matches"),
"sufficient_sample": payload.get("sufficient_sample"),
}
def _parse_server_keys(value: str | None) -> list[str]:
return [part.strip() for part in str(value or "").split(",") if part.strip()]
def _parse_datetime_argument(value: str | None, *, default: datetime) -> datetime:
if value is None or str(value).strip().lower() == "now":
return default
raw = str(value).strip()
if len(raw) == 10:
raw = f"{raw}T00:00:00+00:00"
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
return _as_utc(parsed)
def _month_start(value: datetime) -> datetime:
point = _as_utc(value)
return point.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
def _previous_month_start(current_month_start: datetime) -> datetime:
return _month_start(current_month_start - timedelta(days=1))
def _week_start(value: datetime) -> datetime:
point = _as_utc(value)
return (point - timedelta(days=point.weekday())).replace(
hour=0,
minute=0,
second=0,
microsecond=0,
)
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _serialize_target(target: RconServerTarget) -> dict[str, object]:
return {
"target_key": build_rcon_target_key(target),
"external_server_id": target.external_server_id,
"name": target.name,
"host": target.host,
"port": target.port,
"source_name": target.source_name,
}
def _serialize_window(window: BackfillWindow) -> dict[str, object]:
return {
"start": _to_iso(window.start),
"end": _to_iso(window.end),
"requested_log_backtrack_seconds": window.lookback_seconds,
}
def _to_iso(value: datetime) -> str:
return _as_utc(value).isoformat().replace("+00:00", "Z")
def _main(argv: Iterable[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Backfill RCON AdminLog historical materialized matches.")
parser.add_argument("--from", dest="from_value", default=None)
parser.add_argument("--to", dest="to_value", default=None)
parser.add_argument("--servers", default=None)
parser.add_argument("--ensure-recent-matches", type=int, default=None)
parser.add_argument("--ensure-current-month", action="store_true")
parser.add_argument("--ensure-leaderboard-windows", action="store_true")
parser.add_argument("--chunk-hours", type=int, default=get_rcon_backfill_chunk_hours())
parser.add_argument("--sleep-seconds", type=float, default=get_rcon_backfill_sleep_seconds())
parser.add_argument("--max-days-back", type=int, default=get_rcon_backfill_max_days_back())
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--regenerate-snapshots", action="store_true")
parser.add_argument("--db-path", type=Path, default=None)
args = parser.parse_args(list(argv) if argv is not None else None)
if args.ensure_recent_matches is not None and args.ensure_recent_matches <= 0:
raise ValueError("--ensure-recent-matches must be positive.")
if args.chunk_hours <= 0:
raise ValueError("--chunk-hours must be positive.")
if args.sleep_seconds < 0:
raise ValueError("--sleep-seconds must be zero or positive.")
if args.max_days_back <= 0:
raise ValueError("--max-days-back must be positive.")
payload = run_rcon_historical_backfill(
servers=args.servers,
from_value=args.from_value,
to_value=args.to_value,
ensure_recent_matches=args.ensure_recent_matches,
ensure_current_month=args.ensure_current_month,
ensure_leaderboard_windows=args.ensure_leaderboard_windows,
chunk_hours=args.chunk_hours,
sleep_seconds=args.sleep_seconds,
max_days_back=args.max_days_back,
dry_run=args.dry_run,
regenerate_snapshots=args.regenerate_snapshots,
db_path=args.db_path,
)
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
return 0 if payload.get("status") != "error" else 1
if __name__ == "__main__":
raise SystemExit(_main())

View File

@@ -8,6 +8,7 @@ from pathlib import Path
from typing import Literal
from .config import get_storage_path, use_postgres_rcon_storage
from .config import get_historical_weekly_fallback_min_matches
from .historical_storage import ALL_SERVERS_SLUG
from .rcon_admin_log_materialization import (
MATCH_RESULT_SOURCE,
@@ -75,6 +76,8 @@ def build_rcon_materialized_leaderboard_snapshot_payload(
"current_week_closed_matches": result.get("current_week_closed_matches"),
"previous_week_closed_matches": result.get("previous_week_closed_matches"),
"current_month_start": result.get("current_month_start"),
"selected_month_start": result.get("selected_month_start"),
"selected_month_end": result.get("selected_month_end"),
"current_month_closed_matches": result.get("current_month_closed_matches"),
"previous_month_closed_matches": result.get("previous_month_closed_matches"),
"sufficient_sample": result.get("sufficient_sample"),
@@ -109,6 +112,7 @@ def list_rcon_materialized_leaderboard(
metric: str = "kills",
limit: int = 10,
db_path: Path | None = None,
now: datetime | None = None,
) -> dict[str, object]:
"""Return a leaderboard built from materialized RCON/AdminLog player stats.
@@ -120,21 +124,26 @@ def list_rcon_materialized_leaderboard(
normalized_timeframe = _normalize_timeframe(timeframe)
normalized_metric = _normalize_metric(metric)
normalized_limit = max(1, int(limit or 10))
window = _build_window(normalized_timeframe)
if normalized_metric == "support":
return _empty_payload(
server_key=server_key,
timeframe=normalized_timeframe,
metric=normalized_metric,
limit=normalized_limit,
window=window,
reason="rcon-materialized-stats-do-not-include-support-score",
)
anchor = _as_utc(now or datetime.now(timezone.utc))
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
connection_scope = _connect_scope(resolved_path, db_path=db_path)
with connection_scope as connection:
window = select_leaderboard_window(
connection=connection,
server_key=server_key,
timeframe=normalized_timeframe,
now=anchor,
)
if normalized_metric == "support":
return _empty_payload(
server_key=server_key,
timeframe=normalized_timeframe,
metric=normalized_metric,
limit=normalized_limit,
window=window,
reason="rcon-materialized-stats-do-not-include-support-score",
)
rows = _fetch_leaderboard_rows(
connection,
server_key=server_key,
@@ -143,13 +152,6 @@ def list_rcon_materialized_leaderboard(
window_start=window["start"],
window_end=window["end"],
)
counts = _fetch_match_counts(
connection,
server_key=server_key,
timeframe=normalized_timeframe,
window_start=window["start"],
window_end=window["end"],
)
source_range = _fetch_source_range(
connection,
server_key=server_key,
@@ -169,20 +171,16 @@ def list_rcon_materialized_leaderboard(
"window_kind": window["kind"],
"window_label": window["label"],
"uses_fallback": False,
"selection_reason": "rcon-materialized-current-window",
"current_week_start": _to_iso(_week_start(window["end"])),
"current_week_closed_matches": counts["current_week_closed_matches"],
"previous_week_closed_matches": counts["previous_week_closed_matches"],
"current_month_start": _to_iso(_month_start(window["end"])),
"current_month_closed_matches": counts["current_month_closed_matches"],
"previous_month_closed_matches": counts["previous_month_closed_matches"],
"sufficient_sample": {
"minimum_closed_matches": 1,
"current_week_closed_matches": counts["current_week_closed_matches"],
"current_week_has_sufficient_sample": counts["current_week_closed_matches"] >= 1,
"is_early_week": False,
"fallback_max_weekday": 2,
},
"selection_reason": window["selection_reason"],
"current_week_start": _to_iso(window["current_week_start"]),
"current_week_closed_matches": window["current_week_closed_matches"],
"previous_week_closed_matches": window["previous_week_closed_matches"],
"current_month_start": _to_iso(window["current_month_start"]),
"selected_month_start": _to_iso(window["selected_month_start"]),
"selected_month_end": _to_iso(window["selected_month_end"]),
"current_month_closed_matches": window["current_month_closed_matches"],
"previous_month_closed_matches": window["previous_month_closed_matches"],
"sufficient_sample": window["sufficient_sample"],
"source_range_start": _to_iso(source_range[0]) if source_range[0] else None,
"source_range_end": _to_iso(source_range[1]) if source_range[1] else None,
"items": items,
@@ -280,6 +278,108 @@ def _fetch_match_counts(
}
def select_leaderboard_window(
*,
connection: object,
server_key: str | None,
timeframe: str,
now: datetime | None = None,
) -> dict[str, object]:
"""Select the RCON leaderboard window using weekly/monthly fallback policy."""
anchor = _as_utc(now or datetime.now(timezone.utc))
current_week_start = _week_start(anchor)
previous_week_start = current_week_start - timedelta(days=7)
current_month_start = _month_start(anchor)
previous_month_start = _previous_month_start(current_month_start)
minimum_week_matches = get_historical_weekly_fallback_min_matches()
current_week_count = _count_matches(
connection,
server_key=server_key,
start=current_week_start,
end=anchor,
)
previous_week_count = _count_matches(
connection,
server_key=server_key,
start=previous_week_start,
end=current_week_start,
)
current_month_count = _count_matches(
connection,
server_key=server_key,
start=current_month_start,
end=anchor,
)
previous_month_count = _count_matches(
connection,
server_key=server_key,
start=previous_month_start,
end=current_month_start,
)
if timeframe == "monthly":
use_previous_month = anchor.day <= 7
start = previous_month_start if use_previous_month else current_month_start
end = current_month_start if use_previous_month else anchor
return {
"start": start,
"end": end,
"days": max(1, (end.date() - start.date()).days),
"kind": "previous-month" if use_previous_month else "current-month",
"label": "Mes anterior" if use_previous_month else "Mes actual",
"selection_reason": (
"monthly-uses-previous-month-until-day-8"
if use_previous_month
else "monthly-uses-current-month-after-day-7"
),
"current_week_start": current_week_start,
"current_week_closed_matches": current_week_count,
"previous_week_closed_matches": previous_week_count,
"current_month_start": current_month_start,
"selected_month_start": start,
"selected_month_end": end,
"current_month_closed_matches": current_month_count,
"previous_month_closed_matches": previous_month_count,
"sufficient_sample": {
"minimum_closed_matches": 1,
"current_month_closed_matches": current_month_count,
"previous_month_closed_matches": previous_month_count,
"current_month_has_sufficient_sample": current_month_count >= 1,
"uses_previous_month_until_day": 7,
},
}
current_week_has_sample = current_week_count >= minimum_week_matches
start = current_week_start if current_week_has_sample else previous_week_start
end = anchor if current_week_has_sample else current_week_start
return {
"start": start,
"end": end,
"days": max(1, (end.date() - start.date()).days),
"kind": "current-week" if current_week_has_sample else "previous-week",
"label": "Semana actual" if current_week_has_sample else "Semana anterior",
"selection_reason": (
"weekly-current-week-has-sufficient-closed-matches"
if current_week_has_sample
else "weekly-fallback-previous-week-insufficient-current-week-data"
),
"current_week_start": current_week_start,
"current_week_closed_matches": current_week_count,
"previous_week_closed_matches": previous_week_count,
"current_month_start": current_month_start,
"selected_month_start": current_month_start,
"selected_month_end": anchor,
"current_month_closed_matches": current_month_count,
"previous_month_closed_matches": previous_month_count,
"sufficient_sample": {
"minimum_closed_matches": minimum_week_matches,
"current_week_closed_matches": current_week_count,
"current_week_has_sufficient_sample": current_week_has_sample,
"previous_week_closed_matches": previous_week_count,
},
}
def _fetch_source_range(
connection: object,
*,
@@ -390,19 +490,15 @@ def _empty_payload(
"window_label": window["label"],
"uses_fallback": False,
"selection_reason": reason,
"current_week_start": _to_iso(_week_start(window["end"])),
"current_week_closed_matches": 0,
"previous_week_closed_matches": 0,
"current_month_start": _to_iso(_month_start(window["end"])),
"current_month_closed_matches": 0,
"previous_month_closed_matches": 0,
"sufficient_sample": {
"minimum_closed_matches": 1,
"current_week_closed_matches": 0,
"current_week_has_sufficient_sample": False,
"is_early_week": False,
"fallback_max_weekday": 2,
},
"current_week_start": _to_iso(window["current_week_start"]),
"current_week_closed_matches": window["current_week_closed_matches"],
"previous_week_closed_matches": window["previous_week_closed_matches"],
"current_month_start": _to_iso(window["current_month_start"]),
"selected_month_start": _to_iso(window["selected_month_start"]),
"selected_month_end": _to_iso(window["selected_month_end"]),
"current_month_closed_matches": window["current_month_closed_matches"],
"previous_month_closed_matches": window["previous_month_closed_matches"],
"sufficient_sample": window["sufficient_sample"],
"source_range_start": None,
"source_range_end": None,
"items": [],
@@ -430,6 +526,12 @@ def _build_window(timeframe: str) -> dict[str, object]:
}
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _week_start(value: datetime) -> datetime:
point = value.astimezone(timezone.utc)
start = point - timedelta(days=point.weekday())