A job scheduler that runs a job once, on time, on some machine
Cron on one box is five minutes of work. Cron on twenty boxes, where any of them can die mid-job, is this.
The brief
Users schedule jobs — once at a time, or on a repeating schedule.
Jobs are executed by a pool of workers. Workers are ordinary machines and can disappear without warning.
Requirements
Functional
- Schedule a one-off job for a future time
- Schedule a recurring job
- Cancel a scheduled job, including one that is about to run
- Report what happened to each run
Non-functional
- A job must not run twice concurrently on two workers
- A job whose worker dies mid-run must eventually run again
- Late is usually survivable; silently never running is not
Back-of-envelope
Assume
- 1 million scheduled jobs in the system
- Peak of 2,000 jobs due in the same second — schedules cluster on the hour and nobody schedules for 3:07
- A job takes 5 seconds on average
Therefore
- Steady rate is low, but the clustering is the design constraint: 2,000 due at once, not spread across the minute.
- Concurrency: 2,000 × 5 s = 10,000 worker-seconds to clear one spike. Twenty workers take about eight minutes; two hundred take about fifty seconds.
- The polling query is 'jobs due before now, not yet claimed' — a range scan on a time index, run repeatedly by every worker.
Averaging 2,000 jobs over 60 seconds would have said 33 per second and hidden the spike completely. The clustering is stated as its own assumption because it is the one that sizes the worker pool.
The interface
What is stored
id · schedule · payload · state · nextRunAt · leaseUntil · leaseOwner · leaseGeneration · attemptA partial index on (nextRunAt) WHERE state = 'DUE' is the hottest object in the system and stays small — a million jobs, but only the due ones are in the index every worker is hammering. leaseGeneration is what makes fencing possible; see the failures below.
jobId · attempt · workerId · startedAt · finishedAt · outcomeA separate table because a job has many runs, and because 'report what happened to each run' is a functional requirement that a single mutable status column on the job cannot satisfy.
The design
The decisions
Each of these could go the other way. The choice, the reason, and what it costs — a design that lists only what it chose teaches the choice; one that lists what it gave up teaches the judgement.
What breaks first
In order. Each names what you would actually observe, and each fix carries its cost.
When something fails
Scaling it
Each step is triggered by a number, not a feeling — and carries what it costs.
What gets probed
The design is the easy half. These are where the conversation goes, and each has a defensible answer above.
- A worker claims a job and is paused by a long GC for longer than its lease. Two workers now believe they own it. What happens?
- 2,000 jobs come due at once and you have 20 workers. What does the last one's latency look like?
- A user cancels a job one millisecond before a worker claims it. Who wins, and is that the answer you want?
- A recurring job takes longer than its interval. What should happen?