Skip to content

Replace pre-enqueued scheduled jobs with a ScheduleState ledger#76

Open
davegaeddert wants to merge 1 commit into
masterfrom
worktree-jobs-schedule-ledger
Open

Replace pre-enqueued scheduled jobs with a ScheduleState ledger#76
davegaeddert wants to merge 1 commit into
masterfrom
worktree-jobs-schedule-ledger

Conversation

@davegaeddert

Copy link
Copy Markdown
Member

Why

Removing an already-scheduled job class left a pending JobRequest that errored at pickup after the deploy — the worker's scheduler pre-enqueued each entry's next run days ahead, and those materialized rows outlived the code that created them. Changing an entry's timing had the same shape: the old slot fired one last time.

What

Scheduling is now evaluated at tick time against a ScheduleState ledger — one row per entry identity (job class + concurrency key + schedule), recording the last slot handled. Nothing is enqueued before its slot comes due, so removing an entry simply stops it from being evaluated. Slots are claimed with an optimistic UPDATE ... WHERE last_enqueued_slot = <old> sharing a transaction with the enqueue: exactly-once across any number of workers, no leader election.

  • Catch-up window (JOBS_SCHEDULE_CATCHUP_WINDOW, default 24h): the most recent missed slot replays after downtime, while a stale ledger row (entry removed and re-added later) never fires a long-gone slot.
  • Config validation: load_schedule fully validates entries (types, registration, cron ranges — including string fields that previously bypassed range checks — and duplicate identities). A jobs.schedule preflight check reports every broken entry at deploy time.
  • Admin Schedule page: entries with next slot + last handled slot; broken entries render as rows instead of 500ing the page.
  • JobClassNotRegistered replaces the bare KeyError when a stored job_class no longer resolves.
  • PruneScheduleLedger chore GCs ledger rows for removed entries.
  • Upgrade path: transition machinery for pre-ledger workers still running during a rolling deploy (completed-run dedupe + provenance-verified sweep of pre-enqueued future rows, matched by slot-stamp == start_at epoch) — marked pre-ledger transition, removable one release later. Overdue pre-enqueued rows are deliberately kept so the old catch-up behavior holds through the upgrade.
  • Note: ScheduleState's unique constraint is created in the migration rather than convergence — workers get_or_create the same deterministic key at boot, and that race safety needs the constraint to exist before the first write.

Review

Five internal multi-agent review rounds, two external (Codex) reviews, and an implementation comparison against Solid Queue, GoodJob, sidekiq-cron, Oban, and Quartz (which validated the claim mechanism as stronger than leader-election designs and aligned the catch-up default with Quartz's misfire default). 36 new tests; 126 passing in plain-jobs.

https://claude.ai/code/session_01AEyVNMqpAhB1KVo6jrSPMk

The worker's scheduler used to enqueue each JOBS_SCHEDULE entry's next
run as a future JobRequest, days ahead of its start time. Those
materialized rows outlived the code that created them: removing or
renaming a scheduled job class left a pending row that errored at
pickup after the deploy, and changing an entry's timing let the old
slot fire one last time.

Scheduling is now evaluated at tick time against a ScheduleState
ledger — one row per entry identity (job class + concurrency key +
schedule), recording the last slot handled. Nothing is enqueued before
its slot comes due, so removing an entry simply stops it from being
evaluated. A slot is claimed with an optimistic UPDATE guarded by the
previous ledger value, with the enqueue in the same transaction, which
makes each slot fire exactly once across any number of workers with no
leader election.

Around the core:

- A catch-up window (JOBS_SCHEDULE_CATCHUP_WINDOW, default 24h) replays
  the most recent missed slot after downtime while keeping a stale
  ledger row (an entry removed and re-added later) from firing a
  long-gone slot. The window also bounds the catch-up walk.
- load_schedule validates entries fully (types, registration, cron
  ranges, duplicate identities) and a jobs.schedule preflight check
  reports every broken entry at deploy time. The admin gains a Schedule
  page that renders broken entries instead of failing.
- JobClassNotRegistered replaces the bare KeyError when a stored
  job_class no longer resolves.
- ScheduledCommand keys digest only when the raw command would overflow
  the 255-char concurrency_key with the slot stamp appended.
- A PruneScheduleLedger chore deletes ledger rows for removed entries
  (queue-scoped workers can't tell those from another queue's).
- Transition machinery for pre-ledger workers still running during the
  upgrade (a completed-run dedupe and a provenance-verified sweep of
  pre-enqueued future rows) is marked 'pre-ledger transition' and
  removable one release later.

The ScheduleState unique constraint is created in the migration rather
than convergence: workers get_or_create the same deterministic key at
boot, and that race safety needs the constraint to exist before the
first write.

Claude-Session: https://claude.ai/code/session_01AEyVNMqpAhB1KVo6jrSPMk
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