Implement public snapshot scheduler

This commit is contained in:
devRaGonSa
2026-06-11 10:00:05 +02:00
parent c93d17ff5f
commit 00e85d4708
8 changed files with 1077 additions and 110 deletions

View File

@@ -26,6 +26,11 @@ 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_PUBLIC_RANKING_WEEKLY_REFRESH_MINUTE = 10
DEFAULT_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES = ("07:00", "19:00")
DEFAULT_PUBLIC_HISTORICAL_WEEKLY_REFRESH_MINUTE = 25
DEFAULT_PUBLIC_HISTORICAL_MONTHLY_REFRESH_MINUTE = 40
DEFAULT_PUBLIC_HISTORICAL_MONTHLY_REFRESH_HOUR_INTERVAL = 2
DEFAULT_HISTORICAL_REFRESH_MAX_RETRIES = 2
DEFAULT_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS = 30
DEFAULT_HISTORICAL_FULL_SNAPSHOT_EVERY_RUNS = 4
@@ -300,6 +305,29 @@ def _read_bool_env(name: str, *, default: bool) -> bool:
raise ValueError(f"{name} must be a boolean value.")
def _normalize_hh_mm_value(name: str, configured_value: str) -> str:
"""Normalize one HH:MM value used by public refresh scheduler settings."""
normalized_value = configured_value.strip()
parts = normalized_value.split(":")
if len(parts) != 2:
raise ValueError(f"{name} must use HH:MM format.")
try:
hour, minute = (int(part) for part in parts)
except (TypeError, ValueError) as error:
raise ValueError(f"{name} must use HH:MM format.") from error
if hour < 0 or hour > 23 or minute < 0 or minute > 59:
raise ValueError(f"{name} must use HH:MM format.")
return f"{hour:02d}:{minute:02d}"
def _read_minute_env(name: str, default_value: int) -> int:
"""Read one scheduler minute offset constrained to one wall-clock hour."""
minute = _read_int_env(name, str(default_value), minimum=0)
if minute > 59:
raise ValueError(f"{name} must be at most 59.")
return minute
def get_historical_refresh_overlap_hours() -> int:
"""Return the overlap window used by incremental historical refreshes."""
configured_value = os.getenv(
@@ -528,14 +556,8 @@ def get_public_full_refresh_time() -> str:
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}"
)
return _normalize_hh_mm_value("HLL_PUBLIC_FULL_REFRESH_TIME", configured_value)
def get_public_full_refresh_timezone() -> str:
@@ -567,6 +589,55 @@ def get_public_recent_matches_refresh_interval_seconds() -> int:
)
def get_public_ranking_weekly_refresh_minute() -> int:
"""Return the minute offset used by hourly weekly ranking snapshot refreshes."""
return _read_minute_env(
"HLL_PUBLIC_RANKING_WEEKLY_REFRESH_MINUTE",
DEFAULT_PUBLIC_RANKING_WEEKLY_REFRESH_MINUTE,
)
def get_public_ranking_monthly_refresh_times() -> tuple[str, ...]:
"""Return the local HH:MM slots used by monthly ranking refreshes."""
configured_value = os.getenv(
"HLL_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES",
",".join(DEFAULT_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES),
)
values = tuple(
_normalize_hh_mm_value("HLL_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES", item)
for item in configured_value.split(",")
if item.strip()
)
if not values:
raise ValueError("HLL_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES cannot be empty.")
return values
def get_public_historical_weekly_refresh_minute() -> int:
"""Return the minute offset used by hourly historical weekly refreshes."""
return _read_minute_env(
"HLL_PUBLIC_HISTORICAL_WEEKLY_REFRESH_MINUTE",
DEFAULT_PUBLIC_HISTORICAL_WEEKLY_REFRESH_MINUTE,
)
def get_public_historical_monthly_refresh_minute() -> int:
"""Return the minute offset used by periodic historical monthly refreshes."""
return _read_minute_env(
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_MINUTE",
DEFAULT_PUBLIC_HISTORICAL_MONTHLY_REFRESH_MINUTE,
)
def get_public_historical_monthly_refresh_hour_interval() -> int:
"""Return the hour interval used by periodic historical monthly refreshes."""
return _read_int_env(
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_HOUR_INTERVAL",
str(DEFAULT_PUBLIC_HISTORICAL_MONTHLY_REFRESH_HOUR_INTERVAL),
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")

View File

@@ -24,7 +24,12 @@ from .config import (
get_public_full_refresh_enabled,
get_public_full_refresh_time,
get_public_full_refresh_timezone,
get_public_historical_monthly_refresh_hour_interval,
get_public_historical_monthly_refresh_minute,
get_public_historical_weekly_refresh_minute,
get_public_ranking_monthly_refresh_times,
get_public_ranking_refresh_interval_seconds,
get_public_ranking_weekly_refresh_minute,
get_public_recent_matches_refresh_interval_seconds,
)
from .database_maintenance import run_database_maintenance_cleanup
@@ -32,7 +37,10 @@ from .elo_mmr_engine import rebuild_elo_mmr_models
from .elo_mmr_storage import get_latest_elo_mmr_generated_at
from .historical_ingestion import run_incremental_refresh
from .historical_snapshots import (
PREWARM_SNAPSHOT_SERVER_KEYS,
generate_and_persist_historical_snapshots,
generate_and_persist_historical_leaderboard_snapshots,
generate_and_persist_historical_monthly_ui_snapshots,
generate_and_persist_priority_historical_snapshots,
generate_and_persist_recent_historical_snapshots,
)
@@ -58,13 +66,27 @@ DEFAULT_HISTORICAL_SERVER_SCOPE = (
)
_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
_LAST_PUBLIC_RANKING_WEEKLY_REFRESH_SLOT: str | None = None
_LAST_PUBLIC_RANKING_MONTHLY_REFRESH_SLOT: str | None = None
_LAST_PUBLIC_HISTORICAL_WEEKLY_REFRESH_SLOT: str | None = None
_LAST_PUBLIC_HISTORICAL_MONTHLY_REFRESH_SLOT: str | 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_RANKING_ANNUAL = "public-ranking-annual-refresh"
PUBLIC_REFRESH_LOCK_RANKING_MONTHLY = "public-ranking-monthly-refresh"
PUBLIC_REFRESH_LOCK_RANKING_WEEKLY = "public-ranking-weekly-refresh"
PUBLIC_REFRESH_LOCK_HISTORICAL_WEEKLY = "public-historical-weekly-refresh"
PUBLIC_REFRESH_LOCK_HISTORICAL_MONTHLY = "public-historical-monthly-refresh"
PUBLIC_REFRESH_LOCK_RECENT_MATCHES = "public-recent-matches-refresh"
PUBLIC_HEAVY_REFRESH_KEYS = frozenset(
{
PUBLIC_REFRESH_LOCK_FULL,
PUBLIC_REFRESH_LOCK_RANKING_ANNUAL,
PUBLIC_REFRESH_LOCK_RANKING_MONTHLY,
}
)
def run_periodic_historical_refresh(
@@ -469,22 +491,118 @@ def _maybe_run_public_read_model_refreshes(
"""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] = {}
heavy_job_ran = False
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),
blocked_by=PUBLIC_HEAVY_REFRESH_KEYS - {PUBLIC_REFRESH_LOCK_FULL},
)
heavy_job_ran = _refresh_result_counts_as_executed(results["public_full_refresh_result"])
if _is_public_interval_refresh_due(
last_run_at=_LAST_PUBLIC_RANKING_REFRESH_AT,
interval_seconds=get_public_ranking_refresh_interval_seconds(),
ranking_monthly_slot = _resolve_latest_daily_schedule_slot(
now=anchor,
times=get_public_ranking_monthly_refresh_times(),
)
if _is_public_slot_refresh_due(
last_completed_slot=_LAST_PUBLIC_RANKING_MONTHLY_REFRESH_SLOT,
slot=ranking_monthly_slot,
):
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 heavy_job_ran:
results["ranking_monthly_snapshot_result"] = _build_public_scheduler_skip_result(
refresh_key=PUBLIC_REFRESH_LOCK_RANKING_MONTHLY,
reason="heavy-public-refresh-ran-this-cycle",
scheduled_slot=ranking_monthly_slot,
)
else:
results["ranking_monthly_snapshot_result"] = _run_non_overlapping_public_refresh(
PUBLIC_REFRESH_LOCK_RANKING_MONTHLY,
lambda: refresh_public_monthly_ranking_snapshots(
run_number=run_number,
now=anchor,
scheduled_slot=ranking_monthly_slot,
),
blocked_by=PUBLIC_HEAVY_REFRESH_KEYS - {PUBLIC_REFRESH_LOCK_RANKING_MONTHLY},
)
if _refresh_result_counts_as_executed(results["ranking_monthly_snapshot_result"]):
heavy_job_ran = True
ranking_weekly_slot = _resolve_latest_hourly_schedule_slot(
now=anchor,
minute=get_public_ranking_weekly_refresh_minute(),
)
if _is_public_slot_refresh_due(
last_completed_slot=_LAST_PUBLIC_RANKING_WEEKLY_REFRESH_SLOT,
slot=ranking_weekly_slot,
):
if heavy_job_ran:
results["ranking_weekly_snapshot_result"] = _build_public_scheduler_skip_result(
refresh_key=PUBLIC_REFRESH_LOCK_RANKING_WEEKLY,
reason="heavy-public-refresh-ran-this-cycle",
scheduled_slot=ranking_weekly_slot,
)
else:
results["ranking_weekly_snapshot_result"] = _run_non_overlapping_public_refresh(
PUBLIC_REFRESH_LOCK_RANKING_WEEKLY,
lambda: refresh_public_weekly_ranking_snapshots(
run_number=run_number,
now=anchor,
scheduled_slot=ranking_weekly_slot,
),
blocked_by=PUBLIC_HEAVY_REFRESH_KEYS,
)
historical_weekly_slot = _resolve_latest_hourly_schedule_slot(
now=anchor,
minute=get_public_historical_weekly_refresh_minute(),
)
if _is_public_slot_refresh_due(
last_completed_slot=_LAST_PUBLIC_HISTORICAL_WEEKLY_REFRESH_SLOT,
slot=historical_weekly_slot,
):
if heavy_job_ran:
results["historical_weekly_snapshot_result"] = _build_public_scheduler_skip_result(
refresh_key=PUBLIC_REFRESH_LOCK_HISTORICAL_WEEKLY,
reason="heavy-public-refresh-ran-this-cycle",
scheduled_slot=historical_weekly_slot,
)
else:
results["historical_weekly_snapshot_result"] = _run_non_overlapping_public_refresh(
PUBLIC_REFRESH_LOCK_HISTORICAL_WEEKLY,
lambda: refresh_public_weekly_historical_snapshots(
run_number=run_number,
now=anchor,
scheduled_slot=historical_weekly_slot,
),
blocked_by=PUBLIC_HEAVY_REFRESH_KEYS | {PUBLIC_REFRESH_LOCK_HISTORICAL_MONTHLY},
)
historical_monthly_slot = _resolve_latest_hourly_schedule_slot(
now=anchor,
minute=get_public_historical_monthly_refresh_minute(),
hour_interval=get_public_historical_monthly_refresh_hour_interval(),
)
if _is_public_slot_refresh_due(
last_completed_slot=_LAST_PUBLIC_HISTORICAL_MONTHLY_REFRESH_SLOT,
slot=historical_monthly_slot,
):
if heavy_job_ran:
results["historical_monthly_snapshot_result"] = _build_public_scheduler_skip_result(
refresh_key=PUBLIC_REFRESH_LOCK_HISTORICAL_MONTHLY,
reason="heavy-public-refresh-ran-this-cycle",
scheduled_slot=historical_monthly_slot,
)
else:
results["historical_monthly_snapshot_result"] = _run_non_overlapping_public_refresh(
PUBLIC_REFRESH_LOCK_HISTORICAL_MONTHLY,
lambda: refresh_public_monthly_historical_snapshots(
run_number=run_number,
now=anchor,
scheduled_slot=historical_monthly_slot,
),
blocked_by=PUBLIC_HEAVY_REFRESH_KEYS | {PUBLIC_REFRESH_LOCK_HISTORICAL_WEEKLY},
)
if _is_public_interval_refresh_due(
last_run_at=_LAST_PUBLIC_RECENT_MATCHES_REFRESH_AT,
@@ -546,7 +664,10 @@ def refresh_public_full_read_models(
)
steps["annual_ranking_snapshots"] = _run_public_refresh_step(
"annual-ranking-snapshots-2026",
lambda: refresh_public_annual_ranking_snapshots(year=PUBLIC_FULL_REFRESH_YEAR),
lambda: refresh_public_annual_ranking_snapshots(
year=PUBLIC_FULL_REFRESH_YEAR,
now=anchor,
),
)
steps["player_search_index"] = _run_public_refresh_step(
"player-search-index",
@@ -579,8 +700,6 @@ def refresh_public_ranking_snapshots(
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(
@@ -591,9 +710,6 @@ def refresh_public_ranking_snapshots(
}
)
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),
@@ -605,6 +721,118 @@ def refresh_public_ranking_snapshots(
return completed
def refresh_public_weekly_ranking_snapshots(
*,
run_number: int,
now: datetime | None = None,
scheduled_slot: datetime | None = None,
) -> dict[str, Any]:
"""Refresh the hourly weekly public ranking matrix."""
global _LAST_PUBLIC_RANKING_WEEKLY_REFRESH_SLOT
anchor = _as_utc(now or datetime.now(timezone.utc))
slot = scheduled_slot or _resolve_latest_hourly_schedule_slot(
now=anchor,
minute=get_public_ranking_weekly_refresh_minute(),
)
started = time.perf_counter()
_emit_json_log(
{
"event": "public-ranking-weekly-refresh-started",
"job_name": "public-ranking-weekly",
"run_number": run_number,
"timeframe": "weekly",
"scheduled_slot": _to_iso(slot),
"scheduled_slot_local": _format_local_schedule_slot(slot),
}
)
try:
result = refresh_ranking_snapshots(
limit=30,
replace_existing=True,
timeframes=("weekly",),
now=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(),
}
if str(result.get("status") or "ok") != "error":
_LAST_PUBLIC_RANKING_WEEKLY_REFRESH_SLOT = _schedule_slot_key(slot)
completed = {
**result,
"job_name": "public-ranking-weekly",
"timeframe": "weekly",
"duration_ms": _elapsed_ms(started),
"generated_at": _to_iso(anchor),
"scheduled_slot": _to_iso(slot),
"scheduled_slot_local": _format_local_schedule_slot(slot),
"generation_policy": "scheduled-hourly-weekly-ranking-refresh",
"server_scope": "all-public-ranking-scopes",
}
_emit_json_log({"event": "public-ranking-weekly-refresh-completed", **completed})
return completed
def refresh_public_monthly_ranking_snapshots(
*,
run_number: int,
now: datetime | None = None,
scheduled_slot: datetime | None = None,
) -> dict[str, Any]:
"""Refresh the twice-daily monthly public ranking matrix."""
global _LAST_PUBLIC_RANKING_MONTHLY_REFRESH_SLOT
anchor = _as_utc(now or datetime.now(timezone.utc))
slot = scheduled_slot or _resolve_latest_daily_schedule_slot(
now=anchor,
times=get_public_ranking_monthly_refresh_times(),
)
started = time.perf_counter()
_emit_json_log(
{
"event": "public-ranking-monthly-refresh-started",
"job_name": "public-ranking-monthly",
"run_number": run_number,
"timeframe": "monthly",
"scheduled_slot": _to_iso(slot),
"scheduled_slot_local": _format_local_schedule_slot(slot),
}
)
try:
result = refresh_ranking_snapshots(
limit=30,
replace_existing=True,
timeframes=("monthly",),
now=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(),
}
if str(result.get("status") or "ok") != "error":
_LAST_PUBLIC_RANKING_MONTHLY_REFRESH_SLOT = _schedule_slot_key(slot)
completed = {
**result,
"job_name": "public-ranking-monthly",
"timeframe": "monthly",
"duration_ms": _elapsed_ms(started),
"generated_at": _to_iso(anchor),
"scheduled_slot": _to_iso(slot),
"scheduled_slot_local": _format_local_schedule_slot(slot),
"generation_policy": "scheduled-twice-daily-monthly-ranking-refresh",
"server_scope": "all-public-ranking-scopes",
}
_emit_json_log({"event": "public-ranking-monthly-refresh-completed", **completed})
return completed
def refresh_public_recent_matches_snapshots(
*,
run_number: int,
@@ -657,8 +885,10 @@ def refresh_public_annual_ranking_snapshots(
*,
year: int = PUBLIC_FULL_REFRESH_YEAR,
limit: int = 20,
now: datetime | None = None,
) -> dict[str, Any]:
"""Refresh supported annual ranking snapshots for all public scopes."""
anchor = _as_utc(now or datetime.now(timezone.utc))
combinations = [
(server_key, metric)
for server_key in SNAPSHOT_GENERATOR_SERVER_KEYS
@@ -709,6 +939,7 @@ def refresh_public_annual_ranking_snapshots(
status = "error"
return {
"status": status,
"generated_at": _to_iso(anchor),
"year": year,
"limit": limit,
"combinations_expected": len(combinations),
@@ -720,6 +951,122 @@ def refresh_public_annual_ranking_snapshots(
}
def refresh_public_weekly_historical_snapshots(
*,
run_number: int,
now: datetime | None = None,
scheduled_slot: datetime | None = None,
) -> dict[str, Any]:
"""Refresh the hourly weekly historical leaderboard subset used by historico.html."""
global _LAST_PUBLIC_HISTORICAL_WEEKLY_REFRESH_SLOT
anchor = _as_utc(now or datetime.now(timezone.utc))
slot = scheduled_slot or _resolve_latest_hourly_schedule_slot(
now=anchor,
minute=get_public_historical_weekly_refresh_minute(),
)
started = time.perf_counter()
_emit_json_log(
{
"event": "public-historical-weekly-refresh-started",
"job_name": "public-historical-weekly",
"run_number": run_number,
"timeframe": "weekly",
"scheduled_slot": _to_iso(slot),
"scheduled_slot_local": _format_local_schedule_slot(slot),
"server_keys": list(PREWARM_SNAPSHOT_SERVER_KEYS),
}
)
try:
result = {
"status": "ok",
**generate_and_persist_historical_leaderboard_snapshots(
timeframe="weekly",
server_keys=PREWARM_SNAPSHOT_SERVER_KEYS,
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(),
}
if str(result.get("status") or "ok") != "error":
_LAST_PUBLIC_HISTORICAL_WEEKLY_REFRESH_SLOT = _schedule_slot_key(slot)
completed = {
**result,
"job_name": "public-historical-weekly",
"timeframe": "weekly",
"duration_ms": _elapsed_ms(started),
"generated_at": result.get("generated_at") or _to_iso(anchor),
"scheduled_slot": _to_iso(slot),
"scheduled_slot_local": _format_local_schedule_slot(slot),
"generation_policy": "scheduled-hourly-historical-weekly-refresh",
}
_emit_json_log({"event": "public-historical-weekly-refresh-completed", **completed})
return completed
def refresh_public_monthly_historical_snapshots(
*,
run_number: int,
now: datetime | None = None,
scheduled_slot: datetime | None = None,
) -> dict[str, Any]:
"""Refresh the periodic monthly historical subset used by historico.html."""
global _LAST_PUBLIC_HISTORICAL_MONTHLY_REFRESH_SLOT
anchor = _as_utc(now or datetime.now(timezone.utc))
slot = scheduled_slot or _resolve_latest_hourly_schedule_slot(
now=anchor,
minute=get_public_historical_monthly_refresh_minute(),
hour_interval=get_public_historical_monthly_refresh_hour_interval(),
)
started = time.perf_counter()
_emit_json_log(
{
"event": "public-historical-monthly-refresh-started",
"job_name": "public-historical-monthly",
"run_number": run_number,
"timeframe": "monthly",
"scheduled_slot": _to_iso(slot),
"scheduled_slot_local": _format_local_schedule_slot(slot),
"server_keys": list(PREWARM_SNAPSHOT_SERVER_KEYS),
}
)
try:
result = {
"status": "ok",
**generate_and_persist_historical_monthly_ui_snapshots(
server_keys=PREWARM_SNAPSHOT_SERVER_KEYS,
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(),
}
if str(result.get("status") or "ok") != "error":
_LAST_PUBLIC_HISTORICAL_MONTHLY_REFRESH_SLOT = _schedule_slot_key(slot)
completed = {
**result,
"job_name": "public-historical-monthly",
"timeframe": "monthly",
"duration_ms": _elapsed_ms(started),
"generated_at": result.get("generated_at") or _to_iso(anchor),
"scheduled_slot": _to_iso(slot),
"scheduled_slot_local": _format_local_schedule_slot(slot),
"generation_policy": "scheduled-bi-hourly-historical-monthly-refresh",
}
_emit_json_log({"event": "public-historical-monthly-refresh-completed", **completed})
return completed
def get_next_public_full_refresh_at(
*,
now: datetime | None = None,
@@ -788,6 +1135,7 @@ def _run_public_refresh_step(
def _run_non_overlapping_public_refresh(
refresh_key: str,
callback: Any,
blocked_by: set[str] | frozenset[str] | None = None,
) -> dict[str, Any]:
if refresh_key in _PUBLIC_REFRESH_IN_PROGRESS:
return {
@@ -795,6 +1143,14 @@ def _run_non_overlapping_public_refresh(
"reason": "refresh-already-in-progress",
"refresh_key": refresh_key,
}
blocking_keys = sorted((blocked_by or set()) & _PUBLIC_REFRESH_IN_PROGRESS)
if blocking_keys:
return {
"status": "skipped",
"reason": "conflicting-refresh-in-progress",
"refresh_key": refresh_key,
"blocked_by": blocking_keys,
}
_PUBLIC_REFRESH_IN_PROGRESS.add(refresh_key)
try:
return callback()
@@ -807,11 +1163,7 @@ def _record_public_refreshes_from_cycle(
*,
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
global _LAST_PUBLIC_RECENT_MATCHES_REFRESH_AT
recent_result = payload.get("recent_matches_event_result")
if isinstance(recent_result, dict) and recent_result.get("status") not in {
"error",
@@ -866,10 +1218,87 @@ def _resolve_runner_tick_seconds(interval_seconds: int) -> int:
interval_seconds,
get_public_ranking_refresh_interval_seconds(),
get_public_recent_matches_refresh_interval_seconds(),
60,
]
return max(1, min(intervals))
def _resolve_latest_hourly_schedule_slot(
*,
now: datetime,
minute: int,
hour_interval: int = 1,
) -> datetime:
local_now = now.astimezone(_get_public_refresh_zone())
candidate = local_now.replace(minute=minute, second=0, microsecond=0)
if local_now < candidate:
candidate -= timedelta(hours=1)
candidate = candidate.replace(minute=minute, second=0, microsecond=0)
while candidate.hour % hour_interval != 0:
candidate -= timedelta(hours=1)
candidate = candidate.replace(minute=minute, second=0, microsecond=0)
return candidate.astimezone(timezone.utc)
def _resolve_latest_daily_schedule_slot(
*,
now: datetime,
times: tuple[str, ...],
) -> datetime:
local_now = now.astimezone(_get_public_refresh_zone())
candidates: list[datetime] = []
for time_value in times:
hour, minute = (int(part) for part in time_value.split(":"))
candidate = datetime.combine(
local_now.date(),
datetime_time(hour=hour, minute=minute),
tzinfo=local_now.tzinfo,
)
if candidate > local_now:
candidate -= timedelta(days=1)
candidates.append(candidate)
return max(candidates).astimezone(timezone.utc)
def _schedule_slot_key(slot: datetime) -> str:
return _as_utc(slot).isoformat().replace("+00:00", "Z")
def _format_local_schedule_slot(slot: datetime) -> str:
return slot.astimezone(_get_public_refresh_zone()).isoformat(timespec="minutes")
def _is_public_slot_refresh_due(
*,
last_completed_slot: str | None,
slot: datetime,
) -> bool:
return last_completed_slot != _schedule_slot_key(slot)
def _build_public_scheduler_skip_result(
*,
refresh_key: str,
reason: str,
scheduled_slot: datetime | None = None,
) -> dict[str, Any]:
result: dict[str, Any] = {
"status": "skipped",
"reason": reason,
"refresh_key": refresh_key,
}
if scheduled_slot is not None:
result["scheduled_slot"] = _to_iso(scheduled_slot)
result["scheduled_slot_local"] = _format_local_schedule_slot(scheduled_slot)
return result
def _refresh_result_counts_as_executed(result: dict[str, Any]) -> bool:
if not isinstance(result, dict):
return False
return str(result.get("status") or "").lower() in {"ok", "partial"}
def _get_public_refresh_zone() -> ZoneInfo:
timezone_name = get_public_full_refresh_timezone()
try:
@@ -929,6 +1358,29 @@ def _resolve_refresh_cycle_status(**results: dict[str, Any]) -> str:
return "ok"
def run_public_refresh_job_once(job_name: str, *, now: datetime | None = None) -> dict[str, Any]:
"""Run one public snapshot job immediately for manual validation."""
anchor = _as_utc(now or datetime.now(timezone.utc))
normalized_job = str(job_name or "").strip().lower()
if normalized_job == "public-full":
return refresh_public_full_read_models(run_number=1, now=anchor)
if normalized_job == "ranking-weekly":
return refresh_public_weekly_ranking_snapshots(run_number=1, now=anchor)
if normalized_job == "ranking-monthly":
return refresh_public_monthly_ranking_snapshots(run_number=1, now=anchor)
if normalized_job == "historical-weekly":
return refresh_public_weekly_historical_snapshots(run_number=1, now=anchor)
if normalized_job == "historical-monthly":
return refresh_public_monthly_historical_snapshots(run_number=1, now=anchor)
if normalized_job == "recent-matches":
return refresh_public_recent_matches_snapshots(
run_number=1,
now=anchor,
trigger="manual-public-job",
)
raise ValueError(f"Unsupported public job: {job_name}")
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)
@@ -1178,6 +1630,19 @@ def main() -> None:
default=None,
help="Optional safety limit for the number of refresh cycles to execute.",
)
parser.add_argument(
"--public-job",
choices=(
"public-full",
"ranking-weekly",
"ranking-monthly",
"historical-weekly",
"historical-monthly",
"recent-matches",
),
default=None,
help="Run one public snapshot scheduler job immediately and exit.",
)
args = parser.parse_args()
if args.hourly:
@@ -1191,6 +1656,10 @@ def main() -> None:
raise ValueError("--retry-delay must be zero or positive.")
if args.max_runs is not None and args.max_runs <= 0:
raise ValueError("--max-runs must be positive when provided.")
if args.public_job is not None:
payload = run_public_refresh_job_once(args.public_job)
print(json.dumps({"status": "ok", "data": payload}, indent=2))
return
run_periodic_historical_refresh(
interval_seconds=args.interval,

View File

@@ -392,6 +392,144 @@ def generate_and_persist_priority_historical_snapshots(
}
def generate_and_persist_historical_leaderboard_snapshots(
*,
timeframe: str,
server_keys: tuple[str, ...] = PREWARM_SNAPSHOT_SERVER_KEYS,
generated_at: datetime | None = None,
leaderboard_limit: int = DEFAULT_WEEKLY_LEADERBOARD_LIMIT,
db_path: Path | None = None,
) -> dict[str, object]:
"""Build and persist only the weekly or monthly leaderboard snapshot set."""
from .historical_snapshot_storage import persist_historical_snapshot_batch
generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc))
leaderboard_limit = _normalize_snapshot_limit("leaderboard_limit", leaderboard_limit)
normalized_timeframe = str(timeframe or "").strip().lower()
if normalized_timeframe not in {"weekly", "monthly"}:
raise ValueError("timeframe must be 'weekly' or 'monthly'.")
snapshots: list[dict[str, object]] = []
for server_key in server_keys:
for metric in SNAPSHOT_LEADERBOARD_METRICS:
if normalized_timeframe == "weekly":
_log_snapshot_build_started(
server_key,
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
metric=metric,
)
snapshots.append(
_build_weekly_leaderboard_snapshot(
server_key,
metric,
generated_at_value,
limit=leaderboard_limit,
db_path=db_path,
)
)
continue
_log_snapshot_build_started(
server_key,
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,
metric=metric,
)
snapshots.append(
_build_monthly_leaderboard_snapshot(
server_key,
metric,
generated_at_value,
limit=leaderboard_limit,
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),
"timeframe": normalized_timeframe,
"snapshot_policy": "leaderboard-subset",
"server_keys": list(server_keys),
"metrics": list(SNAPSHOT_LEADERBOARD_METRICS),
"snapshot_count": len(persisted_records),
"servers_processed": len(snapshots_by_server),
"snapshots_by_server": snapshots_by_server,
}
def generate_and_persist_historical_monthly_ui_snapshots(
*,
server_keys: tuple[str, ...] = PREWARM_SNAPSHOT_SERVER_KEYS,
generated_at: datetime | None = None,
leaderboard_limit: int = DEFAULT_WEEKLY_LEADERBOARD_LIMIT,
db_path: Path | None = None,
) -> dict[str, object]:
"""Build and persist the monthly historical subset rendered by the public UI."""
from .historical_snapshot_storage import persist_historical_snapshot_batch
generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc))
leaderboard_limit = _normalize_snapshot_limit("leaderboard_limit", leaderboard_limit)
snapshots: list[dict[str, object]] = []
for server_key in server_keys:
for metric in SNAPSHOT_LEADERBOARD_METRICS:
_log_snapshot_build_started(
server_key,
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,
metric=metric,
)
snapshots.append(
_build_monthly_leaderboard_snapshot(
server_key,
metric,
generated_at_value,
limit=leaderboard_limit,
db_path=db_path,
)
)
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP)
snapshots.append(
_build_monthly_mvp_snapshot(
server_key,
generated_at_value,
limit=leaderboard_limit,
db_path=db_path,
)
)
_log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP_V2)
snapshots.append(
_build_monthly_mvp_v2_snapshot(
server_key,
generated_at_value,
limit=leaderboard_limit,
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),
"timeframe": "monthly",
"snapshot_policy": "monthly-ui-subset",
"server_keys": list(server_keys),
"metrics": list(SNAPSHOT_LEADERBOARD_METRICS),
"includes_monthly_mvp": True,
"snapshot_count": len(persisted_records),
"servers_processed": len(snapshots_by_server),
"snapshots_by_server": snapshots_by_server,
}
def generate_and_persist_recent_historical_snapshots(
*,
server_key: str | None = None,

View File

@@ -230,15 +230,20 @@ def refresh_ranking_snapshots(
*,
limit: int = DEFAULT_RANKING_SNAPSHOT_REFRESH_LIMIT,
replace_existing: bool = True,
timeframes: tuple[str, ...] | None = None,
now: datetime | None = None,
db_path: Path | None = None,
) -> dict[str, object]:
"""Generate the full weekly/monthly ranking snapshot matrix with partial-failure reporting."""
normalized_limit = _normalize_limit(limit)
anchor = _as_utc(now or datetime.now(timezone.utc))
normalized_timeframes = tuple(
_normalize_generator_timeframe(timeframe)
for timeframe in (timeframes or SNAPSHOT_GENERATOR_TIMEFRAMES)
)
combinations = [
(timeframe, server_key, metric)
for timeframe in SNAPSHOT_GENERATOR_TIMEFRAMES
for timeframe in normalized_timeframes
for server_key in SNAPSHOT_GENERATOR_SERVER_KEYS
for metric in SNAPSHOT_GENERATOR_METRICS
]
@@ -305,6 +310,7 @@ def refresh_ranking_snapshots(
return {
"status": overall_status,
"generated_at": _to_iso(anchor),
"timeframes": list(normalized_timeframes),
"limit": normalized_limit,
"replace_existing": replace_existing,
"combinations_expected": len(combinations),
@@ -1421,6 +1427,13 @@ def _main(argv: list[str] | None = None) -> int:
type=int,
default=DEFAULT_RANKING_SNAPSHOT_REFRESH_LIMIT,
)
refresh_parser.add_argument(
"--timeframe",
choices=SNAPSHOT_GENERATOR_TIMEFRAMES,
action="append",
dest="timeframes",
help="Optional timeframe filter. Repeat to refresh more than one timeframe.",
)
refresh_parser.add_argument(
"--sqlite-path",
type=Path,
@@ -1459,6 +1472,7 @@ def _main(argv: list[str] | None = None) -> int:
payload = refresh_ranking_snapshots(
limit=args.limit,
replace_existing=args.replace_existing,
timeframes=tuple(args.timeframes) if args.timeframes else None,
db_path=args.sqlite_path,
)
print(

View File

@@ -10,6 +10,7 @@ from contextlib import nullcontext, redirect_stdout
from datetime import datetime, timezone
from unittest.mock import patch
import app.historical_runner as historical_runner_module
from app.config import (
get_historical_refresh_interval_seconds,
get_historical_refresh_max_retries,
@@ -17,7 +18,12 @@ from app.config import (
get_public_full_refresh_enabled,
get_public_full_refresh_time,
get_public_full_refresh_timezone,
get_public_historical_monthly_refresh_hour_interval,
get_public_historical_monthly_refresh_minute,
get_public_historical_weekly_refresh_minute,
get_public_ranking_monthly_refresh_times,
get_public_ranking_refresh_interval_seconds,
get_public_ranking_weekly_refresh_minute,
get_public_recent_matches_refresh_interval_seconds,
)
from app.payloads import (
@@ -27,8 +33,10 @@ from app.payloads import (
build_recent_historical_matches_snapshot_payload,
)
from app.historical_runner import (
_maybe_run_public_read_model_refreshes,
_run_refresh_with_retries,
get_next_public_full_refresh_at,
run_public_refresh_job_once,
run_periodic_historical_refresh,
)
from app.historical_snapshots import _normalize_snapshot_limit
@@ -40,6 +48,15 @@ from app.rcon_historical_read_model import (
class HistoricalSnapshotRefreshTests(unittest.TestCase):
def setUp(self) -> None:
historical_runner_module._LAST_PUBLIC_FULL_REFRESH_LOCAL_DATE = None
historical_runner_module._LAST_PUBLIC_RECENT_MATCHES_REFRESH_AT = None
historical_runner_module._LAST_PUBLIC_RANKING_WEEKLY_REFRESH_SLOT = None
historical_runner_module._LAST_PUBLIC_RANKING_MONTHLY_REFRESH_SLOT = None
historical_runner_module._LAST_PUBLIC_HISTORICAL_WEEKLY_REFRESH_SLOT = None
historical_runner_module._LAST_PUBLIC_HISTORICAL_MONTHLY_REFRESH_SLOT = None
historical_runner_module._PUBLIC_REFRESH_IN_PROGRESS.clear()
def test_runner_numeric_env_values_are_parsed_before_use(self) -> None:
with patch.dict(
os.environ,
@@ -75,6 +92,11 @@ class HistoricalSnapshotRefreshTests(unittest.TestCase):
"HLL_PUBLIC_FULL_REFRESH_TIMEZONE": "Europe/Madrid",
"HLL_PUBLIC_RANKING_REFRESH_INTERVAL_SECONDS": "900",
"HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS": "60",
"HLL_PUBLIC_RANKING_WEEKLY_REFRESH_MINUTE": "10",
"HLL_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES": "07:00,19:00",
"HLL_PUBLIC_HISTORICAL_WEEKLY_REFRESH_MINUTE": "25",
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_MINUTE": "40",
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_HOUR_INTERVAL": "2",
},
clear=False,
):
@@ -83,6 +105,11 @@ class HistoricalSnapshotRefreshTests(unittest.TestCase):
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)
self.assertEqual(get_public_ranking_weekly_refresh_minute(), 10)
self.assertEqual(get_public_ranking_monthly_refresh_times(), ("07:00", "19:00"))
self.assertEqual(get_public_historical_weekly_refresh_minute(), 25)
self.assertEqual(get_public_historical_monthly_refresh_minute(), 40)
self.assertEqual(get_public_historical_monthly_refresh_hour_interval(), 2)
def test_next_public_full_refresh_uses_madrid_six_am(self) -> None:
with patch.dict(
@@ -102,6 +129,126 @@ class HistoricalSnapshotRefreshTests(unittest.TestCase):
datetime(2026, 6, 10, 4, 0, tzinfo=timezone.utc),
)
def test_public_scheduler_runs_hourly_weekly_ranking_job(self) -> None:
historical_runner_module._LAST_PUBLIC_RECENT_MATCHES_REFRESH_AT = datetime(
2026, 6, 10, 6, 10, tzinfo=timezone.utc
)
historical_runner_module._LAST_PUBLIC_RANKING_MONTHLY_REFRESH_SLOT = "2026-06-10T05:00:00Z"
historical_runner_module._LAST_PUBLIC_HISTORICAL_WEEKLY_REFRESH_SLOT = "2026-06-10T05:25:00Z"
historical_runner_module._LAST_PUBLIC_HISTORICAL_MONTHLY_REFRESH_SLOT = "2026-06-10T04:40:00Z"
with (
patch.dict(
os.environ,
{
"HLL_PUBLIC_FULL_REFRESH_ENABLED": "false",
"HLL_PUBLIC_RANKING_WEEKLY_REFRESH_MINUTE": "10",
"HLL_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES": "07:00,19:00",
"HLL_PUBLIC_HISTORICAL_WEEKLY_REFRESH_MINUTE": "25",
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_MINUTE": "40",
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_HOUR_INTERVAL": "2",
"HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS": "3600",
},
clear=False,
),
patch(
"app.historical_runner.refresh_public_weekly_ranking_snapshots",
return_value={"status": "ok"},
) as weekly_ranking_refresh,
):
payload = _maybe_run_public_read_model_refreshes(
run_number=3,
now=datetime(2026, 6, 10, 6, 10, tzinfo=timezone.utc),
)
self.assertEqual(payload["status"], "ok")
self.assertIn("ranking_weekly_snapshot_result", payload)
weekly_ranking_refresh.assert_called_once()
def test_public_scheduler_runs_bi_hourly_monthly_historical_job(self) -> None:
historical_runner_module._LAST_PUBLIC_RECENT_MATCHES_REFRESH_AT = datetime(
2026, 6, 10, 6, 40, tzinfo=timezone.utc
)
historical_runner_module._LAST_PUBLIC_RANKING_MONTHLY_REFRESH_SLOT = "2026-06-10T05:00:00Z"
historical_runner_module._LAST_PUBLIC_RANKING_WEEKLY_REFRESH_SLOT = "2026-06-10T06:10:00Z"
historical_runner_module._LAST_PUBLIC_HISTORICAL_WEEKLY_REFRESH_SLOT = "2026-06-10T06:25:00Z"
with (
patch.dict(
os.environ,
{
"HLL_PUBLIC_FULL_REFRESH_ENABLED": "false",
"HLL_PUBLIC_RANKING_WEEKLY_REFRESH_MINUTE": "10",
"HLL_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES": "07:00,19:00",
"HLL_PUBLIC_HISTORICAL_WEEKLY_REFRESH_MINUTE": "25",
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_MINUTE": "40",
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_HOUR_INTERVAL": "2",
"HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS": "3600",
},
clear=False,
),
patch(
"app.historical_runner.refresh_public_monthly_historical_snapshots",
return_value={"status": "ok"},
) as historical_monthly_refresh,
):
payload = _maybe_run_public_read_model_refreshes(
run_number=4,
now=datetime(2026, 6, 10, 6, 40, tzinfo=timezone.utc),
)
self.assertEqual(payload["status"], "ok")
self.assertIn("historical_monthly_snapshot_result", payload)
historical_monthly_refresh.assert_called_once()
def test_public_scheduler_skips_lower_priority_jobs_when_heavy_job_runs(self) -> None:
historical_runner_module._LAST_PUBLIC_RECENT_MATCHES_REFRESH_AT = datetime(
2026, 6, 10, 5, 30, tzinfo=timezone.utc
)
with (
patch.dict(
os.environ,
{
"HLL_PUBLIC_FULL_REFRESH_ENABLED": "false",
"HLL_PUBLIC_RANKING_WEEKLY_REFRESH_MINUTE": "10",
"HLL_PUBLIC_RANKING_MONTHLY_REFRESH_TIMES": "07:00,19:00",
"HLL_PUBLIC_HISTORICAL_WEEKLY_REFRESH_MINUTE": "25",
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_MINUTE": "40",
"HLL_PUBLIC_HISTORICAL_MONTHLY_REFRESH_HOUR_INTERVAL": "2",
"HLL_PUBLIC_RECENT_MATCHES_REFRESH_INTERVAL_SECONDS": "3600",
},
clear=False,
),
patch(
"app.historical_runner.refresh_public_monthly_ranking_snapshots",
return_value={"status": "ok"},
) as monthly_ranking_refresh,
patch("app.historical_runner.refresh_public_weekly_ranking_snapshots") as weekly_ranking_refresh,
patch("app.historical_runner.refresh_public_weekly_historical_snapshots") as weekly_historical_refresh,
):
payload = _maybe_run_public_read_model_refreshes(
run_number=5,
now=datetime(2026, 6, 10, 5, 30, tzinfo=timezone.utc),
)
self.assertEqual(payload["ranking_monthly_snapshot_result"]["status"], "ok")
self.assertEqual(payload["ranking_weekly_snapshot_result"]["status"], "skipped")
self.assertEqual(payload["historical_weekly_snapshot_result"]["status"], "skipped")
monthly_ranking_refresh.assert_called_once()
weekly_ranking_refresh.assert_not_called()
weekly_historical_refresh.assert_not_called()
def test_manual_public_job_runner_routes_to_supported_job(self) -> None:
with patch(
"app.historical_runner.refresh_public_weekly_historical_snapshots",
return_value={"status": "ok", "job_name": "public-historical-weekly"},
) as weekly_historical_refresh:
result = run_public_refresh_job_once(
"historical-weekly",
now=datetime(2026, 6, 10, 6, 25, tzinfo=timezone.utc),
)
self.assertEqual(result["job_name"], "public-historical-weekly")
weekly_historical_refresh.assert_called_once()
def test_historical_leaderboard_snapshot_does_not_runtime_enrich_public_request(self) -> None:
snapshot = {
"generated_at": "2026-06-10T04:00:00Z",