Distributed Job Scheduler (cron-as-a-service, Airflow-like)
Problem statement
Design a service where teams register jobs (one-off at time T, or recurring by cron), which run on a worker fleet with retries, timeouts, dependencies and observability — with no job run twice or silently dropped.
Requirements
Functional
Create/update/delete jobs: cron schedule or run-once; payload/command.
Execute on time (within seconds); retries with backoff; timeouts.
Job dependencies (DAG) and concurrency limits.
Run history, logs, status API, manual trigger.
Non-functional
At-least-once execution with idempotency guidance; no duplicate concurrent runs of the same job.
Scale: 10M scheduled runs/day; 100k jobs.
Scheduler highly available (no single point).
Precise to ~1 s; tolerant of clock skew.
Back-of-the-envelope estimates
- 10M runs/day ≈ 115/s average; bursts at :00 minutes can be 10k/s (everyone schedules on the hour) → the dispatcher must handle spikes via a queue.
- Job table 100k rows; run history 10M rows/day × 1 KB = 10 GB/day → partition by day, retain 30 days hot.
- Due-job scan: index on
next_run_at; a poll every second reads the next few thousand rows.
API
POST /v1/jobs { name, schedule: "0 2 * * *" | { runAt }, command|payload, timeoutSec, retries, maxConcurrency, dependsOn[] }
GET /v1/jobs/{id} / PUT / DELETE
POST /v1/jobs/{id}/trigger
GET /v1/jobs/{id}/runs?status=&limit=
GET /v1/runs/{runId}/logs
Data model
jobs (job_id, owner, schedule, payload, timeout_sec, max_retries, max_concurrency, next_run_at, enabled)
runs (run_id, job_id, scheduled_for, started_at, finished_at, status, attempt, worker_id, output_ref)
UNIQUE (job_id, scheduled_for) -- prevents duplicate runs of the same tick
deps (job_id, depends_on_job_id)
locks (job_id, lease_until, holder) -- or use the runs table + DB row locks
High-level design
- API/Job service persists jobs and computes
next_run_atfrom the cron expression. - Scheduler (dispatcher): a small set of replicas; leader (via ZooKeeper/etcd lease or DB advisory lock) polls
jobs WHERE next_run_at <= now() AND enabledevery second, and for each: inserts arunsrow(job_id, scheduled_for)(unique constraint = exactly one run per tick), advancesnext_run_at, and publishes the run to a queue. Alternatively partition jobs by hash across several active schedulers, each owning a range — removes the single leader bottleneck. - Queue (Kafka/SQS) buffers spikes; priority topics.
- Workers pull runs, claim with a lease (
started_at,worker_id, heartbeat), execute with the timeout, stream logs to object storage, marksucceeded/failed. On failure, re-enqueue with backoff up tomax_retries. - Dependencies: a run is enqueued only when upstream runs for the same logical date succeeded; a DAG evaluator checks on each completion event.
- Watchdog: reaps runs whose lease expired (worker died) and re-queues them.
- UI/observability: run history, DAG view, alerts on failure/SLA miss.
Architecture diagram
Deep dives
Exactly one run per tick: the unique (job_id, scheduled_for) insert is the guard; two schedulers racing still yield one row.
Misfires: if the scheduler was down and ticks were missed, policy per job: run once (coalesce) or run all missed ticks.
Time zones/DST: store schedules with an explicit tz; compute next_run in that tz.
Idempotency: at-least-once is inherent (worker may die after doing the work but before marking success); job owners must make jobs idempotent; provide the run_id in the payload to help.
Concurrency limits: check running count for the job before dispatch; skip or queue.
Bottlenecks & mitigations
Top-of-the-hour stampede → queue absorbs; workers autoscale; encourage jitter in schedules.
Single scheduler leader → partition jobs across schedulers by hash range.
Runs table growth → partition by day; archive.
Log volume → stream to object storage, keep only pointers in the DB.
Failure modes
- Scheduler leader dies: lease expires; another replica takes over within seconds; missed ticks handled by misfire policy.
- Worker dies mid-run: heartbeat stops; watchdog re-queues after the lease; job runs again (idempotency required).
- Queue outage: scheduler keeps creating run rows with
queuedstatus; a replay job publishes them when the queue returns. - Clock skew between schedulers: use DB time (
now()) for due checks, not local clocks. - Poison job (always fails): retries exhausted →
deadstatus + alert; never blocks other jobs.
Follow-up questions interviewers ask
How would you support millions of user-defined reminders (one-off, high volume)?
How would you implement fair scheduling across tenants?
How would you do rolling upgrades of workers without killing long runs?