diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a260dad..2534fd02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -194,6 +194,20 @@ Internal; no behaviour change. Stage 3 of `docs/RUN_LOOP_REDESIGN.md`. (`refresh_registry_in_background()`, backing off for a minute after an offline failure), so a later load has them. The store, install and update paths still fetch as before. +- A sports live manager's idle back-off now honours every pending kickoff, + not just the first. `_note_scheduled_start_candidate()` kept one kickoff + and, while it was inside its 15-minute grace, refused every later one; by + the time the grace ended the later one had passed and was refused again. + So of two favourites kicking off within 15 minutes of each other, the + second lost its own grace: if the first game was not live by then (a rain + delay, a postponement, ESPN slow to flip it) and ESPN had not flipped the + second either, the back-off went back to its ceiling and the second game + was noticed up to the ceiling (15 minutes by default) late. Later + kickoffs now wait in a short queue (`_later_scheduled_starts`, the + earliest 8) and each takes over with a grace of its own when the one + before it expires. A kickoff still + holds the live cadence for at most its own grace, so a postponed game + costs the same quarter of an hour as before. ### ESPN date-range fetches: fewer requests, fewer at once diff --git a/src/common/sports_shared.py b/src/common/sports_shared.py index f1efbbd7..e6d6403d 100644 --- a/src/common/sports_shared.py +++ b/src/common/sports_shared.py @@ -122,6 +122,32 @@ _DEFAULT_LIVE_IDLE_MAX_SECONDS = 900 _KICKOFF_GRACE_SECONDS = 900 #: Fallback cadence around a kickoff when the manager has no update_interval. _KICKOFF_POLL_FLOOR = 30 +#: How many kickoffs after the current one a live manager remembers. Only the +#: earliest few can matter before the next look refreshes the list, so this +#: bounds the memory without dropping a kickoff the board would wait for. +_KICKOFF_QUEUE_MAX = 8 + + +def _current_scheduled_start(host: Any, now: float) -> Optional[float]: + """The kickoff a live manager is honouring now, promoting the next queued one. + + ``_next_scheduled_start_ts`` is the kickoff being honoured: the earliest + one ahead of us, or one that has just passed and is inside its grace. + Kickoffs behind it wait in ``_later_scheduled_starts``. When the current + one's grace runs out, the earliest queued kickoff that is not itself past + its grace takes over -- including one that has already passed, so a + second kickoff inside the first one's grace still gets a grace of its own. + """ + current: Optional[float] = getattr(host, "_next_scheduled_start_ts", None) + if current and current > now - _KICKOFF_GRACE_SECONDS: + return current + queued: Optional[List[float]] = getattr(host, "_later_scheduled_starts", None) + if queued: + alive = sorted(s for s in queued if s > now - _KICKOFF_GRACE_SECONDS) + current = alive.pop(0) if alive else None + host._later_scheduled_starts = alive + host._next_scheduled_start_ts = current + return current if current and current > now - _KICKOFF_GRACE_SECONDS else None def _resolve_font_path(path: str) -> str: @@ -1296,11 +1322,11 @@ class SportsLiveSharedMixin: 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) + now = time.time() + start = _current_scheduled_start(self, now) 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: @@ -1315,6 +1341,16 @@ class SportsLiveSharedMixin: 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. + + Every pending kickoff is honoured, not just the first. A kickoff that + arrives while an earlier one is inside its grace is queued in + ``_later_scheduled_starts`` (the earliest _KICKOFF_QUEUE_MAX of them) + and takes over when that grace ends, with a grace of its own. Keeping + only the one kickoff dropped the second of two favourites starting + within the grace of each other: it was refused while the first held + the slot, and refused again once it had passed, so if ESPN had not + flipped it live by the end of the first grace the back-off went + straight back to its ceiling and the game was noticed up to that late. """ if not isinstance(details, dict): return @@ -1331,7 +1367,7 @@ class SportsLiveSharedMixin: now = time.time() if candidate <= now: return - current = getattr(self, "_next_scheduled_start_ts", None) + current = _current_scheduled_start(self, now) # 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 @@ -1341,10 +1377,20 @@ class SportsLiveSharedMixin: # 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): + # + # Nor is it forgotten: whichever kickoff loses is queued behind the + # one honoured now, so it gets its own grace when that one's ends. + if current is None: self._next_scheduled_start_ts = candidate + return + if candidate == current: + return + if candidate < current: + self._next_scheduled_start_ts, candidate = candidate, current + queued = getattr(self, "_later_scheduled_starts", None) or [] + if candidate not in queued: + self._later_scheduled_starts = sorted( + [*queued, candidate])[:_KICKOFF_QUEUE_MAX] #: How long a game that finished live is still reported by #: finished_games_snapshot(): long enough for the recent-games list, which diff --git a/test/test_sports_shared.py b/test/test_sports_shared.py index 1cdd6fc2..b9ee0a28 100644 --- a/test/test_sports_shared.py +++ b/test/test_sports_shared.py @@ -465,6 +465,136 @@ class TestLiveMixin: # The safety property that makes it correct: 30s beats 600s. assert h._idle_live_interval() == h.update_interval + # ---- every pending kickoff is honoured, not just the first ------------ + # + # One stored kickoff held the slot through its grace and refused every + # later one; a later one that had passed by the time the grace ended was + # refused again as "already past". So of two favourites kicking off ten + # minutes apart, the second lost its grace: if ESPN had not flipped it live + # by the end of the FIRST game's grace, the back-off returned to its + # ceiling and the game was noticed up to that late. That bites whenever the + # first game is not live by then -- a rain delay, a postponement, ESPN slow + # to flip it -- since a live first game keeps the live cadence anyway. + + @staticmethod + def _replay(monkeypatch, kickoffs, flips, until, poll=30, ceiling=900): + """Drive a live manager's idle loop over a schedule on a fake clock. + + ``kickoffs`` are start offsets in seconds from t=0 (the first look), + ``flips`` how long after its start ESPN reports each game live (None: + postponed, never live). Every + look offers each not-yet-live game, as the live loop does, then sleeps + for whatever the back-off returns. Returns, per game, how long after it + went live it was first seen live -- None if never. + """ + clock = [1_800_000_000.0] + monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0]) + h = _LiveHost(no_data_interval=300) + h.live_idle_max_interval = ceiling + h.update_interval = poll + h._empty_live_streak = 30 # idle all morning: at the ceiling + t0 = clock[0] + seen = [None] * len(kickoffs) + while clock[0] - t0 < until: + now = clock[0] - t0 + live = [f is not None and k + f <= now for k, f in zip(kickoffs, flips)] + for i, is_live in enumerate(live): + if is_live and seen[i] is None: + seen[i] = now - (kickoffs[i] + flips[i]) + start = datetime.fromtimestamp(t0 + kickoffs[i], tz=timezone.utc) + h._note_scheduled_start_candidate( + {"is_live": is_live, "is_halftime": False, + "start_time_utc": start}) + # A real board keeps polling at the live cadence while anything is + # live; a game here stays live for an hour after it flips. + on = any(f is not None and k + f <= now < k + f + 3600 + for k, f in zip(kickoffs, flips)) + h._note_live_fetch(on) + clock[0] += poll if on else h._idle_live_interval() + assert len(getattr(h, "_later_scheduled_starts", None) or ()) <= sports_shared._KICKOFF_QUEUE_MAX + return seen + + @pytest.mark.parametrize("kickoffs,flips", [ + # (start offsets, ESPN's flip delay per game), both in seconds. + pytest.param([1800], [120], id="one kickoff, flipped 2 min late"), + pytest.param([1800, 2400], [0, 840], + id="first live on time, the second flipped 14 min late"), + pytest.param([1800, 2400], [None, 840], + id="first postponed, the second 10 min later flipped 14 min late"), + pytest.param([1800, 2400], [1200, 840], + id="first in a 20 min delay, the second flipped 14 min late"), + pytest.param([1800, 2100, 2520], [None, None, 600], + id="three inside one grace, the last flipped 10 min late"), + pytest.param([1800, 1800, 2400], [None, None, 700], + id="two at the same time, then one 10 min later"), + pytest.param([1800, 2700], [None, 840], + id="second kickoff 15 min later, flipped 14 min late"), + pytest.param([1800 + 60 * i for i in range(20)], + [None] * 19 + [840], + id="twenty kickoffs a minute apart overflow the queue"), + ]) + def test_every_pending_kickoff_gets_its_grace(self, monkeypatch, kickoffs, flips): + seen = self._replay(monkeypatch, kickoffs, flips, + until=max(kickoffs) + 3 * 3600) + late = [s for s, f in zip(seen, flips) + if f is not None and (s is None or s > 30)] + assert not late, "games noticed late (s after going live): %r" % (seen,) + + def test_a_kickoff_that_never_flips_costs_one_grace_then_backs_off(self, monkeypatch): + # A postponed game keeps the live cadence for its grace and no longer: + # remembering more kickoffs must not pin the poll to dead ones. + clock = [1_800_000_000.0] + monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0]) + h = self._idle_host() + t0 = clock[0] + for offset in (600, 900): + h._note_scheduled_start_candidate( + {"start_time_utc": datetime.fromtimestamp(t0 + offset, tz=timezone.utc)}) + clock[0] = t0 + 900 + sports_shared._KICKOFF_GRACE_SECONDS - 1 + assert h._idle_live_interval() == h.update_interval + clock[0] = t0 + 900 + sports_shared._KICKOFF_GRACE_SECONDS + 1 + assert h._idle_live_interval() == 900 + assert not getattr(h, "_later_scheduled_starts", None) + + def test_a_queued_kickoff_past_its_own_grace_is_skipped(self, monkeypatch): + # After a long sleep (or a run of looks that never woke the manager) + # several queued kickoffs may have gone stale at once. The one still + # inside its grace must win, not the first stale one in the queue. + clock = [1_800_000_000.0] + monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0]) + h = self._idle_host() + t0 = clock[0] + for offset in (600, 700, 1500): + h._note_scheduled_start_candidate( + {"start_time_utc": datetime.fromtimestamp(t0 + offset, tz=timezone.utc)}) + clock[0] = t0 + 1500 + 150 # 600 and 700 are past their grace + assert h._idle_live_interval() == h.update_interval + assert h._next_scheduled_start_ts == t0 + 1500 + + def test_the_queue_keeps_the_earliest_kickoffs(self, monkeypatch): + clock = [1_800_000_000.0] + monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0]) + h = self._idle_host() + t0 = clock[0] + cap = sports_shared._KICKOFF_QUEUE_MAX + for offset in reversed(range(1, cap + 6)): # latest first + h._note_scheduled_start_candidate( + {"start_time_utc": datetime.fromtimestamp(t0 + 600 * offset, tz=timezone.utc)}) + assert h._next_scheduled_start_ts == t0 + 600 + assert h._later_scheduled_starts == [t0 + 600 * i for i in range(2, cap + 2)] + + def test_a_kickoff_offered_twice_is_kept_once(self, monkeypatch): + clock = [1_800_000_000.0] + monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0]) + h = self._idle_host() + t0 = clock[0] + for _ in range(3): + for offset in (600, 1200): + h._note_scheduled_start_candidate( + {"start_time_utc": datetime.fromtimestamp(t0 + offset, tz=timezone.utc)}) + assert h._next_scheduled_start_ts == t0 + 600 + assert h._later_scheduled_starts == [t0 + 1200] + def test_finding_a_live_game_resets_the_streak(self): h = _LiveHost() h._note_live_fetch(False)