fix: refresh historical snapshots from runner

This commit is contained in:
devRaGonSa
2026-05-22 14:56:54 +02:00
parent dfdb53d2b6
commit 664f3f456f
8 changed files with 296 additions and 59 deletions

View File

@@ -233,20 +233,40 @@ def get_historical_crcon_retry_delay_seconds() -> float:
def get_historical_refresh_interval_seconds() -> int: def get_historical_refresh_interval_seconds() -> int:
"""Return the default interval used by the historical refresh loop.""" """Return the default interval used by the historical refresh loop."""
configured_value = os.getenv( return _read_int_env(
"HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS", "HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS",
os.getenv( os.getenv(
"HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS", "HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS",
str(DEFAULT_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS), str(DEFAULT_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS),
), ),
) minimum=1,
interval_seconds = int(configured_value)
if interval_seconds <= 0:
raise ValueError(
"HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS must be positive."
) )
return interval_seconds
def _read_int_env(name: str, default_value: str, *, minimum: int) -> int:
"""Read one integer env var and keep validation errors actionable."""
configured_value = os.getenv(name, default_value)
try:
value = int(configured_value)
except (TypeError, ValueError) as error:
raise ValueError(f"{name} must be an integer.") from error
if value < minimum:
qualifier = "positive" if minimum == 1 else f"at least {minimum}"
raise ValueError(f"{name} must be {qualifier}.")
return value
def _read_float_env(name: str, default_value: str, *, minimum: float) -> float:
"""Read one float env var and keep validation errors actionable."""
configured_value = os.getenv(name, default_value)
try:
value = float(configured_value)
except (TypeError, ValueError) as error:
raise ValueError(f"{name} must be a number.") from error
if value < minimum:
qualifier = "zero or positive" if minimum == 0 else f"at least {minimum}"
raise ValueError(f"{name} must be {qualifier}.")
return value
def get_historical_refresh_overlap_hours() -> int: def get_historical_refresh_overlap_hours() -> int:
@@ -297,30 +317,20 @@ def get_rcon_request_timeout_seconds() -> float:
def get_historical_refresh_max_retries() -> int: def get_historical_refresh_max_retries() -> int:
"""Return the retry count used by the historical refresh loop.""" """Return the retry count used by the historical refresh loop."""
configured_value = os.getenv( return _read_int_env(
"HLL_HISTORICAL_REFRESH_MAX_RETRIES", "HLL_HISTORICAL_REFRESH_MAX_RETRIES",
str(DEFAULT_HISTORICAL_REFRESH_MAX_RETRIES), str(DEFAULT_HISTORICAL_REFRESH_MAX_RETRIES),
minimum=0,
) )
max_retries = int(configured_value)
if max_retries < 0:
raise ValueError("HLL_HISTORICAL_REFRESH_MAX_RETRIES must be zero or positive.")
return max_retries
def get_historical_refresh_retry_delay_seconds() -> int: def get_historical_refresh_retry_delay_seconds() -> float:
"""Return the wait time between historical refresh retries.""" """Return the wait time between historical refresh retries."""
configured_value = os.getenv( return _read_float_env(
"HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS", "HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS",
str(DEFAULT_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS), str(DEFAULT_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS),
minimum=0,
) )
retry_delay_seconds = int(configured_value)
if retry_delay_seconds < 0:
raise ValueError(
"HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS must be zero or positive."
)
return retry_delay_seconds
def get_historical_full_snapshot_every_runs() -> int: def get_historical_full_snapshot_every_runs() -> int:

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import time import time
import traceback
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
@@ -39,7 +40,7 @@ def run_periodic_historical_refresh(
*, *,
interval_seconds: int, interval_seconds: int,
max_retries: int, max_retries: int,
retry_delay_seconds: int, retry_delay_seconds: float,
server_slug: str | None = None, server_slug: str | None = None,
max_pages: int | None = None, max_pages: int | None = None,
page_size: int | None = None, page_size: int | None = None,
@@ -73,7 +74,7 @@ def run_periodic_historical_refresh(
page_size=page_size, page_size=page_size,
run_number=completed_runs, run_number=completed_runs,
) )
print(json.dumps({"run": completed_runs, **payload}, indent=2), flush=True) _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
@@ -86,7 +87,7 @@ def run_periodic_historical_refresh(
def _run_refresh_with_retries( def _run_refresh_with_retries(
*, *,
max_retries: int, max_retries: int,
retry_delay_seconds: int, retry_delay_seconds: float,
server_slug: str | None, server_slug: str | None,
max_pages: int | None, max_pages: int | None,
page_size: int | None, page_size: int | None,
@@ -182,12 +183,25 @@ def _run_refresh_with_retries(
"elo_mmr_result": elo_mmr_result, "elo_mmr_result": elo_mmr_result,
} }
except Exception as exc: except Exception as exc:
failure_payload = {
"event": "historical-refresh-attempt-failed",
"attempt": attempt,
"max_retries": max_retries,
"server_scope": _describe_refresh_scope(server_slug),
"snapshot_scope": _describe_snapshot_scope(server_slug),
"error_type": type(exc).__name__,
"error": str(exc),
"traceback": traceback.format_exc(),
}
_emit_json_log(failure_payload)
if attempt > max_retries: if attempt > max_retries:
return { return {
"status": "error", "status": "error",
"attempts_used": attempt, "attempts_used": attempt,
"max_retries": max_retries, "max_retries": max_retries,
"error_type": type(exc).__name__,
"error": str(exc), "error": str(exc),
"traceback": failure_payload["traceback"],
} }
if retry_delay_seconds > 0: if retry_delay_seconds > 0:
time.sleep(retry_delay_seconds) time.sleep(retry_delay_seconds)
@@ -202,6 +216,15 @@ def generate_historical_snapshots(
generated_at = datetime.now(timezone.utc) generated_at = datetime.now(timezone.utc)
full_snapshot_every_runs = get_historical_full_snapshot_every_runs() full_snapshot_every_runs = get_historical_full_snapshot_every_runs()
should_run_full_refresh = bool(server_slug) or run_number % full_snapshot_every_runs == 0 should_run_full_refresh = bool(server_slug) or run_number % full_snapshot_every_runs == 0
_emit_json_log(
{
"event": "historical-snapshot-refresh-started",
"run_number": run_number,
"snapshot_step": "full-matrix" if should_run_full_refresh else "priority-prewarm",
"server_slug": server_slug,
"snapshot_scope": _describe_snapshot_scope(server_slug),
}
)
if should_run_full_refresh: if should_run_full_refresh:
result = generate_and_persist_historical_snapshots( result = generate_and_persist_historical_snapshots(
server_key=server_slug, server_key=server_slug,
@@ -221,6 +244,11 @@ def generate_historical_snapshots(
} }
def _emit_json_log(payload: dict[str, Any]) -> None:
"""Print JSON logs that remain safe for Compose and log collectors."""
print(json.dumps(payload, ensure_ascii=True, default=str), flush=True)
def _describe_refresh_scope(server_slug: str | None) -> list[str]: def _describe_refresh_scope(server_slug: str | None) -> list[str]:
if server_slug: if server_slug:
return [server_slug] return [server_slug]
@@ -353,7 +381,7 @@ def main() -> None:
) )
parser.add_argument( parser.add_argument(
"--retry-delay", "--retry-delay",
type=int, type=float,
default=get_historical_refresh_retry_delay_seconds(), default=get_historical_refresh_retry_delay_seconds(),
help="Seconds to wait between failed attempts.", help="Seconds to wait between failed attempts.",
) )

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
import sqlite3 import sqlite3
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -135,9 +136,16 @@ def build_historical_server_snapshots(
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""Build all precomputed historical snapshots required for one server.""" """Build all precomputed historical snapshots required for one server."""
generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc)) generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc))
leaderboard_limit = _normalize_snapshot_limit("leaderboard_limit", leaderboard_limit)
recent_matches_limit = _normalize_snapshot_limit(
"recent_matches_limit",
recent_matches_limit,
)
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_SERVER_SUMMARY)
snapshots = [_build_server_summary_snapshot(server_key, generated_at_value, db_path=db_path)] snapshots = [_build_server_summary_snapshot(server_key, generated_at_value, db_path=db_path)]
for metric in SNAPSHOT_LEADERBOARD_METRICS: for metric in SNAPSHOT_LEADERBOARD_METRICS:
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, metric=metric)
snapshots.append( snapshots.append(
_build_weekly_leaderboard_snapshot( _build_weekly_leaderboard_snapshot(
server_key, server_key,
@@ -147,6 +155,7 @@ def build_historical_server_snapshots(
db_path=db_path, db_path=db_path,
) )
) )
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_LEADERBOARD, metric=metric)
snapshots.append( snapshots.append(
_build_monthly_leaderboard_snapshot( _build_monthly_leaderboard_snapshot(
server_key, server_key,
@@ -157,6 +166,7 @@ def build_historical_server_snapshots(
) )
) )
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP)
snapshots.append( snapshots.append(
_build_monthly_mvp_snapshot( _build_monthly_mvp_snapshot(
server_key, server_key,
@@ -165,6 +175,7 @@ def build_historical_server_snapshots(
db_path=db_path, db_path=db_path,
) )
) )
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP_V2)
snapshots.append( snapshots.append(
_build_monthly_mvp_v2_snapshot( _build_monthly_mvp_v2_snapshot(
server_key, server_key,
@@ -174,6 +185,7 @@ def build_historical_server_snapshots(
) )
) )
for snapshot_type in PLAYER_EVENT_SNAPSHOT_TYPES: for snapshot_type in PLAYER_EVENT_SNAPSHOT_TYPES:
_log_snapshot_build_started(server_key, snapshot_type)
snapshots.append( snapshots.append(
_build_player_event_snapshot( _build_player_event_snapshot(
server_key, server_key,
@@ -184,6 +196,7 @@ def build_historical_server_snapshots(
) )
) )
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_RECENT_MATCHES)
snapshots.append( snapshots.append(
_build_recent_matches_snapshot( _build_recent_matches_snapshot(
server_key, server_key,
@@ -205,12 +218,23 @@ def build_priority_historical_snapshots(
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""Build the minimum warm snapshot set required by the historical UI.""" """Build the minimum warm snapshot set required by the historical UI."""
generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc)) generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc))
leaderboard_limit = _normalize_snapshot_limit("leaderboard_limit", leaderboard_limit)
recent_matches_limit = _normalize_snapshot_limit(
"recent_matches_limit",
recent_matches_limit,
)
snapshots: list[dict[str, object]] = [] snapshots: list[dict[str, object]] = []
for server_key in server_keys: for server_key in server_keys:
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_SERVER_SUMMARY)
snapshots.append( snapshots.append(
_build_server_summary_snapshot(server_key, generated_at_value, db_path=db_path) _build_server_summary_snapshot(server_key, generated_at_value, db_path=db_path)
) )
for metric in PREWARM_LEADERBOARD_METRICS: for metric in PREWARM_LEADERBOARD_METRICS:
_log_snapshot_build_started(
server_key,
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
metric=metric,
)
snapshots.append( snapshots.append(
_build_weekly_leaderboard_snapshot( _build_weekly_leaderboard_snapshot(
server_key, server_key,
@@ -220,6 +244,11 @@ def build_priority_historical_snapshots(
db_path=db_path, db_path=db_path,
) )
) )
_log_snapshot_build_started(
server_key,
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,
metric=metric,
)
snapshots.append( snapshots.append(
_build_monthly_leaderboard_snapshot( _build_monthly_leaderboard_snapshot(
server_key, server_key,
@@ -229,6 +258,7 @@ def build_priority_historical_snapshots(
db_path=db_path, db_path=db_path,
) )
) )
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP)
snapshots.append( snapshots.append(
_build_monthly_mvp_snapshot( _build_monthly_mvp_snapshot(
server_key, server_key,
@@ -237,6 +267,7 @@ def build_priority_historical_snapshots(
db_path=db_path, db_path=db_path,
) )
) )
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP_V2)
snapshots.append( snapshots.append(
_build_monthly_mvp_v2_snapshot( _build_monthly_mvp_v2_snapshot(
server_key, server_key,
@@ -246,6 +277,7 @@ def build_priority_historical_snapshots(
) )
) )
for snapshot_type in PLAYER_EVENT_SNAPSHOT_TYPES: for snapshot_type in PLAYER_EVENT_SNAPSHOT_TYPES:
_log_snapshot_build_started(server_key, snapshot_type)
snapshots.append( snapshots.append(
_build_player_event_snapshot( _build_player_event_snapshot(
server_key, server_key,
@@ -255,6 +287,7 @@ def build_priority_historical_snapshots(
db_path=db_path, db_path=db_path,
) )
) )
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_RECENT_MATCHES)
snapshots.append( snapshots.append(
_build_recent_matches_snapshot( _build_recent_matches_snapshot(
server_key, server_key,
@@ -678,7 +711,7 @@ def _get_latest_player_event_month_key(
with _connect(resolved_path) as connection: with _connect(resolved_path) as connection:
row = connection.execute( row = connection.execute(
f""" f"""
SELECT MAX(substr(occurred_at, 1, 7)) AS latest_month SELECT MAX(substr(CAST(occurred_at AS TEXT), 1, 7)) AS latest_month
FROM player_event_raw_ledger FROM player_event_raw_ledger
WHERE occurred_at IS NOT NULL WHERE occurred_at IS NOT NULL
AND {where_sql} AND {where_sql}
@@ -706,7 +739,7 @@ def _get_player_event_source_range(
MAX(occurred_at) AS source_range_end MAX(occurred_at) AS source_range_end
FROM player_event_raw_ledger FROM player_event_raw_ledger
WHERE occurred_at IS NOT NULL WHERE occurred_at IS NOT NULL
AND substr(occurred_at, 1, 7) = ? AND substr(CAST(occurred_at AS TEXT), 1, 7) = ?
AND {where_sql} AND {where_sql}
""", """,
[month_key, *params], [month_key, *params],
@@ -753,3 +786,34 @@ def _as_utc(value: datetime) -> datetime:
def _to_iso(value: datetime) -> str: def _to_iso(value: datetime) -> str:
return _as_utc(value).isoformat().replace("+00:00", "Z") return _as_utc(value).isoformat().replace("+00:00", "Z")
def _normalize_snapshot_limit(name: str, value: object) -> int:
try:
limit = int(value)
except (TypeError, ValueError) as error:
raise ValueError(f"{name} must be a positive integer.") from error
if limit <= 0:
raise ValueError(f"{name} must be a positive integer.")
return limit
def _log_snapshot_build_started(
server_key: str,
snapshot_type: str,
*,
metric: str | None = None,
) -> None:
print(
json.dumps(
{
"event": "historical-snapshot-build-started",
"server_key": server_key,
"snapshot_type": snapshot_type,
"metric": metric,
},
ensure_ascii=True,
default=str,
),
flush=True,
)

View File

@@ -1842,7 +1842,7 @@ def list_monthly_mvp_v2_ranking(
FROM player_event_raw_ledger FROM player_event_raw_ledger
WHERE event_type = 'player_kill_summary' WHERE event_type = 'player_kill_summary'
AND occurred_at IS NOT NULL AND occurred_at IS NOT NULL
AND substr(occurred_at, 1, 7) = ? AND substr(CAST(occurred_at AS TEXT), 1, 7) = ?
AND {event_scope_sql} AND {event_scope_sql}
AND killer_player_key IS NOT NULL AND killer_player_key IS NOT NULL
AND victim_player_key IS NOT NULL AND victim_player_key IS NOT NULL
@@ -1863,7 +1863,7 @@ def list_monthly_mvp_v2_ranking(
FROM player_event_raw_ledger FROM player_event_raw_ledger
WHERE event_type = 'player_death_summary' WHERE event_type = 'player_death_summary'
AND occurred_at IS NOT NULL AND occurred_at IS NOT NULL
AND substr(occurred_at, 1, 7) = ? AND substr(CAST(occurred_at AS TEXT), 1, 7) = ?
AND {event_scope_sql} AND {event_scope_sql}
AND killer_player_key IS NOT NULL AND killer_player_key IS NOT NULL
AND victim_player_key IS NOT NULL AND victim_player_key IS NOT NULL
@@ -1901,7 +1901,7 @@ def list_monthly_mvp_v2_ranking(
FROM player_event_raw_ledger FROM player_event_raw_ledger
WHERE event_type = 'player_kill_summary' WHERE event_type = 'player_kill_summary'
AND occurred_at IS NOT NULL AND occurred_at IS NOT NULL
AND substr(occurred_at, 1, 7) = ? AND substr(CAST(occurred_at AS TEXT), 1, 7) = ?
AND {event_scope_sql} AND {event_scope_sql}
AND killer_player_key IS NOT NULL AND killer_player_key IS NOT NULL
AND victim_player_key IS NOT NULL AND victim_player_key IS NOT NULL
@@ -2020,7 +2020,7 @@ def _get_monthly_player_event_coverage(
with _connect(db_path) as connection: with _connect(db_path) as connection:
latest_row = connection.execute( latest_row = connection.execute(
f""" f"""
SELECT MAX(substr(occurred_at, 1, 7)) AS latest_month_key SELECT MAX(substr(CAST(occurred_at AS TEXT), 1, 7)) AS latest_month_key
FROM player_event_raw_ledger FROM player_event_raw_ledger
WHERE occurred_at IS NOT NULL WHERE occurred_at IS NOT NULL
AND {scope_sql} AND {scope_sql}
@@ -2035,7 +2035,7 @@ def _get_monthly_player_event_coverage(
MAX(occurred_at) AS source_range_end MAX(occurred_at) AS source_range_end
FROM player_event_raw_ledger FROM player_event_raw_ledger
WHERE occurred_at IS NOT NULL WHERE occurred_at IS NOT NULL
AND substr(occurred_at, 1, 7) = ? AND substr(CAST(occurred_at AS TEXT), 1, 7) = ?
AND {scope_sql} AND {scope_sql}
""", """,
[month_key, *scope_params], [month_key, *scope_params],

View File

@@ -108,7 +108,10 @@ def list_net_duel_summaries(
COALESCE(SUM(net_value), 0) AS net_duel_value COALESCE(SUM(net_value), 0) AS net_duel_value
FROM duel_pairs FROM duel_pairs
GROUP BY player_a_key, player_a_name, player_b_key, player_b_name GROUP BY player_a_key, player_a_name, player_b_key, player_b_name
ORDER BY ABS(net_duel_value) DESC, total_encounters DESC, player_a_name ASC, player_b_name ASC ORDER BY ABS(COALESCE(SUM(net_value), 0)) DESC,
COALESCE(SUM(event_value), 0) DESC,
player_a_name ASC,
player_b_name ASC
LIMIT ? LIMIT ?
""", """,
[*params, limit], [*params, limit],
@@ -239,7 +242,7 @@ def _build_common_where(
clauses.append("server_slug = ?") clauses.append("server_slug = ?")
params.append(server_slug.strip()) params.append(server_slug.strip())
if month: if month:
clauses.append("substr(COALESCE(occurred_at, ''), 1, 7) = ?") clauses.append("substr(COALESCE(CAST(occurred_at AS TEXT), ''), 1, 7) = ?")
params.append(month.strip()) params.append(month.strip())
if external_match_id: if external_match_id:
clauses.append("external_match_id = ?") clauses.append("external_match_id = ?")

View File

@@ -218,7 +218,12 @@ def persist_snapshot_record(snapshot: Mapping[str, object]) -> HistoricalSnapsho
metric = str(snapshot.get("metric") or "") metric = str(snapshot.get("metric") or "")
window = str(snapshot.get("window") or "") window = str(snapshot.get("window") or "")
payload = snapshot.get("payload") payload = snapshot.get("payload")
payload_json = json.dumps(payload, ensure_ascii=True, separators=(",", ":")) payload_json = json.dumps(
payload,
ensure_ascii=True,
separators=(",", ":"),
default=_json_payload_default,
)
with connect_postgres() as connection: with connect_postgres() as connection:
connection.execute( connection.execute(
""" """
@@ -904,6 +909,12 @@ def _iso(value: object) -> str | None:
return text or None return text or None
def _json_payload_default(value: object) -> str:
if isinstance(value, datetime):
return _iso(value) or ""
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
def _parse_datetime(value: str | None) -> datetime | None: def _parse_datetime(value: str | None) -> datetime | None:
if not value: if not value:
return None return None

View File

@@ -582,43 +582,38 @@ def _minutes_since_timestamp(timestamp: str | None) -> int | None:
def _parse_datetime(value: object) -> datetime | None: def _parse_datetime(value: object) -> datetime | None:
if not isinstance(value, str) or not value.strip(): if isinstance(value, datetime):
return None parsed = value
elif isinstance(value, str) and value.strip():
try: try:
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError: except ValueError:
return None return None
else:
return None
if parsed.tzinfo is None: if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc) parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc) return parsed.astimezone(timezone.utc)
def _calculate_coverage_hours( def _calculate_coverage_hours(
first_sample_at: str | None, first_sample_at: object,
last_sample_at: str | None, last_sample_at: object,
) -> float | None: ) -> float | None:
if not first_sample_at or not last_sample_at: first_point = _parse_datetime(first_sample_at)
last_point = _parse_datetime(last_sample_at)
if first_point is None or last_point is None:
return None return None
first_point = datetime.fromisoformat(first_sample_at.replace("Z", "+00:00")) delta = last_point - first_point
last_point = datetime.fromisoformat(last_sample_at.replace("Z", "+00:00"))
if first_point.tzinfo is None:
first_point = first_point.replace(tzinfo=timezone.utc)
if last_point.tzinfo is None:
last_point = last_point.replace(tzinfo=timezone.utc)
delta = last_point.astimezone(timezone.utc) - first_point.astimezone(timezone.utc)
return round(delta.total_seconds() / 3600, 2) return round(delta.total_seconds() / 3600, 2)
def _calculate_duration_seconds(first_seen_at: object, last_seen_at: object) -> int | None: def _calculate_duration_seconds(first_seen_at: object, last_seen_at: object) -> int | None:
if not isinstance(first_seen_at, str) or not isinstance(last_seen_at, str): first_point = _parse_datetime(first_seen_at)
last_point = _parse_datetime(last_seen_at)
if first_point is None or last_point is None:
return None return None
first_point = datetime.fromisoformat(first_seen_at.replace("Z", "+00:00")) return max(0, int((last_point - first_point).total_seconds()))
last_point = datetime.fromisoformat(last_seen_at.replace("Z", "+00:00"))
if first_point.tzinfo is None:
first_point = first_point.replace(tzinfo=timezone.utc)
if last_point.tzinfo is None:
last_point = last_point.replace(tzinfo=timezone.utc)
return max(0, int((last_point.astimezone(timezone.utc) - first_point.astimezone(timezone.utc)).total_seconds()))
def _calculate_match_duration_seconds(item: dict[str, object]) -> int | None: def _calculate_match_duration_seconds(item: dict[str, object]) -> int | None:

View File

@@ -0,0 +1,126 @@
"""Regression coverage for historical snapshot runner refreshes."""
from __future__ import annotations
import io
import json
import os
import unittest
from contextlib import nullcontext, redirect_stdout
from datetime import datetime, timezone
from unittest.mock import patch
from app.config import (
get_historical_refresh_interval_seconds,
get_historical_refresh_max_retries,
get_historical_refresh_retry_delay_seconds,
)
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 (
_calculate_coverage_hours,
_calculate_duration_seconds,
)
class HistoricalSnapshotRefreshTests(unittest.TestCase):
def test_runner_numeric_env_values_are_parsed_before_use(self) -> None:
with patch.dict(
os.environ,
{
"HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS": "300",
"HLL_HISTORICAL_REFRESH_MAX_RETRIES": "4",
"HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS": "0.5",
},
clear=False,
):
self.assertEqual(get_historical_refresh_interval_seconds(), 300)
self.assertEqual(get_historical_refresh_max_retries(), 4)
self.assertEqual(get_historical_refresh_retry_delay_seconds(), 0.5)
def test_runner_numeric_env_values_fail_with_clear_names(self) -> None:
with patch.dict(
os.environ,
{"HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS": "hourly"},
clear=False,
):
with self.assertRaisesRegex(
ValueError,
"HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS must be an integer",
):
get_historical_refresh_interval_seconds()
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)
self.assertEqual(_calculate_coverage_hours(start, end), 1.5)
self.assertEqual(_calculate_duration_seconds(start, end), 5400)
def test_snapshot_limits_are_numeric_before_snapshot_queries(self) -> None:
self.assertEqual(_normalize_snapshot_limit("recent_matches_limit", "10"), 10)
with self.assertRaisesRegex(ValueError, "recent_matches_limit"):
_normalize_snapshot_limit("recent_matches_limit", "ten")
def test_postgres_snapshot_payload_serializes_datetime_values(self) -> None:
payload = {
"captured_at": datetime(2026, 5, 21, 20, 12, 54, tzinfo=timezone.utc),
}
self.assertEqual(
json.loads(json.dumps(payload, default=_json_payload_default)),
{"captured_at": "2026-05-21T20:12:54Z"},
)
def test_runner_failure_log_includes_exception_type_and_traceback(self) -> None:
stream = io.StringIO()
with (
patch("app.historical_runner.backend_writer_lock", return_value=nullcontext()),
patch(
"app.historical_runner._run_primary_rcon_capture",
side_effect=TypeError("bad timestamp"),
),
redirect_stdout(stream),
):
result = _run_refresh_with_retries(
max_retries=0,
retry_delay_seconds=0,
server_slug=None,
max_pages=None,
page_size=None,
run_number=1,
)
self.assertEqual(result["status"], "error")
self.assertEqual(result["error_type"], "TypeError")
self.assertIn("Traceback", result["traceback"])
self.assertIn('"event": "historical-refresh-attempt-failed"', stream.getvalue())
def test_runner_success_log_serializes_datetime_values(self) -> None:
stream = io.StringIO()
with (
patch(
"app.historical_runner._run_refresh_with_retries",
return_value={
"status": "ok",
"rcon_capture_result": {
"captured_at": datetime(2026, 5, 22, tzinfo=timezone.utc),
},
},
),
redirect_stdout(stream),
):
run_periodic_historical_refresh(
interval_seconds=1,
max_retries=0,
retry_delay_seconds=0,
max_runs=1,
)
self.assertIn('"status": "ok"', stream.getvalue())
self.assertIn('"captured_at": "2026-05-22 00:00:00+00:00"', stream.getvalue())
if __name__ == "__main__":
unittest.main()