Notes
The problem
The naive version is a crontab on one server. If that server dies, jobs silently stop running, and nobody notices until invoices are missing. Put the same crontab on three servers for safety and every job runs three times. There are no retries, no history, no way to ask whether last night's job ran. And one machine's CPU caps how much work you can do.
- the box dies → jobs silently
- never run
- run cron on 3 boxes for
- safety → every job runs 3x
- no retries, no history,
- no "did the 2 a.m. job run?"
- one machine's CPU limits
- how much work you can do
Data model
Start with the data. Every job is a durable row: its type and payload, when it should run, its status, how many attempts so far, which worker holds it and until when, a unique dedupe key so the same job can't be submitted twice, and a cron expression if it repeats. Index run at, because the scheduler's main question is 'what is due now?'
id bigint pk
type 'send_reminder'
payload jsonb
run_at timestamptz (index)
status queued|running|done
|failed
attempts int, max 5
lease_until timestamptz
worker_id text
dedupe_key unique
schedule cron expr or null Architecture
The architecture has four parts. An API to submit, cancel and inspect jobs. A scheduler that, every second or so, finds jobs whose run time has passed, marks them queued and pushes them onto a ready queue. The queue buffers work, often one per priority. And a pool of workers pulls jobs, runs them and reports back, autoscaled on queue depth. Separating 'when' from 'who runs it' lets each scale on its own.
- API: submit, cancel, query
- scheduler: every second,
- find due jobs, mark them
- queued, push to the queue
- queue: buffers ready work,
- one per priority
- workers: pull, run, report;
- scale on queue depth
One scheduler acts
Run three scheduler instances for availability, but only one should act at a time, or due jobs get enqueued three times. Use leader election from Concepts episode thirty-one: the active scheduler holds a lease in etcd or a database row and renews it every few seconds. If it dies, the lease expires and another takes over within about fifteen seconds. Still make enqueueing idempotent, checking the status as you update it, because two leaders can briefly overlap.
- run 3 scheduler instances
- only the lease holder scans
- (etcd lease, or a DB row
- with an expiry)
- leader dies → lease expires
- → another takes over in ~15 s
- still: make the enqueue step
- idempotent (status check)
- fencing token on writes
Claiming
At moderate scale you can skip the separate queue and let workers claim jobs straight from the table. The key is SELECT FOR UPDATE SKIP LOCKED in Postgres or MySQL. Each worker locks up to ten due rows, skipping rows another worker has already locked, then marks them running with a sixty-second lease. No two workers ever get the same job, and nobody waits on anybody else's lock.
UPDATE jobs SET status='running',
worker_id=$me,
lease_until=now()+'60 s'
WHERE id IN (
SELECT id FROM jobs
WHERE status='queued'
AND run_at <= now()
ORDER BY priority, run_at
LIMIT 10
FOR UPDATE SKIP LOCKED)
RETURNING *; Dead workers
Workers die mid-job. While a job runs, its worker extends the lease every twenty seconds or so. If worker two crashes, the heartbeats stop, the lease expires, and a reaper puts the job back to queued for someone else. The subtle case: a worker that was just paused, not dead, may wake up and finish too. So the job ran twice, and handlers must be idempotent, for example by checking whether the reminder was already sent.
- worker extends lease_until
- every ~20 s while running
- worker crashes → lease
- expires → reaper sets job
- back to queued
- slow, not dead? it may still
- finish → job ran twice
- → handlers must be idempotent
Retries
Retries are just rescheduling. On a failure, increment attempts and set the next run time with growing delays and some jitter: thirty seconds, two minutes, ten minutes, an hour. Distinguish errors: a 503 from a provider is worth retrying, an invalid payload is not. After the last attempt, mark the job failed, alert, and give operators a retry button. And give every job type a timeout, so a hung job doesn't hold a worker forever.
- on error: attempts += 1,
- run_at = now + backoff
- 30 s, 2 m, 10 m, 1 h, 6 h
- (+ jitter)
- retryable vs not: 503 yes,
- invalid payload no
- max attempts → status failed,
- alert, manual retry button
- timeout per job type
Recurring jobs
Recurring jobs store a cron expression with a time zone. When an occurrence is scheduled, also create the next one, with a dedupe key of job plus scheduled time so it can never be created twice. After an outage, decide per job what to do with missed runs: catch up every one, run once, or skip. A billing job should catch up; a cache warmer should skip. And watch daylight saving time: two thirty in the morning may not exist, or may happen twice.
- store cron expr + timezone
- on run: insert the NEXT
- occurrence (dedupe key =
- job + scheduled time)
- missed runs (outage):
- catch up all, run once, or
- skip: choose per job
- DST: 2:30 a.m. may not exist
- or happen twice
Priorities
Not all jobs are equal. A password reset email must not wait behind a million bulk exports. Use separate queues for critical, default and bulk work, with dedicated workers for critical. Poll with weights, so bulk work is slow but never starves completely. And cap concurrency per tenant, so one customer exporting a million reports can't monopolise every worker.
- separate queues: critical,
- default, bulk; dedicated
- worker pools for critical
- weighted polling so bulk
- is slow but never starves
- per-tenant concurrency cap:
- one customer's 1M exports
- cannot block everyone
- rate limit calls to APIs
Scaling
When one scheduler can't scan fast enough, partition the jobs into, say, sixty-four buckets by hashing the id, and let each scheduler instance lease a set of buckets, so they divide the work without overlap. For jobs far in the future, store them in time buckets, or use a Redis sorted set keyed by run time, where fetching what's due is a cheap range query. Workers stay stateless and scale on queue depth.
- partition jobs by hash(id)
- into 64 buckets
- each scheduler instance
- leases a set of buckets
- far-future jobs: time-bucket
- them (hour tables, or Redis
- sorted set by run_at)
- workers: stateless, scale on
- queue depth and job lag
Monitoring
What would you monitor? The headline is schedule lag: how long after its run time a job actually starts, at p ninety-nine. Then queue depth per priority, failure rate per job type, retry and dead-letter counts, and jobs stuck running past their timeout. Add a dead man's switch: an alert that fires when an important job did not report success, because silence is how scheduled jobs usually fail.
- schedule lag: start time −
- run_at (p99 under 5 s?)
- queue depth per priority
- failure rate per job type
- retries and dead-letter count
- stuck jobs: running past
- their timeout
- "dead man's switch": alert
- if the nightly job did NOT run
Sizing
Sizing. Fifty million jobs a day averages about five hundred eighty a second, but schedules cluster, so midnight might see twenty times that, twelve thousand jobs a second. If a job takes two seconds, that's twenty-four thousand running at once, around four hundred eighty workers with fifty slots each. Or add a few minutes of jitter to scheduled times and flatten the spike. Job rows add fifty gigabytes a day, so archive finished jobs.
50M jobs/day → avg ~580/s, peak (midnight cron) 20x ≈ 12,000 due per second job takes 2 s on average → 12,000 × 2 = 24,000 concurrent at peak 50 slots per worker → ~480 workers (or smooth the spike with jitter) rows: 50M × 1 KB = 50 GB/day
Say it like this
In the interview: jobs are durable rows with a run time, status, lease, attempts and a dedupe key. One active scheduler per partition, chosen by a lease, moves due jobs to queues. Workers claim jobs with SKIP LOCKED or from the queue, heartbeat to keep their lease, and lose it if they die. Delivery is at least once, so handlers are idempotent. Retries back off into a failed state. Priorities and tenant caps keep it fair, and schedule lag is the metric.
- jobs table: run_at, status,
- lease, attempts, dedupe key
- scheduler: leader lease,
- partitioned buckets
- claim: SKIP LOCKED or queue
- leases + heartbeats → reclaim
- at-least-once → idempotent
- retries w/ backoff → failed
- priorities + tenant caps
- watch: schedule lag