sehaj/learn
System design/15 Classic designs

15.11 · design · 22 min

Design: a distributed job scheduler

Cron as a service for 10 million schedules: finding due jobs without scanning, several schedulers that never double-run a job (leases and fencing, tested), what to do about runs missed during an outage (tested), the top-of-the-hour spike, and executors with heartbeats, retries and alerts for jobs that silently stop.

The prompt

"Design a distributed job scheduler" asks for cron that doesn't live on one machine. A crontab on one server is simple and a single point of failure; three servers running the same crontab run every job three times. The design is about running each job once, on time, across many machines that can fail.

Design it yourself firstmidseniorleasesfencingSKIP LOCKEDidempotency

Snip's teams want to schedule work: "every hour, recompute this owner's report", "every day at 09:00 in the owner's time zone, send the summary", "in 3 days, expire this link". Design a scheduler service for 10 million schedules, where runs start within a few seconds of their time, no run happens twice at once, and a scheduler machine failing doesn't stop or duplicate anything.

22:00

1. Requirements

Functional: create, pause and delete schedules (cron expressions, fixed intervals, one-time); run each at its time on a worker; retry failures; show history per schedule.

Non-functional:

  • Runs start within a few seconds of their scheduled time.
  • A run never executes twice at the same time, and normally runs once.
  • No single point of failure.
  • 10 million schedules; tens of thousands of runs a second at peak.

2. Estimates

  • Average rate: if a typical schedule runs hourly, 10 million schedules is about 2,800 runs a second.
  • The spike: people write 0 * * * *. If a third of hourly schedules are due at minute 0, that's 3.3 million runs in one minute, about 55,000 a second, then quiet. The spike, not the average, sizes the system.
  • History: 240 million runs a day at about 200 bytes is 48 GB a day; kept 30 days, 1.4 TB, partitioned by day.

3. Entities and API

text
Schedule { id, owner, spec: "0 9 * * *" | every 1 hour, time_zone, next_run_at, job: { queue, payload },
           misfire: run_once | skip, owner_lease, lease_until, fence }
Run      { schedule_id, scheduled_for, attempt, status, worker, started_at, finished_at }   unique (schedule_id, scheduled_for)

POST   /schedules            { spec, time_zone, job, misfire }    →  201 { id, next_run_at }
PATCH  /schedules/{id}       { paused: true }
GET    /schedules/{id}/runs?before=…

4. High-level design

A scheduler service
SKIP LOCKEDrun (fenced)finishSchedule APISchedulesindex on next_run_atScheduler 1claims due rowsScheduler 2claims due rowsRun queueExecutorsheartbeat, retry
Schedulers are interchangeable and stateless: the database row, its lease and its fence decide who is doing what.

5 steps. Use Next and Back to follow the request.

Text version
  1. Creating a schedule computes its first next_run_at in its own time zone and stores it.
  2. Every second, each scheduler claims a batch of rows whose next_run_at has passed and that nobody holds, skipping rows another scheduler has locked (07.05). Each claim sets a lease and increments a fence number.
  3. For each claimed row, the scheduler records a run for that exact slot (unique per schedule and time) and puts it on the run queue.
  4. Executors run the job, sending heartbeats that extend the lease while it runs, and retry with backoff on failure (03.09).
  5. On completion, the row moves to its next slot, but only if the fence number still matches: a scheduler whose lease expired can't advance a schedule someone else now holds.
  • Schedule API → Schedules
  • Scheduler 1 ↔ Schedules: SKIP LOCKED
  • Scheduler 2 ↔ Schedules
  • Scheduler 1 → Run queue: run (fenced)
  • Scheduler 2 → Run queue
  • Run queue → Executors
  • Executors → Schedules: finish

5. Deep dives

Deep dive

Finding what's due

BadScan every schedule every second

10 million rows a second to find a few thousand due ones.

GoodAn index on next_run_at, polled with SKIP LOCKED

WHERE next_run_at <= now() ORDER BY next_run_at LIMIT 100 reads only due rows, and several schedulers poll at once without blocking each other (07.05 A job queue on Postgres with SKIP LOCKED).

GreatThe index, sharded, with the next minute held in memory

Partition schedules by hash of ID across database shards, each polled by its own schedulers. For tight timing, each scheduler also loads the coming minute's due rows into an in-memory timer and fires them on the second, claiming each just before it runs.

Deep dive

Never running a job twice at once

BadOne scheduler, or several with no coordination

One is a single point of failure; several each run everything, so every job runs N times.

GoodElect a leader; only it schedules

Correct with a proper lease (08.07 Leader election and leases), but one machine does all the work, and failover leaves a gap.

GreatClaim each row with a lease and a fence; record runs uniquely by slot

Any scheduler can claim any due row. The lease frees the row if its holder dies; the fence number, incremented on every claim, rejects a late finish from a holder that lost its lease (08.08 Distributed locks and fencing tokens); and the run's unique (schedule, slot) key makes a duplicate enqueue a no-op. Jobs themselves are idempotent, because at-least-once is still the honest guarantee (07.03 Delivery guarantees and idempotent consumers).

ts
export async function claimDue(db: pg.Pool, owner: string, limit = 10, leaseSeconds = 30) {
  const { rows } = await db.query(
    `UPDATE schedules SET owner = $1, lease_until = now() + make_interval(secs => $3), fence = fence + 1
     WHERE id IN (SELECT id FROM schedules
                  WHERE next_run_at <= now() AND (lease_until IS NULL OR lease_until < now())
                  ORDER BY next_run_at LIMIT $2 FOR UPDATE SKIP LOCKED)
     RETURNING id, next_run_at, fence`,
    [owner, limit, leaseSeconds],
  );
  return rows as { id: number; next_run_at: Date; fence: number }[];
}

export async function finish(db: pg.Pool, id: number, fence: number) {
  // Move to the next slot after now, skipping any missed while the scheduler was down.
  const { rowCount } = await db.query(
    `UPDATE schedules
     SET next_run_at = next_run_at + every * (floor(extract(epoch FROM now() - next_run_at) / extract(epoch FROM every)) + 1),
         owner = NULL, lease_until = NULL
     WHERE id = $1 AND fence = $2`,
    [id, fence],
  );
  return rowCount === 1; // false: our lease expired and someone else has the job now
}

Tested on Postgres 16 with 100 due schedules of "every minute" and four schedulers claiming in batches of 10 at the same time: all 100 were claimed, each by exactly one scheduler. With 2-second leases and nobody finishing, a fifth scheduler reclaimed all 100 once the leases expired. The original holder's finish for schedule 1 was then rejected, because its fence was stale; the new holder's finish succeeded, cleared the lease and set the next run about a minute ahead.

Deep dive

Runs missed during an outage

BadRun every missed slot when you come back

After a 10-minute outage, every per-minute job runs ten times at once: a self-inflicted spike, and usually pointless (ten copies of the same report).

GoodSkip to the next slot

No spike, but a daily billing job that missed 09:00 doesn't run until tomorrow.

GreatA policy per schedule, defaulting to 'run once now, then resume'

Most jobs want "run once to catch up, then carry on"; a few want every slot (each day's invoices); some want to skip. The schedule stores which, and the scheduler applies it.

Tested: a per-minute schedule left 10 minutes 30 seconds overdue was claimed and run once, and finish set its next run about 30 seconds ahead, at the next whole slot, instead of queueing ten catch-up runs.

The top of the hour. When a third of all hourly jobs are due at minute 0, spread them: for schedules that don't care about the exact minute ("hourly"), pick the minute from a hash of the schedule ID, so load is even across the hour. Keep the exact spec for the ones that do care, and size workers for that smaller spike.

Executors. Long jobs send heartbeats that extend their lease; a job whose heartbeats stop is presumed dead and retried. Each job has a timeout, retries with exponential backoff and jitter, and a dead-letter queue after the last attempt. A schedule that hasn't succeeded within twice its interval raises an alert: the most dangerous failure of a scheduler is a job that silently stops running.

6. Wrap-up

What breaks first: the database behind the schedules, at tens of thousands of claims a second during a spike, so schedules are sharded and claims are batched; then time zones: "09:00 daily" in a zone with daylight saving time happens twice, or not at all, once a year, so next run times are computed with a time zone library, never by adding 24 hours.

What's expected at each level

Junior

gets a working design

  • Stores schedules with a next run time and polls for due ones
  • Runs jobs on workers from a queue

Mid-level

explains the trade-offs

  • Uses SKIP LOCKED so several schedulers can poll
  • Adds leases so a dead scheduler's work is picked up
  • Retries with backoff and keeps run history

Senior

drives it and goes deep

  • Adds fencing and unique runs per slot, and makes jobs idempotent
  • Defines missed-run policies and spreads the top-of-hour spike
  • Alerts on schedules that stop succeeding and handles daylight saving time

Seen in production

Dropbox

2020

Async Task Framework (ATF)

Tasks are kept in a task store; a store consumer periodically polls it for tasks that are ready and pushes them onto queues for execution, with timeouts for tasks that aren't claimed or stop sending heartbeats, and exponential backoff for retriable failures. The system guarantees a task runs at least once after being scheduled, and at the time of writing served about 9,000 scheduled tasks a second for 28 engineering teams.

What we foundStore scheduled tasks durably, poll for the ones that are due, and guarantee at least once.

How we designed Dropbox ATF: an async task frameworkdropbox.tech ↗

Slack

2017

Job queue

On its busiest days Slack's job system processes over 1.4 billion jobs, at a peak of 33,000 a second. It originally kept jobs in Redis; during an outage, a database slowdown made jobs run slower, Redis reached its maximum memory, and it could no longer enqueue new jobs. Slack's fix was to add Kafka in front of Redis rather than replace Redis.

What we foundBackground work at huge scale, and an outage when the queue itself filled up and couldn't accept new jobs.

Scaling Slack's Job Queue (Slack Engineering, updated 2020)slack.engineering ↗
Predict first

Scheduler A claims a daily job, then pauses for 45 seconds during garbage collection. Its 30-second lease expires, scheduler B claims and runs the job, and then A wakes up and tries to finish. What should happen?

Commit to an answer before you read on.

Recap

Interview questions

SeniorfreqGood to knowDesign

Design a distributed job scheduler like cron for many machines.

Report a mistakeEdit this page
← Previous15.10Design: search autocomplete
Next → · being written16.01Design: ticket booking
☰ Classic designs15.11 · 126/147next →