SYSTEM DESIGN · DESIGN PROBLEMS · PLATFORM AND ASYNC · MEDIUM · 38 OF 41

Design a Distributed Job Scheduler: Cron at Scale, Leases and At-Least-Once Runs

How to design a shared scheduler that runs millions of one-off and cron jobs on time: a jobs table indexed by next run time, partitioned schedulers that claim due work with leases, a unique run key so each tick fires once, a queue and worker pool with heartbeats, retries, timeouts and fencing tokens, and the midnight thundering herd, priorities and per-team quotas.

Reported atGoogleGoldman SachsOracleFlipkartRazorpay

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 18

Press play to hear the narration, or step through at your own pace.

Notes

Listen

Listen. Teams submit jobs that run once at a set time, or on a cron schedule in a given time zone. A job is a container image or an HTTP callback with arguments. Each job has a retry policy, a timeout and a priority. Teams can pause, resume and cancel, and see run history and logs. Multi-step workflows with dependencies are out of scope.

Qualities. A job should start within about two seconds of its due time, and a scheduled tick must never be silently skipped. Exactly-once execution is impossible when machines can die mid-run, so promise at least once and ask teams to make jobs idempotent. Survive any crash, and be fair, so one team's ten thousand jobs cannot starve everyone else.

Functional
  • one-off: run at a given time
  • recurring: cron expression
  • in a time zone
  • payload: container image or
  • HTTP callback + arguments
  • retries, timeout, priority
  • pause, resume, cancel
  • run history, status and logs
  • out of scope: DAG workflows
Non-functional
  • start within ~2 s of due time
  • no tick ever skipped
  • at-least-once execution
  • (jobs should be idempotent)
  • survive scheduler and worker
  • crashes without losing work
  • fair: one team cannot starve
  • the others
  • highly available API

Ask scale

Scale. Ten million job definitions producing a hundred million runs a day is only about a thousand runs a second on average. But people love round times. A large share of cron jobs say midnight or the top of the hour, so a hundred thousand runs can be due in the same second. Run records are about fifty gigabytes a day; keep ninety days.

Back of the envelope
10M job definitions
100M runs/day
  100M / 10⁵ s ≈ 1,000 runs/s

but cron loves round times:
  if 0.1% of a day's runs are
    due at exactly 00:00:00
  → 100,000 in one second

run record ≈ 500 B
  100M × 500 B = 50 GB/day

Define API

The API. Creating a job takes either a cron schedule with a time zone or a single run time, the target, arguments, timeout, retry limit and priority, plus an idempotency key so a retried create does not make two jobs. It returns the job id and the next run time. Teams can list runs, pause, resume, cancel a run and delete the job.

API
POST /v1/jobs
  { name, schedule: "0 2 * * *",
    tz, run_at?, target, args,
    timeout_s, max_retries,
    priority, idempotency_key }
  → 201 { job_id, next_run_at }
GET  /v1/jobs/{id}/runs?cursor=
POST /v1/jobs/{id}:pause|resume
POST /v1/runs/{id}:cancel
DELETE /v1/jobs/{id}

Data model

Data model. Use a relational store, sharded later, because we need transactions and an index on time. Jobs carry their next run time, indexed, plus a bucket number from a hash of the job id. Each run is its own row with a unique constraint on job id and due time, so one tick can never create two runs. Runs track state, attempt, worker, lease and a fencing token.

Data model (SQL, sharded)
jobs
  job_id PK, owner, schedule, tz
  next_run_at  (INDEX)
  bucket = hash(job_id) % 1024

runs
  run_id PK, job_id, due_at
  UNIQUE (job_id, due_at)
  state, attempt, worker_id
  lease_until, fence_token
  started_at, finished_at

High-level design

The high-level design. The API writes the job with its next run time. Scheduler instances keep asking the database what is due, create a run row for each, advance the job's next run time, and push the run onto a queue. Workers take runs from the queue, execute them, and report the result back to the runs table.

Why split it this way? Deciding when is cheap and must be precise. Doing the work is slow and unpredictable, from one second to one hour. A queue between them absorbs bursts and orders work by priority, and workers scale on queue depth. Logs stream to object storage, with a link on the run row.

Why split the roles
  • schedulers: small and fast,
  • only decide what is due
  • queue: buffers bursts,
  • orders by priority
  • workers: slow, varied,
  • scale on queue depth
  • logs → object storage,
  • link stored on the run
  • each part scales on its own

Deep dive: due jobs

Deep dive one: finding due jobs. Each scheduler polls about once a second for jobs whose next run time is within the next five seconds, using the index. FOR UPDATE SKIP LOCKED lets several schedulers work at once without blocking or grabbing the same rows. In one transaction per job, insert the run and compute the next run time from the cron expression.

To scale out, split jobs into a thousand and twenty-four buckets and have each scheduler own a share through a lease in a coordinator like etcd, renewed every few seconds. If a scheduler dies, its lease expires and the others pick up its buckets within about ten seconds. If two ever overlap, the unique run key turns the second insert into a harmless conflict.

Finding due jobs
every ~1 s, per owned bucket:
SELECT job_id FROM jobs
 WHERE bucket IN (:mine)
   AND next_run_at <= now() + 5s
 ORDER BY next_run_at LIMIT 500
 FOR UPDATE SKIP LOCKED

per job, one transaction:
  INSERT run (job_id, due_at)
  UPDATE next_run_at = next(cron)
Partition the schedulers
  • 1,024 buckets, 4 schedulers
  • → 256 buckets each
  • ownership = lease in etcd,
  • renewed every 3 s
  • scheduler dies → lease ends
  • in 10 s → others take over
  • double claim? UNIQUE
  • (job_id, due_at) rejects it

Deep dive: execution

Deep dive two: execution. When a worker takes a run, it marks it running with a lease thirty seconds out and a new fencing token. It sends a heartbeat every ten seconds to extend the lease. If the worker dies, heartbeats stop, the lease expires, and a reaper puts the run back on the queue with the attempt count increased.

The tricky case is a zombie. Worker A pauses for a minute, maybe a long garbage collection. Its lease expires, worker B takes the run, then A wakes up and writes its result. Fencing tokens fix this: each claim gets a higher number, and the database rejects writes with an older one, as in Concepts episode 31. Side effects can still repeat, which is why jobs must be idempotent.

Failures retry with exponential backoff and jitter: ten seconds, twenty, forty, and so on, until the retry limit. Then the run is marked dead and the owner is alerted. A run that exceeds its timeout is killed and counts as a failure. And decide what happens to ticks missed during a pause or an outage: skip them, or run once to catch up.

Leases and heartbeats
  • worker claims run:
  • state = running
  • lease_until = now + 30 s
  • fence_token = token + 1
  • heartbeat every 10 s
  • extends the lease
  • no heartbeat → lease expires
  • → reaper requeues the run
  • (attempt + 1)
Zombies and fencing
  • worker A pauses 60 s (GC)
  • → lease expires, B takes run
  • → A wakes and writes result
  • fix: fencing token
  • B holds token 8, A holds 7
  • writes carry the token
  • DB rejects token < current
  • jobs still idempotent:
  • side effects may repeat
Retries and timeouts
on failure:
  attempt < max_retries →
    due_at = now + 2^attempt × 10s
    (+ jitter)
  else → state = dead, alert

timeout_s reached →
  kill container, count as fail

cron missed while paused:
  policy = skip | run once

Deep dive: midnight

Deep dive three: midnight. A hundred thousand runs due at once overwhelms the schedulers and the workers. Look ahead and enqueue runs a few seconds early into a delay queue that releases them on time. Encourage jitter: hourly can mean a random but stable minute per job. Use priority queues, and cap concurrent runs per team so one team cannot take every worker.

Taming the herd
  • look ahead: enqueue runs a
  • few seconds early into a
  • delay queue, released on time
  • jitter by default: "hourly"
  • = a random but fixed minute
  • priority queues: high first
  • per-team quotas: max N
  • concurrent runs per team

Trade-offs

Failure modes. A dead scheduler means its buckets run about ten seconds late, but nothing is skipped because due jobs stay due in the database. A dead worker means a retry. If the queue loses a message, a reaper finds runs stuck in queued and re-enqueues them. If the database primary fails, schedulers pause during failover, then catch up.

Monitor start delay, from due time to actual start, at p ninety-nine, the number of runs that are due but not yet queued, queue depth per priority, and failure, retry and dead counts per team. At ten times the load, shard the jobs table by bucket, add schedulers, and autoscale workers on queue depth.

Failure modes
  • scheduler dies → lease moves,
  • ~10 s late, nothing skipped
  • worker dies → lease expires,
  • run retried
  • queue loses a message →
  • reaper finds runs "queued"
  • too long, re-enqueues
  • DB primary fails → failover,
  • schedulers pause, catch up
Monitor and grow
  • monitor:
  • start delay p99 (due → run)
  • due-but-not-queued count
  • queue depth per priority
  • failure and retry rates
  • dead runs per team
  • at 10x:
  • shard jobs by bucket
  • more buckets per scheduler
  • autoscale workers on depth

Say it like this

In the interview: jobs are indexed by next run time and hashed into buckets. Schedulers own buckets through leases and claim due rows with skip locked, and a unique key on job and due time guarantees one run per tick. Workers hold leases with heartbeats and fencing tokens. Failures retry with backoff, and look-ahead, jitter and quotas tame the midnight rush.

Interview summary
  • jobs: next_run_at index,
  • hashed into buckets
  • schedulers own buckets
  • via etcd leases
  • SKIP LOCKED + UNIQUE
  • (job_id, due_at) = one run
  • queue → workers with lease,
  • heartbeat, fencing token
  • retry + backoff, timeout, DLQ
  • look-ahead, jitter, quotas

Interview questions

How do schedulers find jobs that are due without scanning everything?

Index jobs by next_run_at and poll for rows due within a short look-ahead, using SELECT ... FOR UPDATE SKIP LOCKED so multiple scheduler instances can claim rows concurrently without blocking each other.

How do you ensure a cron tick fires only once?

Each run row has a unique constraint on (job_id, due_at), and inserting the run and advancing next_run_at happen in one transaction, so a duplicate claim fails on the constraint.

How do you scale the scheduler itself horizontally?

Hash jobs into a fixed number of buckets and give each scheduler instance a lease on a subset of buckets through a coordinator such as etcd or ZooKeeper; when an instance dies its lease expires and others take its buckets.

What happens if a worker crashes in the middle of a job?

The worker holds a lease on the run that it extends with heartbeats; when heartbeats stop the lease expires and a reaper re-queues the run with an incremented attempt count.

Why can a scheduler only promise at-least-once execution?

A worker can finish the side effect and then crash before reporting success, and the system cannot tell that apart from a crash before the work, so it must retry, which means jobs need to be idempotent.

What is a fencing token and why does a job scheduler need one?

It is a number that increases with every claim of a run; writes carry the token and are rejected if it is older than the current one, which stops a paused zombie worker from overwriting the result of the worker that took over.

How do you handle thousands of jobs scheduled for exactly midnight?

Enqueue runs a few seconds early into a delay queue, add stable jitter to loosely specified schedules, process by priority, and enforce per-team concurrency quotas so the burst is spread and shared fairly.

What would you monitor in a job scheduler?

Start delay from due time to execution at p99, the count of due runs not yet queued, queue depth per priority, and failure, retry and dead-letter rates per team.