Skip to content

feat: cron scheduler for run-to-completion jobs (#986 slice B) - #997

Merged
milkway merged 1 commit into
mainfrom
feat/issue-986-scheduler
Jul 13, 2026
Merged

feat: cron scheduler for run-to-completion jobs (#986 slice B)#997
milkway merged 1 commit into
mainfrom
feat/issue-986-scheduler

Conversation

@milkway

@milkway milkway commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Refs #986 (slice B of 3; slice A = #996)

What

  • Migration 0028 (both dialects): schedules (cron per spec, optional argv override, enable flag, per-schedule timeout column for slice C) + schedule_runs (status ok/failed/error, exit code, log tail, duration).
  • jobs.rs scheduler: leader-only (scaler posture), 30 s tick. ETL semantics: fires when the cron has an occurrence since the last fire marker — no fire-on-create, downtime collapses to one firing. The marker is claimed before the run via an atomic compare-and-set (IS NOT DISTINCT FROM on pg) — crash mid-job never double-fires; a split-brain second scheduler loses the race. Jobs run detached (a long ETL never blocks the tick).
  • Failure surfacing: non-Ok outcomes record the log tail and raise the new job-failed webhook alert (Webhooks de notificação para alertas (threshold sem canal de entrega) #930 sink; cooldown per spec applies).
  • Request building reuses the spawn path's resolution (creds, ${VAR} env, limits, network, labels) minus public-path injection — a job serves no HTTP.
  • Dep: croner = "3" (crates.io-verified, 3.0.1, ~6M downloads).

Verification

  • Live e2e with the real binary + Docker: inserted a * * * * * schedule with a cmd override on a busybox spec; the scheduler fired it unattended — history row ok | exit 0 | "etl rodou" | 166 ms, zero leftover job containers.
  • Due-semantics unit table (fired-yesterday→due, fired-today→not, new-schedule waits its first occurrence, downtime collapse, unparseable cron never due and never wedges the tick).
  • CRUD + atomic fire-claim roundtrip on sqlite and real Postgres (postgres-it — the claim's stale-caller loses on both dialects).
  • Full gate: 649 tests (+3), clippy -D warnings, migration filename parity.

Slice C (admin UI for schedules + run history, per-schedule timeout, docs) remains — plan on the issue.

🤖 Generated with Claude Code

Slice B of #986: schedules persist (migration 0028, both dialects:
`schedules` with an optional argv override + `schedule_runs` history)
and a leader-only scheduler fires them through slice A's run_job.

Semantics chosen for ETL, not messaging: a schedule fires when its
cron has an occurrence between the last fire marker and now — no
fire-on-create (a new "daily 03:00" waits for 03:00), and downtime
over several occurrences collapses to ONE firing. The fire marker is
claimed BEFORE the job runs via an atomic compare-and-set on the
previous value (`IS NOT DISTINCT FROM` on pg), so a crash mid-job
never double-fires and a split-brain second scheduler loses the race
(scaler posture, #596). Jobs run on detached tasks so a long ETL
never blocks the tick; a non-Ok outcome ('failed' = non-zero exit,
'error' = could not run) records the log tail and raises the new
`job-failed` webhook alert (#930).

Cron parsing via croner 3 (verified on crates.io; ~6M downloads).
Request built from the spec exactly like the spawn path (creds,
${VAR} env resolution, limits, network, labels) minus public-path —
a job serves no HTTP; the schedule's cmd_json overrides the argv.

Tests: due-semantics table (incl. downtime collapse + unparseable
cron never due/never wedges the tick), CRUD + atomic-claim roundtrip
(sqlite and real pg), full gate green. Live e2e: a `* * * * *`
schedule on a busybox spec fired by the real serve — history row
`ok/0/"etl rodou"/166ms`, zero leftover containers.

Slice C (admin UI + docs + per-schedule timeout) remains.

Refs #986

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@milkway
milkway merged commit 992e37f into main Jul 13, 2026
6 checks passed
@milkway
milkway deleted the feat/issue-986-scheduler branch July 13, 2026 17:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant