Phase 2 — Week state machine #408
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
1 participant
Notifications
Due date
No due date set.
Blocks
#406 Resolve the technical debt issues that remain for the
week table issue
johnsturgeon/tgfp-web
Reference
johnsturgeon/tgfp-web#408
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Parent: #406 · Tech debt:
Weekhas no state machineWhy
app/models/week_state.pydefinesWeekState(pending/pregame/in_progress/all_final) and nothing imports it. Thegame_statecolumnwas pulled back out of
Weekbefore shipping, because a column no job writeswould have sat at
pendingon every row and lied.This phase is independent of Phase 3 and 4. It only needs Phase 1 far enough
along that
weekrows exist.Tasks
Add an in-memory SQLite
sessionfixture totests/conftest.py.Weekhas no foreign keys, so it needs no seed data — this is thecheapest possible entry point into the "no test database" problem, and
everything below leans on it.
Add
GameStatusas aStrEnuminapp/models/game_status.py,alongside
season.pyandweek_state.py. MembersSCHEDULED,IN_PROGRESS,FINAL,POSTPONED,SUSPENDED; lowercase values, tomatch
WeekState.Game.game_statusstays annotatedstr-- aStrEnummember is astr, so it stores with no conversion andcompares equal to what comes back off a loaded row.
Write
week_state_from_statuses()as a pure function, with tests.No DB, no wiring, nothing imports it yet.
Add
game_stateback toWeekplus a migration, defaulting topending. Annotate the columnstr, notWeekState— a nativePostgres enum makes every future state an
ALTER TYPEon a live table.Add
odds_statetoWeek, same migration, defaulting topreliminary. NewOddsStateStrEnum inapp/models/week_state.py:PRELIMINARY→LOCKED, one direction. This single fact is both "oddsare frozen" and "the picks page is released" — they are one moment with
two names, see the design notes.
Make
Game.spreadandGame.favorite_team_idnullable, samemigration. Today a game with no odds is written as home −0.5, byte for
byte identical to a genuine pick'em, so "does every game have a line" is
not an answerable question — and that is precisely the guard on the
LOCKEDtransition. Null means "no odds yet"; the transition is whatguarantees non-null afterwards.
gameis still empty in production, sothis is one revision with no backfill. Blast radius is
app/templates/picks.j2:48,app/jobs/create_picks.py, and onecomparison at
app/models/game.py:83.Give it a writer.sync_current_week(orupdate_a_game) computesthe state from the provider payload and writes it.Give
game_statea writer. One shared helper, called by every jobthat writes
gamerows, inside the same transaction as the write.Not
sync_current_week— that job only ever sees what the provider saysthe current week is, and never touches a game row. Not a standalone
projection job either: a separate job is exactly where drift comes from.
Committing the projection alongside the rows it summarises takes the
drift window to zero rather than to one tick.
Delete
_games_exist_and_all_games_are_final()inapp/jobs/award_update_all.py— defined, called from nowhere, and verynearly the
pending/all_finalhalf of this logic. Do not leave athird copy.
Design notes
Two kinds of column, opposite failure modes. Both live on
week, and theobvious tidy-up on either one breaks it. Comment them individually.
game_stateodds_stategamerowsgame_statedrifting is self-healing and benign — the next write corrects it,and the worst case is one extra poll or a picker link appearing a beat late.
odds_stateis not derivable from anything: no query over any table can tellyou whether you released the picks page.
Storing
game_statedoes not violate the one-way rule — but #410 statesthat rule in a form that forbids it outright. That paragraph is being amended
there: week identity comes from the provider and nothing derives it; week
state may be projected from tables that week drives, provided it is
recomputed from scratch and never read back into identity. The circularity the
rule exists to prevent is
Week.current()needing games to answer, which woulddeadlock, because games are created for a week. State is not identity.
Why
odds_stateis one fact and not two. An earlier draft of this hadodds_lockedandpicks_releasedas separate facts on the theory that odds"settle" at some observable moment you might want to freeze before releasing.
They do not — lines move on injury news right up to kickoff. The freeze is an
act of the pool, not an observation of the market, and there is no reason to
perform it earlier than the moment you show people the numbers they are
picking against. One column.
That also means odds coverage is a guard, not a trigger. The trigger is
policy (today: Wednesday 6am PT). The guard is "every game has a line, and
now < first_kickoff". See #410.odds_stateneeds no unlock tracking. Going back toPRELIMINARYis onlylegal before any
PlayerGamePickexists for the week, and in that region ithas no consequences to record: nothing downstream depends on the old spreads,
so unlock-then-relock is indistinguishable from having locked correctly the
first time. The end state is the whole truth. The one thing that looks like it
needs history — not re-announcing "picks are open" on the second lock — is
covered by the effect ledger, not by this column.
Pure function, not a query method.
Two reasons: there is no seedable testdatabase, so a pure function is the only version testable today; and it has twocallers on opposite sides of the one-way data rule — the live updater feeds itstatuses from the API payload, a backfill would feed it statuses fromgamerows. The Week table still never derives itself from tables it drives.Both of those reasons are gone. The first task in this issue builds the test
database. And under the sync/dispatch split there is only ever one caller: the
live updater writes its game row and stops, and the projection reads rows.
Nothing feeds statuses in from an API payload any more.
The pure function survives regardless, as the inner layer — it is the only
part with branching worth testing exhaustively, and the test table below is
still the specification. What changes is that it is no longer the public API.
The wrapper that loads rows and folds them is three lines, and that is what
callers use.
POSTPONEDandSUSPENDEDare skipped, not folded. Tank01 has fivestatus codes, not three (
0scheduled,1in progress,2final,3postponed,4suspended). The old string comparison quietly ignored thelast two:
is_final,is_pregameandis_in_progressinapp/models/game.pyare three independent equality checks, so a postponedgame answered
Falseto all three and belonged to no bucket. A weekcontaining one would never reach
ALL_FINAL-- polling would never stop andthe rollups would never fire.
Filter them out before the fold, and the fold itself stays a three-branch
function over games that are actually going to happen:
Do not implement this as "count postponed as final" instead. Any version
that counts terminal-vs-not sees "one
POSTPONED, fifteenSCHEDULED" as amix and returns
IN_PROGRESSfor a week where nothing has kicked off --and postponements are usually announced days ahead, so that is an ordinary
Tuesday, not an edge case. Under #410 it would start score polling early and,
once
nag_playersreads week state, stop nagging players who have not picked.Skipping gets it right; folding does not.
This is provisional. What actually happens to a postponed or suspended
game -- rescheduled into this week, moved to another, abandoned -- is
unresolved and wants its own issue. A rescheduled game reappears under status
0with a new kickoff, which is a problem forGame.get_first_game_of_the_weekand the nag times rather than for this fold.Knowingly unhandled: if every game in a week is postponed the filtered list
is empty and this returns
PENDING. Not worth a branch.in_progressmeans "started but not finished", not "a game is live rightnow". A week with thirteen finals and a Monday nighter yet to kick off is
neither
pregamenorall_final. A naiveany(status == IN_PROGRESS)getsthat case wrong — put it in the docstring, it is the assumption a future reader
will make incorrectly.
It also must not be a method on
Week.As of Phase 1,app/models/game.pyimportsWeekat module level —Game.current_season_distinct_week_infoscallsWeek.current(session)— sothe dependency arrow insideapp/modelscurrently runsgame -> week.Putting the state calculation onWeekas a query method (the obvious shape:Week.recompute_state(session), readingGamerows) closes that intogame -> week -> game. That is not a style problem; it breaksimport app.modelsoutright, and it will surface as an unrelated-lookingImportError somewhere far from the change.A module-level function taking a sequence of status strings has no suchproblem: it imports nothing fromgame, and callers pass the statuses in.The conclusion holds; the import argument does not survive #414.
app/models/game.py:162is the only use ofWeekin that file, and itsits inside
current_season_distinct_week_infositself. Once #414 moves thatmethod onto
Weekand rewrites it to read theweektable,game.pydropsits
Weekimport and there is no import edge between the two modules ineither direction — at which point
week -> gamewould be perfectly legal.The reason that does survive is the one #414 is itself establishing:
Weekanswers from the
weektable alone. A method onWeekthat readsGamere-muddies that the moment #414 has cleaned it, and reads as "the week derives
itself from games" even when it is computed on read and stores nothing. The
fold belongs on
Game— which after #409 hasweek_idand can do it as aone-column filter — or in the job.
Test cases
Statuses are
GameStatusmembers, not the ESPN-eraSTATUS_*strings.[]PENDINGFINALALL_FINALSCHEDULEDPREGAMEIN_PROGRESS, rest scheduledIN_PROGRESSIN_PROGRESSALL_FINALFINALplus onePOSTPONEDALL_FINALFINALplus oneSUSPENDEDALL_FINALSCHEDULEDplus onePOSTPONEDPREGAMEPOSTPONEDIN_PROGRESSIN_PROGRESS, restPOSTPONEDIN_PROGRESSDone when
game_stateon a live week reflects reality without anyone running a job by hand.odds_stateexists and defaults topreliminary. Nothing writes it in thisphase — its writer is the dispatcher, in #410.
Game.spreadis nullable, and a game with no odds in the payload is storedas null rather than as home −0.5.
weektable issueweektable issue #406Game.week_idforeign key #409current_season_distinct_week_infosto the 'week' model #414