Files
RFB/rfb_polling.py

405 lines
18 KiB
Python

"""Independent polling, request budget and persistent last-good snapshots.
No external coordinator or extra Python dependencies are required.
"""
from collections import deque
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from zoneinfo import ZoneInfo
import copy
import hashlib
import json
import logging
import math
import os
import random
import threading
import time
@dataclass(frozen=True)
class PollingConfig:
max_requests_per_minute: int = 90
calendar_wait_seconds: float = 900
calendar_prepare_seconds: float = 120
prepare_minutes: float = 70
live_status_seconds: float = 1
box_score_seconds: float = 5
play_by_play_seconds: float = 5
prestart_status_seconds: float = 30
near_start_status_seconds: float = 10
metadata_seconds: float = 300
standings_seconds: float = 300
other_games_seconds: float = 30
other_games_demand_seconds: float = 90
finished_status_seconds: float = 30
finished_hold_seconds: float = 600
request_connect_timeout: float = 3
request_read_timeout: float = 5
retry_initial_seconds: float = 5
retry_max_seconds: float = 300
rate_limit_pause_seconds: float = 120
forbidden_pause_seconds: float = 600
startup_jitter_seconds: float = 30
snapshot_seconds: float = 30
timezone: str = "Europe/Moscow"
cache_directory: str = ".rfb_cache"
shotmap_max_images: int = 192
@classmethod
def load(cls, path, logger):
values = asdict(cls())
try:
overrides = json.loads(Path(path).read_text(encoding="utf-8-sig"))
except FileNotFoundError:
overrides = {}
if not isinstance(overrides, dict):
raise ValueError("polling_config.json must contain an object")
for name, value in overrides.items():
if name not in values:
raise ValueError(f"Unknown polling setting: {name}")
if isinstance(values[name], (int, float)):
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{name} must be a number")
if not math.isfinite(value) or value < 0 or (value == 0 and name != "startup_jitter_seconds"):
raise ValueError(f"{name} must be positive")
elif not isinstance(value, str) or not value:
raise ValueError(f"{name} must be a nonempty string")
values[name] = value
for name in ("max_requests_per_minute", "shotmap_max_images"):
if int(values[name]) != values[name]:
raise ValueError(f"{name} must be an integer")
values[name] = int(values[name])
ZoneInfo(values["timezone"])
return cls(**values)
def root(payload):
if not isinstance(payload, dict):
return {}
if isinstance(payload.get("data"), dict) and not any(k in payload for k in ("items", "result")):
return payload["data"]
return payload
def game_state(payload):
value = root(payload)
value = value.get("result") if isinstance(value.get("result"), dict) else value
return str(value.get("gameStatus") or value.get("state") or value.get("status") or "").casefold()
FINAL_STATES = {"result", "resultconfirmed", "finished", "ended", "final", "game over"}
LIVE_STATES = {"online", "live"}
def valid_payload(name, payload):
value = root(payload)
if not value or "error" in value:
return False
if str(value.get("status", "")).casefold() in ("error", "fail", "no-status"):
return False
if str(value.get("message", "")).casefold() == "not found":
return False
if name in ("seasons", "calendar", "actual-standings", "season_stats", "league_stats"):
return isinstance(value.get("items"), list)
result = value.get("result")
if name == "play-by-play":
return isinstance(result, list)
if name == "live-status":
ls = result if isinstance(result, dict) else value
return str(ls.get("message", "")).casefold() != "not found" and any(
k in ls for k in ("gameStatus", "state", "period", "scoreA", "scoreB", "second")
)
if name in ("game", "box-score"):
if not isinstance(result, dict) or not isinstance(result.get("teams"), list):
return False
teams = result["teams"]
if not teams or any(not isinstance(team, dict) for team in teams):
return False
if not {team.get("teamNumber") for team in teams}.issuperset({1, 2}):
return False
if name == "game":
return (isinstance(result.get("game"), dict) and result["game"].get("id") is not None
and all(isinstance(result.get(key), dict) for key in ("team1", "team2")))
return all(isinstance(team.get("total"), dict) and "points" in team["total"]
for team in teams if team.get("teamNumber") in (1, 2))
if name == "pregame-full-stats":
return isinstance(result, dict) and all(isinstance(result.get(k), list) for k in ("team1PlayersStats", "team2PlayersStats"))
return isinstance(result, dict) and bool(result)
class FetchError(Exception):
def __init__(self, message, delay=5, status_code=None):
super().__init__(message)
self.delay, self.status_code = delay, status_code
class ApiClient:
"""One serialized transport and one budget for EVERY API call on this machine."""
def __init__(self, config, session, logger=None, clock=None):
self.config, self.session = config, session
self.logger = logger or logging.getLogger(__name__)
self.clock = clock or time
self.transport_lock = threading.Lock()
self.lock = threading.RLock()
self.starts = deque()
self.next_request_at = 0.0
self.blocked_until = 0.0
self.total_requests = 0
self.diagnostics = {}
self.failures = {}
self.notice_at = {}
def _wait_slot(self, stop):
while not stop.is_set():
now = self.clock.monotonic()
with self.lock:
while self.starts and self.starts[0] <= now - 60:
self.starts.popleft()
deadline = max(self.next_request_at, self.blocked_until)
if len(self.starts) >= self.config.max_requests_per_minute:
deadline = max(deadline, self.starts[0] + 60)
delay = deadline - now
if delay <= 0:
self.starts.append(now)
self.total_requests += 1
self.next_request_at = now + 60 / self.config.max_requests_per_minute
return
if stop.wait(min(delay, 1)):
break
raise FetchError("Polling stopped", 0)
def _retry_after(self, headers, fallback):
value = headers.get("Retry-After")
if value is None:
return fallback
try:
return max(0, float(value))
except (TypeError, ValueError):
try:
dt = parsedate_to_datetime(value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return max(0, dt.timestamp() - self.clock.time())
except (TypeError, ValueError, OverflowError):
return fallback
def fetch(self, name, url, stop):
code = None
try:
with self.transport_lock:
self._wait_slot(stop)
response = self.session.get(url, timeout=(self.config.request_connect_timeout,
self.config.request_read_timeout))
try:
code = response.status_code
if code == 429 or code in (401, 403):
fallback = self.config.rate_limit_pause_seconds if code == 429 else self.config.forbidden_pause_seconds
delay = max(fallback, self._retry_after(response.headers, fallback))
with self.lock:
self.blocked_until = max(self.blocked_until, self.clock.monotonic() + delay)
raise FetchError(f"HTTP {code}: shared pause on this machine", delay, code)
if not 200 <= code < 300:
raise FetchError(f"HTTP {code}", self.config.retry_initial_seconds, code)
payload = root(response.json())
if not valid_payload(name, payload):
raise FetchError("Data not ready or invalid response", max(30, self.config.retry_initial_seconds))
finally:
response.close()
except Exception as ex:
if stop.is_set():
raise FetchError("Polling stopped", 0) from ex
with self.lock:
n = min(self.failures.get((name, url), 0) + 1, 16)
self.failures[(name, url)] = n
delay = max(getattr(ex, "delay", 0), min(self.config.retry_max_seconds,
self.config.retry_initial_seconds * 2 ** (n - 1)))
self.diagnostics[name] = {"ok": False, "http_status": code, "message": str(ex),
"retry_seconds": delay}
now = self.clock.monotonic()
if now >= self.notice_at.get(name, -1):
self.logger.warning("[API] %s: %s; retry in %.1fs", name, ex, delay)
self.notice_at[name] = now + 300
raise FetchError(str(ex), delay, code) from ex
with self.lock:
self.failures.pop((name, url), None)
self.diagnostics[name] = {"ok": True, "http_status": code, "updated_at": datetime.now().isoformat()}
return payload
def health(self):
with self.lock:
now = self.clock.monotonic()
return {"total_requests": self.total_requests,
"requests_last_minute": sum(t > now - 60 for t in self.starts),
"limit_per_minute": self.config.max_requests_per_minute,
"pause_seconds": round(max(0, self.blocked_until - now), 1),
"sources": copy.deepcopy(self.diagnostics)}
class SnapshotStore:
def __init__(self, directory, identity, logger):
self.path = Path(directory) / (hashlib.sha256(identity.encode()).hexdigest()[:20] + ".json")
self.logger = logger
def load(self):
try:
value = json.loads(self.path.read_text(encoding="utf-8"))
if not isinstance(value, dict) or value.get("version") != 1:
return {}
if (not isinstance(value.get("latest_data", {}), dict)
or not isinstance(value.get("resource_game_ids", {}), dict)
or (value.get("calendar") is not None and not isinstance(value["calendar"], dict))):
raise ValueError("invalid snapshot structure")
checked_at = value.get("season_checked_at", 0)
if not isinstance(checked_at, (int, float)) or not math.isfinite(checked_at):
raise ValueError("invalid season timestamp")
return value
except FileNotFoundError:
return {}
except (OSError, ValueError) as ex:
self.logger.warning("[cache] snapshot ignored: %s", ex)
return {}
def save(self, value):
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_suffix(".tmp")
temporary.write_text(json.dumps(dict(version=1, **value), ensure_ascii=False), encoding="utf-8")
os.replace(temporary, self.path)
except (OSError, TypeError, ValueError) as ex:
self.logger.warning("[cache] snapshot not saved: %s", ex)
class MatchController:
"""Calendar-driven state machine, running on one existing server only."""
def __init__(self, namespace, config, client, store):
self.n, self.config, self.client, self.store = namespace, config, client, store
self.next_calendar = 0
self.next_seasons = 0
self.next_snapshot = 0
self.calendar_retry_at = 0
self.bootstrap_done = False
def restore(self):
saved = self.store.load()
with self.n["DATA_LOCK"]:
self.n["SEASON"] = saved.get("season")
self.n["CALENDAR"] = saved.get("calendar")
self.n["latest_data"].update(saved.get("latest_data") or {})
self.n["RESOURCE_GAME_IDS"].update(saved.get("resource_game_ids") or {})
self.n["SEASON_CHECKED_AT"] = saved.get("season_checked_at", 0)
status = self.n["latest_data"].get("live-status", {}).get("data")
status_gid = self.n["RESOURCE_GAME_IDS"].get("live-status")
if status_gid is not None and valid_payload("live-status", status):
self.n["GAME_STATUS_CACHE"][str(status_gid)] = {"payload": status}
self.next_seasons = time.monotonic() + 86400 if saved.get("season_checked_at", 0) > time.time() - 86400 else 0
def checkpoint(self):
with self.n["DATA_LOCK"]:
data = {k: copy.deepcopy(v) for k, v in self.n["latest_data"].items() if not k.startswith("excel")}
value = {"season": self.n["SEASON"], "calendar": copy.deepcopy(self.n["CALENDAR"]),
"latest_data": data, "resource_game_ids": dict(self.n["RESOURCE_GAME_IDS"]),
"season_checked_at": self.n.get("SEASON_CHECKED_AT", 0)}
self.store.save(value)
def tick(self, now=None, monotonic=None):
n, cfg = self.n, self.config
now = now or n["broadcast_now"]()
mono = time.monotonic() if monotonic is None else monotonic
if mono >= self.next_seasons:
try:
seasons = self.client.fetch("seasons", n["api_url"]("seasons"), n["stop_event"])
items = seasons.get("items") or []
if items:
n["SEASON"] = items[0]["season"]
n["SEASON_CHECKED_AT"] = time.time()
self.next_seasons = mono + 86400
except FetchError as ex:
self.next_seasons = mono + max(300, ex.delay)
if n["SEASON"] is None:
n["SEASON"] = now.year + (1 if now.month > 9 else 0)
if mono >= max(self.next_calendar, self.calendar_retry_at):
try:
calendar = self.client.fetch("calendar", n["api_url"]("calendar"), n["stop_event"])
with n["DATA_LOCK"]:
n["CALENDAR"] = calendar
self.calendar_retry_at = 0
self.bootstrap_done = True
except FetchError as ex:
self.calendar_retry_at = mono + max(60, ex.delay)
interval = cfg.calendar_prepare_seconds if n["CURRENT_THREADS_MODE"] in ("prestart", "live", "finished") else cfg.calendar_wait_seconds
self.next_calendar = mono + interval + random.uniform(0, interval * 0.05)
# Never activate an old restored schedule until a successful check in this process.
if not self.bootstrap_done:
return
gid, start, today, state = n["pick_game_for_team"](n["CALENDAR"], now=now)
# An active match belongs to this arena even when it continues past midnight.
current_gid = n.get("GAME_ID")
finishing = (n["CURRENT_THREADS_MODE"] == "finished" and n.get("OFFLINE_SWITCH_AT")
and time.time() < n["OFFLINE_SWITCH_AT"])
if current_gid is not None and (n["CURRENT_THREADS_MODE"] == "live" or finishing):
current = next((item.get("game") or {} for item in (n["CALENDAR"] or {}).get("items", [])
if str((item.get("game") or {}).get("id")) == str(current_gid)), {})
gid, start, today = current_gid, n.get("GAME_START_DT"), True
state = current.get("gameStatus") or ("Result" if finishing else "Online")
if not today or gid is None:
n["GAME_TODAY"] = False
n["GAME_SOON"] = False
n["GAME_START_DT"] = None
n["STATUS"] = "no_game_today"
n["start_waiting_threads"]()
# A previous game remains available from disk; no API request for it.
n["GAME_ID"] = n["get_cached_game_id"]() or gid
return
changed = str(gid) != str(n.get("GAME_ID"))
n["GAME_ID"], n["GAME_START_DT"], n["GAME_TODAY"] = gid, start, True
state = str(state or "").casefold()
delta = (start - now).total_seconds() if start else 0
n["GAME_SOON"] = delta <= cfg.prepare_minutes * 60
if changed:
n["OFFLINE_SWITCH_AT"] = None
n["start_waiting_threads"]()
n["STATUS"] = "today_not_started"
if str(gid) in n["FINISHED_GAME_IDS"]:
if n["OFFLINE_SWITCH_AT"] and time.time() >= n["OFFLINE_SWITCH_AT"]:
n["OFFLINE_SWITCH_AT"] = None
n["STATUS"] = "finished_today"
n["start_waiting_threads"]()
return
if state in FINAL_STATES:
n["begin_finished_mode"](gid)
elif n["CURRENT_THREADS_MODE"] == "live" and not changed:
pass # live-status has fresher information than calendar
elif state in LIVE_STATES:
n["STATUS"] = "live"
n["start_live_threads"](n["SEASON"], gid)
self.next_calendar = min(self.next_calendar, mono + cfg.calendar_prepare_seconds)
elif delta <= cfg.prepare_minutes * 60:
n["STATUS"] = "today_not_started"
n["start_preparation_threads"](n["SEASON"], gid, delta <= 300)
self.next_calendar = min(self.next_calendar, mono + cfg.calendar_prepare_seconds)
else:
n["STATUS"] = "today_not_started"
n["start_waiting_threads"]()
def run(self):
stop = self.n["stop_event"]
self.restore()
if stop.wait(random.uniform(0, self.config.startup_jitter_seconds)):
return
while not stop.is_set():
try:
self.tick()
now = time.monotonic()
if now >= self.next_snapshot:
self.checkpoint()
self.next_snapshot = now + self.config.snapshot_seconds
except Exception:
self.n["logger"].exception("[schedule] update failed; waiting before next attempt")
stop.wait(30)
stop.wait(2)
self.checkpoint()