No scheduler: every recurring data ingest is a manual chore #13

Closed
opened 2026-08-27 20:57:39 +02:00 by solvreven · 4 comments
Owner

Nothing in the repo runs on a schedule — no CI workflow, no cron, no task.
Every recurring ingest depends on someone remembering:

job cadence needed currently
scripts/fetch_current_season.py after each gameweek manual
scripts/fetch_market.py --gw N through the week manual (only existed from 2026-08-27)
scripts/fetch_props.py --gw N before each deadline manual
scripts/snapshot_pit.py before each deadline manual, and cannot be taken retrospectively
scripts/build_cache.py after any ingest manual

If the ingest is not run the model silently stops learning;
archive_only_warning now states that positively in the build warnings, but
only once someone looks.

Cost is not the blocker — measured 2026-08-27. The Odds API bulk endpoint
bills 1 credit per market for the entire slate, so a full match-odds refresh
is 2 credits:

markets=h2h,totals  ->  cost 2, 20 fixtures returned

Daily match odds ~60/month, weekly props ~40/month — about 100 against a 500
budget
. The earlier assumption of per-event pricing (~20-30 per refresh) was
wrong and is what made this look unaffordable.

Suggested fix. A scheduled job chaining ingest -> cache rebuild -> snapshot,
with the credit budget stated and the balance logged per run (the events()
call is free and returns the quota headers).

Nothing in the repo runs on a schedule — no CI workflow, no cron, no task. Every recurring ingest depends on someone remembering: | job | cadence needed | currently | |---|---|---| | `scripts/fetch_current_season.py` | after each gameweek | manual | | `scripts/fetch_market.py --gw N` | through the week | manual (only existed from 2026-08-27) | | `scripts/fetch_props.py --gw N` | before each deadline | manual | | `scripts/snapshot_pit.py` | before each deadline | manual, and **cannot be taken retrospectively** | | `scripts/build_cache.py` | after any ingest | manual | If the ingest is not run the model silently stops learning; `archive_only_warning` now states that positively in the build warnings, but only once someone looks. **Cost is not the blocker — measured 2026-08-27.** The Odds API bulk endpoint bills **1 credit per market for the entire slate**, so a full match-odds refresh is **2 credits**: ``` markets=h2h,totals -> cost 2, 20 fixtures returned ``` Daily match odds ~60/month, weekly props ~40/month — about **100 against a 500 budget**. The earlier assumption of per-event pricing (~20-30 per refresh) was wrong and is what made this look unaffordable. **Suggested fix.** A scheduled job chaining ingest -> cache rebuild -> snapshot, with the credit budget stated and the balance logged per run (the `events()` call is free and returns the quota headers).
Collaborator

The scheduler needs operational choices that the repository cannot infer. Please confirm:

  1. Where should it run: Forgejo Actions, host cron/systemd timer, or another machine?
  2. What timezone and exact schedules should be used for each job, especially the deadline-relative props and PIT snapshot runs?
  3. Should scheduled jobs commit generated data/cache artifacts back to the repository, upload them as artifacts, or update only the running host?
  4. Which secret names will be available to the runner for The Odds API and any deployment/write-back authentication?
  5. What hard monthly/per-run credit ceiling should abort a run, and who/where should receive failure or low-quota notifications?

Once those are fixed, the workflow can encode the cadence and budget mechanically.

The scheduler needs operational choices that the repository cannot infer. Please confirm: 1. Where should it run: Forgejo Actions, host cron/systemd timer, or another machine? 2. What timezone and exact schedules should be used for each job, especially the deadline-relative props and PIT snapshot runs? 3. Should scheduled jobs commit generated data/cache artifacts back to the repository, upload them as artifacts, or update only the running host? 4. Which secret names will be available to the runner for The Odds API and any deployment/write-back authentication? 5. What hard monthly/per-run credit ceiling should abort a run, and who/where should receive failure or low-quota notifications? Once those are fixed, the workflow can encode the cadence and budget mechanically.
Author
Owner

A scheduler now exists on branch fix/gw2-window-and-provenance (a89fb53, 2026-08-30 14:58 Oslo) — pushed just now, which is why it was invisible when the questions above were written. Answering them against what is actually built, so the work is not done twice.

It is not a CI/cron design, so most of the questions above do not arise:

  1. Where it runs — a daemon thread inside the app process (backend/web/chores.py), started in the FastAPI lifespan beside the existing autorefresh.Refresher. No runner, no workflow file.
  2. Timezone and schedules — none. It polls every 60s and decides from facts, not a clock: "every GW2 fixture is over and no ingest is recorded for GW2". A timer that misses cannot be reasoned about afterwards; this question can be answered at any moment from data already in memory. The pre-deadline window is the 3 hours before each FPL deadline, read from events.
  3. Write-back — none. It shells out to the same scripts a human runs (after_gameweek.py, the NT pull, snapshot_pit.py), so there is one code path; the rebuilt cache reaches the app through the existing mtime hot-reload. The ledger data/chores.json is gitignored deliberately: committing it would hand a fresh clone a completion marker for work that never happened there.
  4. Secrets — none needed. Current-season ingest and fixtures are the free FPL API; Norsk Tipping is free and unmetered. The Odds API is not on this path.
  5. Credit ceiling — not applicable for the same reason: nothing on this path spends.

Verified end-to-end on live data, not just in tests: on first start it detected GW1 as the newest finished round, ran ingest → rebuild → score unprompted, and the server hot-reloaded the cache it built (built_at 2026-08-30T12:40:12Z). Ledger now reads after_gameweek:1 OK.

Guards, each a way an automated chore is worse than none: refuses a round still being played; runs each round once; records outcome not attempt; stops after 3 attempts rather than polling FPL; never overlaps itself; never snapshots after a deadline; and publishes its state on /api/status, rendered in the header — a scheduler that works silently cannot be told from one that died. 37 tests, mutation-checked (12 guards cut one at a time; the two that stayed green were a double-guarded property and one test that could not fail, which was rewritten).

The one question that is genuinely still open is architectural, and it is the reader's to make: in-process means it only runs while the server does. A deadline passing with the app closed is recorded MISSED and shown in the warning colour, because a point-in-time snapshot cannot be taken retrospectively. A Forgejo Actions version would not have that gap, at the cost of secrets, write-back policy and a second code path. I would not build both.

One correction to the issue title: it says every recurring ingest is manual. Bootstrap, fixtures and the squad sync stopped being manual in 9d9bd4d (5-minute atomic refresh, age published).

**A scheduler now exists on branch `fix/gw2-window-and-provenance` (`a89fb53`, 2026-08-30 14:58 Oslo) — pushed just now, which is why it was invisible when the questions above were written.** Answering them against what is actually built, so the work is not done twice. It is **not** a CI/cron design, so most of the questions above do not arise: 1. **Where it runs** — a daemon thread inside the app process (`backend/web/chores.py`), started in the FastAPI lifespan beside the existing `autorefresh.Refresher`. No runner, no workflow file. 2. **Timezone and schedules** — none. It polls every 60s and decides from **facts**, not a clock: *"every GW2 fixture is over and no ingest is recorded for GW2"*. A timer that misses cannot be reasoned about afterwards; this question can be answered at any moment from data already in memory. The pre-deadline window is the 3 hours before each FPL deadline, read from `events`. 3. **Write-back** — none. It shells out to the same scripts a human runs (`after_gameweek.py`, the NT pull, `snapshot_pit.py`), so there is one code path; the rebuilt cache reaches the app through the existing mtime hot-reload. The ledger `data/chores.json` is gitignored deliberately: committing it would hand a fresh clone a completion marker for work that never happened there. 4. **Secrets** — none needed. Current-season ingest and fixtures are the free FPL API; Norsk Tipping is free and unmetered. The Odds API is not on this path. 5. **Credit ceiling** — not applicable for the same reason: nothing on this path spends. **Verified end-to-end on live data**, not just in tests: on first start it detected GW1 as the newest finished round, ran ingest → rebuild → score unprompted, and the server hot-reloaded the cache it built (`built_at 2026-08-30T12:40:12Z`). Ledger now reads `after_gameweek:1 OK`. Guards, each a way an automated chore is worse than none: refuses a round still being played; runs each round once; records outcome not attempt; stops after 3 attempts rather than polling FPL; never overlaps itself; never snapshots after a deadline; and publishes its state on `/api/status`, rendered in the header — a scheduler that works silently cannot be told from one that died. 37 tests, mutation-checked (12 guards cut one at a time; the two that stayed green were a double-guarded property and one test that could not fail, which was rewritten). **The one question that is genuinely still open is architectural, and it is the reader's to make:** in-process means it only runs while the server does. A deadline passing with the app closed is recorded `MISSED` and shown in the warning colour, because a point-in-time snapshot cannot be taken retrospectively. A Forgejo Actions version would not have that gap, at the cost of secrets, write-back policy and a second code path. I would not build both. One correction to the issue title: it says *every* recurring ingest is manual. Bootstrap, fixtures and the squad sync stopped being manual in `9d9bd4d` (5-minute atomic refresh, age published).
Author
Owner

Taking the decision I left open above: keep it in-process. Do not build the Forgejo Actions version.

The trade is one gap against three costs.

The gap: an in-process scheduler only runs while the server does. A deadline passing with the app closed leaves no point-in-time snapshot, and that one cannot be repaired afterwards.

What that gap actually costs, measured rather than assumed. On first start it marked GW1 and GW2's pre-deadline windows OK, not MISSED — because snapshot_exists_for reads data/pit/ and found seven snapshots taken before GW1's deadline and six before GW2's, every one run by hand. The chore's job is that a snapshot EXISTS before the deadline, not that this code took it. So the realistic failure is "the app was closed for the three hours before a Friday deadline AND nobody ran the script" — and when it happens it is stated in the header in the warning colour, naming the round, rather than being silent.

What CI would cost:

  1. A second code path. The scripts would then be invoked two ways, and the one that gets debugged is whichever failed most recently. This repo has paid for that twice — the diagnostics that produced the GW1 captaincy numbers had none of the production guards, and every reported "p_play" was silently a start probability.
  2. Write-back policy. A rebuilt data/cache/projections.json is ~2 MB and is machine state, not source. Committing it back makes every rebuild a commit; not committing it means the runner's work never reaches the host that serves the app — which is the only place it is wanted.
  3. Secrets and a runner for a job that needs neither. Ingest and fixtures are the free FPL API; Norsk Tipping is free and unmetered. There is nothing to protect and no budget to cap.

And the deciding point: the app is the only consumer. A cache built on a runner has to reach the machine serving the site, and the only thing that machine is guaranteed to be doing when it serves the site is running the server. Putting the scheduler there removes the transport problem entirely.

If the gap ever bites — i.e. a MISSED actually appears — the cheap fix is not CI. It is a Windows Task Scheduler entry on the same host running scripts/after_gameweek.py, which is the same script, on the same machine, with no secrets and no write-back. That keeps one code path and closes the gap for the cost of one scheduled task.

I would revisit this only if the app moves off the local host, at which point the transport problem changes and so does the answer.

**Taking the decision I left open above: keep it in-process. Do not build the Forgejo Actions version.** The trade is one gap against three costs. *The gap:* an in-process scheduler only runs while the server does. A deadline passing with the app closed leaves no point-in-time snapshot, and that one cannot be repaired afterwards. *What that gap actually costs, measured rather than assumed.* On first start it marked GW1 and GW2's pre-deadline windows `OK`, not `MISSED` — because `snapshot_exists_for` reads `data/pit/` and found seven snapshots taken before GW1's deadline and six before GW2's, every one run by hand. The chore's job is that a snapshot EXISTS before the deadline, not that this code took it. So the realistic failure is "the app was closed for the three hours before a Friday deadline AND nobody ran the script" — and when it happens it is stated in the header in the warning colour, naming the round, rather than being silent. *What CI would cost:* 1. **A second code path.** The scripts would then be invoked two ways, and the one that gets debugged is whichever failed most recently. This repo has paid for that twice — the diagnostics that produced the GW1 captaincy numbers had none of the production guards, and every reported "p_play" was silently a start probability. 2. **Write-back policy.** A rebuilt `data/cache/projections.json` is ~2 MB and is machine state, not source. Committing it back makes every rebuild a commit; not committing it means the runner's work never reaches the host that serves the app — which is the only place it is wanted. 3. **Secrets and a runner for a job that needs neither.** Ingest and fixtures are the free FPL API; Norsk Tipping is free and unmetered. There is nothing to protect and no budget to cap. And the deciding point: **the app is the only consumer.** A cache built on a runner has to reach the machine serving the site, and the only thing that machine is guaranteed to be doing when it serves the site is running the server. Putting the scheduler there removes the transport problem entirely. **If the gap ever bites** — i.e. a `MISSED` actually appears — the cheap fix is not CI. It is a Windows Task Scheduler entry on the same host running `scripts/after_gameweek.py`, which is the same script, on the same machine, with no secrets and no write-back. That keeps one code path and closes the gap for the cost of one scheduled task. I would revisit this only if the app moves off the local host, at which point the transport problem changes and so does the answer.
Collaborator

Closed after PR #19 merged as 6e888a5. The implementation described in the latest issue comment is now on main.

Closed after PR #19 merged as 6e888a5. The implementation described in the latest issue comment is now on main.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
solvreven/FPL#13
No description provided.