diff --git a/src/common/sports_shared.py b/src/common/sports_shared.py index 5d74a496..8ac40a3a 100644 --- a/src/common/sports_shared.py +++ b/src/common/sports_shared.py @@ -99,6 +99,13 @@ _IDLE_LONG_STREAK = 24 _IDLE_LONG_FACTOR = 6 _DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 +#: How long after a scheduled start to keep looking on the live cadence. ESPN +#: does not flip a game to in-progress exactly at kickoff, and an escalated +#: back-off treats each of those early looks as another empty one. +_KICKOFF_GRACE_SECONDS = 900 +#: Fallback cadence around a kickoff when the manager has no update_interval. +_KICKOFF_POLL_FLOOR = 30 + def _resolve_font_path(path: str) -> str: """Resolve a bundled font path without depending on the process cwd. @@ -1310,6 +1317,11 @@ class SportsLiveSharedMixin: Capped rather than unbounded: the cost of backing off is how late the first game after a quiet spell is noticed, and past the cap the saving stops being worth that. + + The escalation is then clamped by the next kickoff the league already + knows about -- see _clamp_to_scheduled_start. Without that clamp the cap + *is* the miss: a league idle overnight reaches the ceiling, and the + first game of the next day is not noticed for up to that long. """ streak = getattr(self, "_empty_live_streak", 0) base = self.no_data_interval @@ -1321,10 +1333,87 @@ class SportsLiveSharedMixin: # *shrink* as the streak grew (3600s at streak 0, 900s at streak 24), # the opposite of what the setting named "maximum" promises. if streak >= _IDLE_LONG_STREAK: - return min(int(base * _IDLE_LONG_FACTOR), ceiling) - if streak >= _IDLE_SHORT_STREAK: - return min(int(base * _IDLE_SHORT_FACTOR), ceiling) - return min(base, ceiling) + interval = min(int(base * _IDLE_LONG_FACTOR), ceiling) + elif streak >= _IDLE_SHORT_STREAK: + interval = min(int(base * _IDLE_SHORT_FACTOR), ceiling) + else: + interval = min(base, ceiling) + return self._clamp_to_scheduled_start(interval) + + def _clamp_to_scheduled_start(self, interval: int) -> int: + """Shorten an idle wait that would sleep through a known kickoff. + + The back-off counts consecutive empty looks and nothing else, so it + cannot tell an out-of-season league from an in-season one a few hours + before kickoff. Both reach the ceiling, and the ceiling then becomes the + blind spot: measured on two rigs on 2026-09-19, gaps of up to 928s + between looks, 10 of them at or above 900s. A game starting inside such + a gap is not noticed until it ends -- which is the "it doesn't pick up + new live games until I restart it" report, restarting being the one + thing that forces an immediate look. + + The fix costs no extra request: the live fetch already downloads the + whole day's scoreboard, upcoming games included, and + _note_scheduled_start_candidate keeps the earliest start still ahead of + us out of exactly that payload. + + Two cases, either side of the kickoff: + + * before it -- wait at most until it starts, never past it; + * just after it -- hold the live cadence for _KICKOFF_GRACE_SECONDS, + because a provider that has not yet flipped the status would + otherwise look like another empty check and escalate the back-off + again, right when the game is actually starting. + """ + start = getattr(self, "_next_scheduled_start_ts", None) + if not start: + return interval + live = getattr(self, "update_interval", None) or _KICKOFF_POLL_FLOOR + now = time.time() + if now < start: + return max(live, min(interval, int(start - now))) + if now - start <= _KICKOFF_GRACE_SECONDS: + return live + return interval + + def _note_scheduled_start_candidate(self, details) -> None: + """Offer a game from the current look as the next kickoff to wake for. + + Called for every event the live fetch returns, live or not, so the + earliest start still ahead of us falls out of the payload the manager + already has. Self-correcting: a stored start that has passed is + replaced by the next one offered, so a postponed game cannot pin the + cadence to a kickoff that never happens. + """ + if not isinstance(details, dict): + return + if details.get("is_live") or details.get("is_halftime"): + return + start = details.get("start_time_utc") + timestamp = getattr(start, "timestamp", None) + if timestamp is None: + return + try: + candidate = float(timestamp()) + except (TypeError, ValueError, OSError, OverflowError): + return + now = time.time() + if candidate <= now: + return + current = getattr(self, "_next_scheduled_start_ts", None) + # A kickoff that has only just passed is *kept*, not replaced by the + # next one on the card. Replacing it immediately is what made the grace + # window in _clamp_to_scheduled_start dead code: the moment 13:00 came + # round, the stored start jumped to the 16:05 games, `now < start` went + # true again, and the back-off returned to its ceiling -- at exactly the + # moment the games were starting. Observed live on 2026-09-20: the rig + # polled at 13:00:45, found nothing live because ESPN had not flipped + # the status yet, and then went quiet for the next quarter of an hour, + # which is the behaviour this whole clamp exists to prevent. + if (current is None + or current <= now - _KICKOFF_GRACE_SECONDS + or candidate < current): + self._next_scheduled_start_ts = candidate def _note_live_fetch(self, found_live: bool) -> None: """Record whether a look for live games found any.""" diff --git a/test/test_sports_shared.py b/test/test_sports_shared.py index 5ef97d40..1cdd6fc2 100644 --- a/test/test_sports_shared.py +++ b/test/test_sports_shared.py @@ -21,8 +21,10 @@ and by 176 byte-identical safety-harness renders. What is genuinely new is: import ast import os import sys +import time import types from abc import ABC +from datetime import datetime, timedelta, timezone import pytest @@ -305,6 +307,164 @@ class TestLiveMixin: h._empty_live_streak = 24 assert h._idle_live_interval() == 900 + # ---- the back-off must not sleep through a kickoff ------------------- + # + # The escalation counts empty looks and nothing else, so an in-season + # league a few hours before kickoff looks identical to one months out of + # season. Both reach the ceiling, and the ceiling then becomes the blind + # spot. Measured on two rigs on 2026-09-19: gaps of up to 928s between + # looks, ten of them at or above 900s. That is the "it doesn't pick up new + # live games until I restart it" report -- restarting being the one thing + # that forces an immediate look. + + def _idle_host(self, streak=24, no_data_interval=300, ceiling=900): + h = _LiveHost(no_data_interval=no_data_interval) + h.live_idle_max_interval = ceiling + h.update_interval = 30 + h._empty_live_streak = streak + return h + + def test_without_a_known_kickoff_the_escalation_is_unchanged(self): + h = self._idle_host() + assert h._idle_live_interval() == 900 + + def test_the_wait_never_runs_past_a_known_kickoff(self): + h = self._idle_host() + h._next_scheduled_start_ts = time.time() + 120 + # 900s would sleep straight through a kickoff 2 minutes out. + assert 30 <= h._idle_live_interval() <= 120 + + def test_a_distant_kickoff_does_not_shorten_the_wait(self): + h = self._idle_host() + h._next_scheduled_start_ts = time.time() + 6 * 3600 + assert h._idle_live_interval() == 900 + + def test_the_wait_never_drops_below_the_live_cadence(self): + # A kickoff one second away must not turn into a one-second poll. + h = self._idle_host() + h._next_scheduled_start_ts = time.time() + 1 + assert h._idle_live_interval() == h.update_interval + + def test_just_after_kickoff_it_holds_the_live_cadence(self): + # ESPN does not flip a game to in-progress exactly at kickoff, and + # each early look would otherwise escalate the back-off further, + # precisely when the game is starting. + h = self._idle_host() + h._next_scheduled_start_ts = time.time() - 60 + assert h._idle_live_interval() == h.update_interval + + def test_long_after_a_kickoff_the_escalation_returns(self): + h = self._idle_host() + h._next_scheduled_start_ts = time.time() - (sports_shared._KICKOFF_GRACE_SECONDS + 60) + assert h._idle_live_interval() == 900 + + def test_a_pending_game_is_recorded_as_the_next_kickoff(self): + h = self._idle_host() + soon = datetime.now(timezone.utc) + timedelta(minutes=20) + h._note_scheduled_start_candidate({"is_live": False, "start_time_utc": soon}) + assert h._next_scheduled_start_ts == pytest.approx(soon.timestamp(), abs=1) + + def test_the_earliest_pending_game_wins(self): + h = self._idle_host() + later = datetime.now(timezone.utc) + timedelta(hours=4) + sooner = datetime.now(timezone.utc) + timedelta(minutes=30) + h._note_scheduled_start_candidate({"start_time_utc": later}) + h._note_scheduled_start_candidate({"start_time_utc": sooner}) + assert h._next_scheduled_start_ts == pytest.approx(sooner.timestamp(), abs=1) + # ... and a later one does not push the stored kickoff back out. + h._note_scheduled_start_candidate({"start_time_utc": later}) + assert h._next_scheduled_start_ts == pytest.approx(sooner.timestamp(), abs=1) + + def test_a_live_game_is_not_a_kickoff_to_wait_for(self): + h = self._idle_host() + start = datetime.now(timezone.utc) + timedelta(minutes=5) + h._note_scheduled_start_candidate({"is_live": True, "start_time_utc": start}) + h._note_scheduled_start_candidate({"is_halftime": True, "start_time_utc": start}) + assert getattr(h, "_next_scheduled_start_ts", None) is None + + def test_a_start_already_past_is_not_recorded(self): + h = self._idle_host() + gone = datetime.now(timezone.utc) - timedelta(minutes=5) + h._note_scheduled_start_candidate({"start_time_utc": gone}) + assert getattr(h, "_next_scheduled_start_ts", None) is None + + def test_a_stored_kickoff_that_passed_is_replaced_not_pinned(self): + # A postponed game must not hold the cadence to a kickoff that never + # happens; the next candidate offered replaces it. + h = self._idle_host() + h._next_scheduled_start_ts = time.time() - 10_000 + later = datetime.now(timezone.utc) + timedelta(hours=3) + h._note_scheduled_start_candidate({"start_time_utc": later}) + assert h._next_scheduled_start_ts == pytest.approx(later.timestamp(), abs=1) + + def test_junk_candidates_are_ignored_rather_than_raising(self): + h = self._idle_host() + for junk in (None, "not-a-dict", 42, {}, {"start_time_utc": None}, + {"start_time_utc": "2026-09-20T13:00Z"}, + {"start_time_utc": object()}): + h._note_scheduled_start_candidate(junk) + assert getattr(h, "_next_scheduled_start_ts", None) is None + + def test_a_host_without_update_interval_still_clamps(self): + # The mixin documents no update_interval requirement. + h = _LiveHost(no_data_interval=300) + h.live_idle_max_interval = 900 + h._empty_live_streak = 24 + h._next_scheduled_start_ts = time.time() - 60 + assert h._idle_live_interval() == sports_shared._KICKOFF_POLL_FLOOR + + # The regression the 2026-09-20 soak caught: these two methods have to be + # tested together. Set _next_scheduled_start_ts by hand and the grace + # window looks fine; drive it through the candidate logic the way the live + # loop does, and the just-passed kickoff was replaced by the next one on + # the card, `now < start` went true again, and the back-off returned to its + # ceiling at exactly the wrong moment. + + def test_a_just_passed_kickoff_is_not_replaced_by_the_next_one(self): + h = self._idle_host() + now = time.time() + h._next_scheduled_start_ts = now - 45 # kicked off 45s ago + later = datetime.now(timezone.utc) + timedelta(hours=3) + h._note_scheduled_start_candidate({"start_time_utc": later}) + assert h._next_scheduled_start_ts == pytest.approx(now - 45, abs=1) + + def test_and_so_the_grace_window_actually_fires(self): + # The end-to-end property: a kickoff moments ago keeps the live cadence + # even while the rest of the day's card is being offered. + h = self._idle_host() + h._next_scheduled_start_ts = time.time() - 45 + for hours in (3, 4, 7): + h._note_scheduled_start_candidate( + {"start_time_utc": datetime.now(timezone.utc) + timedelta(hours=hours)}) + assert h._idle_live_interval() == h.update_interval + + def test_once_the_grace_expires_the_next_kickoff_takes_over(self): + h = self._idle_host() + h._next_scheduled_start_ts = time.time() - (sports_shared._KICKOFF_GRACE_SECONDS + 60) + later = datetime.now(timezone.utc) + timedelta(hours=3) + h._note_scheduled_start_candidate({"start_time_utc": later}) + assert h._next_scheduled_start_ts == pytest.approx(later.timestamp(), abs=1) + + def test_a_nearer_kickoff_does_not_dislodge_the_grace(self): + # A game ten minutes out does NOT displace one that kicked off moments + # ago, and that is deliberate: while the grace holds we poll at the live + # cadence, which is strictly tighter than anything clamping to the + # nearer kickoff would give. Letting the candidate win here would set + # the wait to ~600s at the exact moment games are starting -- the dead + # grace window this change exists to fix. + # + # The original form of this test asserted the opposite and still passed, + # because pytest.approx's default tolerance on a unix timestamp is about + # 1790 seconds. Every timestamp assertion here now pins abs=1. + h = self._idle_host() + passed = time.time() - 45 + h._next_scheduled_start_ts = passed + soon = datetime.now(timezone.utc) + timedelta(minutes=10) + h._note_scheduled_start_candidate({"start_time_utc": soon}) + assert h._next_scheduled_start_ts == pytest.approx(passed, abs=1) + # The safety property that makes it correct: 30s beats 600s. + assert h._idle_live_interval() == h.update_interval + def test_finding_a_live_game_resets_the_streak(self): h = _LiveHost() h._note_live_fetch(False)