fix(sports): honour every pending kickoff in the idle back-off, not just the first (#772)

_note_scheduled_start_candidate kept one kickoff. While it was inside its
15-minute grace every later kickoff was refused, and by the time the grace
ended the later one had passed and was refused again as already past. 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 straight back to its ceiling and the second game
was noticed up to that late.

Later kickoffs now wait in a short queue (_later_scheduled_starts, the
earliest 8). When the current kickoff's grace ends, the earliest queued one
still inside its own grace takes over -- including one that has already
passed. 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, and
_next_scheduled_start_ts keeps its meaning for anything that reads or sets
it. The promotion is a module function, so the mixin's method set is
unchanged.

Table tests replay the idle loop on a fake clock over kickoff schedules;
mutation-checked (the old code fails 10 of the new tests).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-05 09:52:22 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent b638b91169
commit 6fb2dc3595
3 changed files with 196 additions and 6 deletions
+14
View File
@@ -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
+52 -6
View File
@@ -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
+130
View File
@@ -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)