Phase 4 — Rewire the scheduler onto the week table #410
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#410
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:
create_the_picksruns three times for postseason week 4Why
Jobs currently take a
WeekInfohanded to them at schedule time and have nonotion of what state the week is in. This is the phase where the week table
starts driving the schedule instead of sitting beside it:
The division of labour, stated once so the tasks below read consistently:
odds_stateis thefirst column it owns, and the template for every one after it.
Their exactly-once guarantee comes from an effect ledger, not from
APScheduler holding a
DateTrigger.Needs Phases 1–3.
Tasks
Stop pickling
WeekInfointo APScheduler args.schedule_create_pickspassesargs=[week_info], and APSchedulerserializes job args into the jobstore — so the value is frozen at
schedule time. Pass
week_idand load inside the job.schedule_create_pickslooks up the week at run time instead ofbinding one into a weekly cron. Today the 6am Wednesday run receives a
week captured at 7am the previous Wednesday. Worth confirming
empirically before and after — the current behavior may be masked by
container restarts re-running
schedule_jobsinlifespan.create_the_picksidempotency guard — skip unless the current weekisPENDING. Closes the postseason-week-4 triple-fire, and also coversreruns, restarts, and the admin button, whichis_skip_weeknever did.Make
create_the_picksan upsert — this is the actual fix, and thestruck guard above is the wrong shape.
create_the_picksblind-addsevery game with no existence check, so a second run hits
uq_game_data_source_external_game_idand raisesIntegrityError. It isnot idempotent, despite
scheduler.py:119asserting that it is. A guardmakes the second run not happen; an upsert makes it harmless, which
is what the dispatcher needs since it will call sync repeatedly by
design. It also fixes a bug the guard cannot: a kickoff time flexed
mid-week never reaches the DB today, because there is no update path at
all.
Rename it. With the upsert in place it is
sync_games_for_week, async job. "Creating the picks page" is not a thing it does — that is the
odds_statetransition below.CreatePicksExceptionandSENTRY_CRON_MONITOR_CREATE_PICKSgo with it.Implement the
PRELIMINARY→LOCKEDtransition. This is adispatcher decision, not a job:
* Trigger: policy. Wednesday 6am PT to start, unchanged behaviour.
* Guard: every game in the week has a line (
spread IS NOT NULL,which #408 makes expressible), and
now < first_kickoff.* Guard fails at trigger time: retry each tick. Close to kickoff,
release anyway with the degraded default written explicitly, and
alert. Blocking the entire pool because one game is missing a line is
the wrong failure mode.
The recorded fact is the same shape regardless of which policy produced
it, so replacing Wednesday with an odds-coverage heuristic later is one
function body and no migration.
Add the odds write barrier. Once
odds_state == LOCKED, the gamesync must not touch
spreadorfavorite_team_id. Field-level, notrow-level — scores and
start_timemust keep flowing, because flexscheduling moves kickoffs after picks open and the pick stays valid when
the clock changes. Shape is
upsert_game(session, api_game, locked),not an early return.
Skip the odds fetch entirely when
LOCKED.tank01_api.py:210,gamesunconditionally callsself.odds_data, which loops/getNFLBettingOddsonce per distinct game date — 3–4 calls perweek fetched, for numbers the barrier is about to discard. Needs a split
or a flag on the wrapper; see #411.
Alert on the illegal state combination.
game_state != 'pending' AND odds_state = 'preliminary'means kickoffhappened and the picks page was never released. That is the
season-ending failure for a pick'em pool, and it is undetectable while
"released" and "started" share one axis. With two orthogonal machines it
is one query. Wire it to Sentry or Kuma.
Admin unlock as a guarded setter. Flip
odds_stateback toPRELIMINARY, refuse if anyPlayerGamePickexists for the week, logit. No column, no ledger row, no migration — see the note in #408 on why
unlock needs no tracking.
Replace per-game polling with one live-games job. See below — this
is a redesign, not a port.
Switch
nag_playersto read week state for its trigger. Smallestjob, do it first.
SwitchSubsumed by thecreate_picksto read week state.transition and barrier tasks above.
Reinstate per-week job scheduling at startup.
lifespanno longercalls
schedule_jobs(seeschedule_jobsno longer runs at startupin TECH_DEBT), so between Wednesdays a restart comes up with nothing
scheduled. Once jobs read week state this stops needing a
WeekInfoargument and can come back.
The polling redesign
schedule_update_gamescurrently creates one APScheduler job per game,each polling every 5 minutes from kickoff to kickoff+8h. Replace it with a
single job that fetches all live games for the week whenever the week is in
progress.
This is not a refactor of the existing job — it deletes the per-game model
entirely, and with it three separate problems:
_update_one_gamebuilds a freshNflApiper game, and eachone pulls
games_data+scores_data+odds_data. A 16-game week costsroughly 16× the calls one week-scoped fetch needs. On a metered plan that is
the difference between ~5 calls per poll and ~80.
update_a_gameremoves its own job when the game isfinal, but on a missing game it returns early and removes nothing — so a
deleted or re-created game leaves a job misfiring until its
end_datepasses. Observed in dev: 14
game_id:*jobs against truncated games,producing hundreds of "was missed by 6:50:40" warnings per startup and
saturating the executor pool.
purge between weeks or seasons.
The trigger and the exit condition both come from the week state machine, which
is exactly what it is for: poll while
game_state == IN_PROGRESS, stop on thetransition to
ALL_FINAL. No per-game bookkeeping either way.Note that the stored column is what makes the exit an event: the writing
helper compares the freshly computed state against the stored one, and the
branch where they differ is the transition. That is the edge trigger for
the rollups, and it is the strongest argument for storing
game_stateratherthan computing it on read — a derived value has no previous to compare
against.
Flex scheduling
Kickoff times move, sometimes with under two weeks' notice, and a flexed game
silently invalidates a
DateTriggeralready sitting in the jobstore. Anythingderived from
first_kickoff— nag times, the release deadline, the pollingwindow — has to be recomputed rather than scheduled once. That is an argument
for the tick-and-reconcile shape over pre-scheduling dated jobs, and it is why
the upsert above matters beyond idempotency.
Watch out
Do the per-job switches as separate PRs. This is the point where a rewrite
quietly becomes a weekend. One job per PR keeps each one revertable.
Adding a job first does not sequence it. Every
run_date=now()job inlifespanlands in the same executor pool and runs concurrently. Phase 1 hitthis:
update_all_awards_startupandsync_current_week_startupraced, awardswon, and it blew up on an empty week table. Anything that reads the week table
has to tolerate not finding a row yet, rather than relying on ordering.
Jobs may write to the
weektable; they may not derive its identity.A column's value must come from the provider, or from the writing job's ownaction. It must never come from a query over the tables thatweekschedules — that is the circularity this whole design exists to avoid.Amended, because the original wording forbids the
game_statecolumn in #408.The circularity worth preventing is
Week.current()needing game rows toanswer — that genuinely deadlocks, since games are created for a week. State
is not identity. The rule is:
season,season_type,week_no— comes from theprovider. Nothing derives it, ever.
weekdrives, provided itis recomputed from scratch and never read back into identity.
recomputed at all.
Odds coverage is a guard, not a trigger. Lines move until kickoff; they
never "settle" in a way you could detect and fire on. The trigger is policy,
the guard is completeness. Conflating them produces a release that never fires.
Done when
WeekInfoin its args.create_the_picksrun three times in a row produces one week's games.sync_games_for_weekrun three times in a row produces one week's games andraises nothing.
odds_state == LOCKED, a sync run leaves everyspreaduntouched andstill updates scores and kickoff times.
week, not by what it was handed.weektable issueweektable issue #406current_season_distinct_week_infosto the 'week' model #414