Keep polling a game the live window gave up on #443
No reviewers
Labels
No labels
Kestra
bug
enhancement
someday
subtask
☁️ api
🎛️ infrastructure
🐞 sentry
📆 2025 Season
📝 pages
allpicks
📝 pages
picks
📝 pages
standings
🚀 performance
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
johnsturgeon/tgfp-web!443
Loading…
Reference in a new issue
No description provided.
Delete branch "a-game-that-misses-its-live-window-can-never-reach-final-442"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The bug
_in_live_windowmeasures from the scheduled kickoff, and rule 7 was the onlycaller of
update_scores_current_week. Once that window shut, a game couldnever reach
FINAL-- so the week never reachedALL_FINAL, rule 5 re-fetchedits schedule every 45 minutes forever, and rules 8 and 9 never fired for it.
Two ways in:
rows written with
insert_game'sSCHEDULEDdefault, and the window isalready behind it. Production hit this on the Aug 2026 rebuild: preseason
week 4, all 16 games
SCHEDULED, week stuck atPREGAME, noupdate_scores_current_weekrow injobstateat all.and the provider does not move
start_timewhen they do. A 90-minute delayplus a 3.5-hour game finishes outside a 5-hour tail measured from the
scheduled start.
The fix
LIVE_LEAD/LIVE_TAILare hoisted out of_in_live_windowso_needs_catch_upis expressed against the same window rather than a secondcopy of it. Rule 7b sits below the live poll and fires every 30 minutes while
any game is unsettled and past the tail.
Cancelled games are excluded. A postponed game never settles, and would be the
one case where this rule could not falsify its own condition.
The two clauses share a
JobStaterow, so a live game's 5-minute poll alsosatisfies the slow one -- no double dispatch on a day with both a live game and
an abandoned one.
Tests
test_scores_are_not_polled_long_after_kickoffis deleted, not adjusted.It asserted "a game stuck un-final must not be polled for ever", which is
precisely the behaviour this fixes. Replaced by four tests: the abandoned game
is polled, at the slow cadence, not at the fast one, and a cancelled game does
not hold it open.
test_an_unfinished_game_does_not_move_the_watermarkseeded its live game 9hours past kickoff, which the catch-up now claims. Moved inside the window with
a fresh poll, so the watermark is still what the test turns on.
Mutation-tested, five of five caught: rule deleted (2 tests),
is_cancelledguard dropped (1), catch-up at the live cadence (1), catch-up claiming final
games (6), live window losing its tail bound (2).
Two things that surfaced from doing that, both fixed here: the first
cancelled-game test passed for the wrong reason -- a week holding only a
postponed game reads as
NO_GAMES, so rule 1 answered before rule 7b wasreached -- and a docstring claimed the clause order mattered, which it does not,
since the two clauses are an OR over the same job.
It backfills on deploy
No one-off needed. The rule is level-triggered on the current state, so the
first tick after deploy sees 16 unsettled games past the tail and no
update_scores_current_weekrow, and dispatches. Verified against a seededreplica of production as it stood on 2026-08-31.
Rule 5 may take the first tick or two while its 45-minute window is open, but
it self-limits and rule 7b follows within a couple of minutes.
resolves #442
This is a tight, well-scoped change. I've read
app/jobs/dispatcher.py, theGame/GameStatusmodels, and the full test file to check the surrounding rules and helpers.The change correctly closes a real gap: a game whose scheduled kickoff has slipped out of the 5-hour live tail (delayed game, or a week bootstrapped after its games were already played) could never be re-polled, so it never reached
FINALand every rule below rule 7 stalled for that week. HoistingLIVE_LEAD/LIVE_TAILso rule 7b shares the exact window boundary is the right way to keep the two clauses from drifting, and the tests are unusually thorough. No blocking issues.Worth fixing
Nothing blocking — the items below are judgment calls.
Nits
Unbounded poll/log for a game the provider never finalizes (
app/jobs/dispatcher.py:145). Rule 7b's condition is only falsified when a game reachesFINALor becomes cancelled (POSTPONED/SUSPENDED). If the provider stops returning a settling status for some other reason — a dropped/renamedexternal_game_id, a game leftIN_PROGRESSin the feed — 7b dispatchesupdate_scoresand emits a Sentryinfoevery 30 minutes indefinitely, with no escalation. This is the deliberate trade you made against the old "give up" behaviour, and cancelled games are the escape hatch, so it's defensible; just flagging that the failure mode is silent-but-forever rather than eventually-alerting.SUSPENDEDis bundled into the catch-up exclusion (app/jobs/dispatcher.py:199, viaGame.is_cancelledatapp/models/game.py:89). The docstring justifies the exclusion with "a postponed game never settles," which is true forPOSTPONEDbut not forSUSPENDED— a suspended game can resume the next day and goFINAL, and because it resumes well past the 5-hour tail, neither rule 7 nor 7b will ever poll it to pick up that final. This matches the codebase's existing treatment ofSUSPENDEDas terminal (_get_current_game_state,player_game_pick), so it's pre-existing and out of scope for this PR — but the rule's stated rationale doesn't actually cover theSUSPENDEDhalf ofis_cancelled. If suspended-then-resumed is a real scenario you care about, it'd need separate handling; if not, the docstring slightly overstates the guarantee.Boundary between rule 7 and 7b isn't directly tested. The tests exercise 1-minute-before, 9-hours-after, etc., but not
now == utc_start_time + LIVE_TAIL(7 owns it via<=) vs. just past it (7b owns it via>). The description says mutation testing caught the tail-bound loss, so this is covered indirectly — an explicit boundary case would just make the handoff self-documenting.Test coverage, docstrings, and the version bump (
2.20.1→2.20.2in bothpyproject.tomlanduv.lock) all look correct.