fix(sports): stop the idle back-off sleeping through a kickoff (#599)

* fix(sports): stop the idle back-off sleeping through a kickoff

A league with no live games backs its poll off as empty checks mount,
capped by live_idle_max_interval. The escalation counts empty looks and
nothing else, so a league three hours before kickoff is indistinguishable
from one three months out of season. Both reach the ceiling -- and the
ceiling then *is* 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. Reproduced in the wild on 2026-09-20, where an
unpatched rig sat for fifteen minutes with eight NFL games in progress and
had not noticed any of them. 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.

The clamp costs no extra request: the live fetch already downloads the
whole day's scoreboard, upcoming games included, so the earliest start
still ahead of us falls out of the payload the manager already has.
Before a kickoff the wait is shortened so it cannot run past it; just
after one, the live cadence is held 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 starting.

The grace window needed a second pass. A soak caught it as dead code: the
just-passed kickoff was replaced by the next fixture on the card the
instant it passed, `now < start` went true again, and the back-off
returned to its ceiling. Observed live -- the rig polled at 13:00:45,
found nothing because ESPN had not flipped the status, then went quiet for
a quarter of an hour. A kickoff inside the grace window is now kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* test(sports): pin absolute tolerances and correct a wrong grace expectation

pytest.approx defaults to a relative tolerance. On a unix timestamp that is
roughly 1790 seconds, so every kickoff assertion here was effectively
vacuous -- it called a kickoff half an hour away "equal". All seven now
pin abs=1.

That hid a wrong expectation. test_an_earlier_kickoff_still_wins_during_the_grace
asserted a game ten minutes out should displace one that kicked off moments
ago. It should not, and the code does not: while the grace holds, the wait
is the live cadence (30s), which is strictly tighter than clamping to the
nearer kickoff would give (~600s). Letting the candidate win would set a
ten-minute wait at the exact moment games are starting -- the dead grace
window this branch exists to fix.

The test now pins the real behaviour plus the safety property that makes it
correct, and is renamed to say what it checks.

Reported by CodeRabbit on the PR. The finding was right that code and test
disagreed; the suggested fix was the wrong way to resolve it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-20 18:25:33 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 19686ab761
commit 81e1bc596f
2 changed files with 253 additions and 4 deletions
+93 -4
View File
@@ -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."""
+160
View File
@@ -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)