Markdown Editor

Job Scheduler System Design Template (Reference Design)

Distributed scheduler: cron, delayed and one-off jobs, leases and visibility timeouts, fencing, idempotent handlers, retries, DLQ

Cara menggunakan: Use when replacing ad hoc cron and delay queues. Write the at-least-once guarantee first and size for the top-of-the-hour spike. The main pitfall is non-idempotent handlers, which turn lease expiry into duplicate side effects.

Pratinjau

System Design: [Job scheduler name]

Reference design for a distributed job scheduler that runs cron, delayed and one-off jobs for many teams. It covers job definitions, due-time indexing, leasing with visibility timeouts, at-least-once execution with idempotent handlers (and why exactly-once execution is not promised), sharding, priorities, retries with dead-letter queues, and observability. It follows the structure of the System Design Document template and is pre-filled with a worked design to adapt. Values marked Example are assumptions.

Document control

FieldValue
Document ID[SDD-SCHED-001]
System / feature[Distributed job scheduler]
Version[0.1]
Status[Draft / In review / Approved / Implemented / Superseded]
Author(s)[@author]
Reviewers[@reviewer-1], [@reviewer-2]
Last updatedYYYY-MM-DD
Related[Platform roadmap], [ADR: storage for job queue], [Runbook]

Approvals

RoleNameDecisionDate
Tech lead[@name][Approved / Changes requested]YYYY-MM-DD
Staff / principal engineer[@name][Decision]YYYY-MM-DD
SRE / operations[@name][Decision]YYYY-MM-DD
Security[@name][Decision]YYYY-MM-DD
Representative job owner team[@name][Decision]YYYY-MM-DD

Revision history

VersionDateAuthorChange summary
0.1YYYY-MM-DD[@name]First draft for design review
0.2YYYY-MM-DD[@name]Example: Added deterministic jitter for cron jobs to flatten the :00 spike
0.3YYYY-MM-DD[@name]Example: Fencing tokens on leases
1.0YYYY-MM-DD[@name]Example: Approved

Contents

1. Overview

1.1 Problem statement

[Example: Teams run cron on individual VMs and ad hoc delay queues. When a VM dies its jobs silently stop; two VMs once ran the same billing job and double-invoiced 3 000 customers; nobody can see which jobs failed overnight.]

1.2 Proposed solution

[Example: Teams register job definitions (cron, delayed or one-off) through an API. Definitions and runs are stored in a sharded relational store with an index on due time. Scheduler nodes, each owning a set of shards through leader leases, materialise due runs and move them to priority ready queues. Workers lease runs with a visibility timeout, heartbeat while working and report the result. Expired leases make the run available again, so execution is at-least-once; fencing tokens and idempotent handlers make the effects exactly-once. Failed runs retry with backoff and land in a dead-letter queue after the last attempt.]

1.3 Decisions requested from reviewers

IDQuestionRecommended optionSection
Q-01Example: Guarantee offered to job owners?At-least-once + idempotency key per run7.4
Q-02Example: Store for due runs?Sharded PostgreSQL with SKIP LOCKED dequeue5.2, 10
Q-03Example: Missed cron runs after an outage?Configurable: run once (default), run all, or skip7.2
Q-04Example: Apply jitter to cron by default?Yes, deterministic up to 60 s unless pinned7.2
[Q-nn][Question][Option][Section]

2. Requirements

2.1 Functional requirements

IDRequirementPriority
FR-01Example: Cron jobs with time zone (5-field cron syntax, IANA zones, DST rules defined)Must
FR-02Example: Delayed jobs (run at time T or after duration D)Must
FR-03Example: One-off immediate jobs with payload up to 256 KBMust
FR-04Example: Priorities: critical, default, bulkMust
FR-05Example: Retries with exponential backoff and per-job maximumMust
FR-06Example: Dead-letter queue with inspect, replay and discardMust
FR-07Example: Concurrency limit per job definition (for example max 1 running)Should
FR-08Example: Pause, resume and cancel jobs and runsMust
[FR-nn][Requirement][Must / Should / Could]

2.2 Non-functional requirements

IDQualityTargetMeasured as
NFR-01TimelinessExample: Run started within 5 s of due time at p99 (critical), 60 s (bulk)Start time minus due time
NFR-02No lossExample: Every due run reaches a terminal stateReconciliation of runs
NFR-03AvailabilityExample: 99.95% for scheduling and dispatchSynthetic canary jobs
NFR-04ThroughputExample: 20 000 run starts/s burstLoad test
NFR-05IsolationExample: One team's backlog does not delay others' critical jobsPer-tenant fairness test
[NFR-nn][Quality][Target][Measurement]

2.3 Out of scope

  • [Example: DAG workflows with dependencies between jobs (use the workflow engine)]
  • [Example: running the job code itself; workers are owned by teams]
  • [Example: sub-second scheduling precision]
  • [Item deferred, with the release that will take it]

3. Capacity estimation

3.1 Assumptions

InputSymbolValueSource
Recurring (cron) definitionsJcJ_cExample: 2 millionInventory of existing crons x growth
Share of cron definitions that run hourly at :00hhExample: 30%Inventory
Delayed jobs per dayJdJ_dExample: 30 millionTeam forecasts
One-off jobs per dayJoJ_oExample: 15 millionTeam forecasts
Cron runs per day (all schedules)RcR_cExample: 5 millionInventory
Run record sizessExample: 500 bytes (payload stored separately)Schema in 5.2
Run history retentionTTExample: 30 daysDecision

3.2 Average throughput

Rday=Rc+Jd+Jo=5×106+3×107+1.5×107=5×107 runs/dayR_{day} = R_c + J_d + J_o = 5 \times 10^{6} + 3 \times 10^{7} + 1.5 \times 10^{7} = 5 \times 10^{7} \ \text{runs/day}
QPSavg=5×10786 400≈580 runs/sQPS_{avg} = \frac{5 \times 10^{7}}{86\,400} \approx 580 \ \text{runs/s}

3.3 The top-of-the-hour spike

Without jitter, every hourly cron becomes due in the same second:

Rspike=h×Jc=0.3×2×106=600 000 runs at :00R_{spike} = h \times J_c = 0.3 \times 2 \times 10^{6} = 600\,000 \ \text{runs at :00}

With deterministic jitter spread uniformly over 60 s:

QPSspike=600 00060=10 000 runs/sQPS_{spike} = \frac{600\,000}{60} = 10\,000 \ \text{runs/s}

which is why NFR-04 targets 20 000 run starts/s (2x headroom) and why jitter is on by default (Q-04).

3.4 Storage

S=Rday×s×T=5×107×500 B×30=750 GBS = R_{day} \times s \times T = 5 \times 10^{7} \times 500 \ \text{B} \times 30 = 750 \ \text{GB}

Definitions: 2×106×22 \times 10^{6} \times 2 KB =4= 4 GB. Payloads above 4 KB are stored in object storage and referenced by key.

3.5 Summary

QuantityEstimateDesign consequence
Runs~580/s average, ~10 000/s at :00 with jitterSharded store, batch claim of due runs
Run history~750 GB for 30 daysPartition by day; drop old partitions
Definitions~4 GBFits any relational store
Timeliness5 s p99 for criticalScheduler polls each shard at least every second

4. API design

4.1 Endpoints

MethodPathPurposeAuthIdempotent
PUT/v1/jobs/{name}Create or update a job definitionTeam tokenYes
POST/v1/jobs/{name}/runsEnqueue a one-off or delayed runTeam tokenWith Idempotency-Key
POST/v1/jobs/{name}:pause / :resumePause or resume a definitionTeam tokenYes
POST/v1/leaseWorker leases up to N runs for given queuesWorker tokenNo (returns new leases)
POST/v1/runs/{id}/heartbeatExtend leaseWorker token, fencing tokenYes
POST/v1/runs/{id}/completeReport success or failureWorker token, fencing tokenYes
GET/v1/dlq?job=&cursor=List dead-lettered runsTeam tokenYes
POST/v1/dlq/{id}:replayRe-enqueue a dead-lettered runTeam tokenYes

4.2 Request and response examples

A job definition for a nightly export, with the policies that matter:

{"name": "billing.export-invoices", "schedule": {"cron": "0 2 * * *", "timeZone": "Europe/Berlin", "jitterSeconds": 0, "missedRunPolicy": "run_once"}, "queue": "billing", "priority": "critical", "timeoutSeconds": 900, "retry": {"maxAttempts": 5, "baseSeconds": 30, "maxSeconds": 1800}, "concurrency": 1, "payload": {"format": "csv"}}

A lease response gives the worker everything it needs, including the idempotency key it must use for side effects:

{"leases": [{"runId": "run_8K2", "job": "billing.export-invoices", "attempt": 2, "idempotencyKey": "billing.export-invoices@2026-03-15T02:00:00+01:00", "fencingToken": 41877, "leaseExpiresAt": "2026-03-15T01:02:30Z", "payload": {"format": "csv"}}]}

4.3 Errors

StatusProblem typeWhen
400invalid-scheduleCron expression or time zone invalid
409stale-leaseFencing token older than the current lease (another worker owns the run)
409concurrency-limitDefinition already has the maximum running
413payload-too-largePayload over 256 KB

5. Data model

5.1 Entities

Definitions describe what and when; runs are concrete executions with leases and attempts.

5.2 Storage choices

DataStorePartition keyMain queriesWhy
DefinitionsRelational database (single primary)job_nameNext due definitionsSmall, transactional
RunsExample: 16 PostgreSQL shardsshard = hash(job_name) mod 16Due runs by (state, priority, due_at); claim with FOR UPDATE SKIP LOCKEDTransactional claims without a separate lock service
Attempts and historySame shards, partitioned by dayrun_idHistory per jobCheap retention by dropping partitions
Shard ownershipCoordination service (etcd or ZooKeeper class)Shard IDWho owns shard NLeader leases with fencing
Large payloadsObject storageRun IDFetch by workerKeeps rows small

6. High-level design

Schedulers decide when runs become ready; dispatchers hand ready runs to workers; the store is the source of truth for both.

ComponentResponsibilityScales by
Scheduler APIValidate and store definitions and ad hoc runsStateless replicas
Scheduler nodesFor owned shards: compute next cron times, insert runs, flip due runs to readyShards per node
Dispatcher APIClaim ready runs by priority and fairness, issue leases and fencing tokensStateless replicas
Lease reaperReturn runs with expired leases to ready or retry_waitPer shard, inside scheduler nodes
Team workersExecute handlers, heartbeat, report resultsOwned by teams
[Component][Responsibility][Scaling unit]

7. Detailed design

7.1 Lease and execute (critical path)

A worker claims runs in one transaction, then must heartbeat before the lease expires; every call after the claim carries the fencing token.

7.2 Cron materialisation, jitter and missed runs

  • The scheduler for a shard selects definitions with next_due_at in the next 60 s, inserts a RUN for each with a unique (job_name, due_at) constraint (so a double insert after failover is harmless), and advances next_due_at.
  • Cron times are computed in the definition's time zone. During a DST jump a non-existent local time runs at the next valid time; a repeated local time runs once (first occurrence). State this to users; it surprises people.
  • Jitter: unless jitterSeconds is 0, the run is offset by hash(job_name) mod jitterSeconds, so each job keeps a stable time while the fleet spreads out.
  • Missed runs after an outage follow missedRunPolicy: run_once (default) inserts one catch-up run, run_all inserts every missed time, skip inserts none.

7.3 Run state machine

Every run follows the same states; the dashboard and API use these names.

Backoff before attempt nn (1-based retries) uses full jitter:

delayn=rand(0, min⁡(max, base×2 n−1))\text{delay}_n = \text{rand}\left(0,\ \min(\text{max},\ \text{base} \times 2^{\,n-1})\right)

With base 30 s and max 1 800 s: up to 30 s, 60 s, 120 s, 240 s, then capped growth toward 30 minutes.

7.4 Delivery guarantee and consistency model

  • At-least-once execution. A worker can finish the work and crash before complete; the lease expires and another worker runs it again. Network partitions can also leave a slow worker running while a new lease is issued. Exactly-once execution is not achievable in this model and is not promised.
  • Exactly-once effects, by contract. Each run carries an idempotency key that is stable across attempts (job_name@due_at for cron, caller key for ad hoc runs). Handlers must use it: unique constraints, upserts, or the downstream system's idempotency header.
  • Fencing. Every lease gets a monotonically increasing token. The dispatcher rejects heartbeats and completions with an older token, and handlers that write to a shared resource should pass the token so the resource can reject stale writers.
  • Store consistency. Claims, state changes and lease extensions are single-shard transactions, so a run is never leased to two workers with the same token. Cross-shard views (dashboards, DLQ listing) are eventually consistent.

7.5 Priority and fairness

Dispatchers serve queues in weighted order (Example: critical 70%, default 25%, bulk 5% of claims when all have work), and within a priority they round-robin across teams so one team's 1 million bulk runs cannot starve another team's 10. Per-definition concurrency is enforced at claim time by counting leased runs of that definition.

7.6 Sharding and scheduler ownership

Runs are partitioned into 16 shards by hash(job_name). Each scheduler node holds leases on shards in the coordination service; when a node dies its shards are reassigned within the lease TTL (Example: 10 s). A new owner starts by running the reaper and the materialiser, both of which are idempotent. Resharding doubles the shard count by splitting hash ranges during a maintenance window.

8. Scalability and performance

ConcernApproachLimit before redesign
Claim rateSKIP LOCKED batch claims, 16 shardsExample: ~2 000 claims/s per shard, ~32 000/s total
:00 spikeDeterministic jitter; materialise 60 s aheadJobs pinned to exact :00 (no jitter) above 50 000
Table bloatDaily partitions for history; aggressive vacuum on run tablesMonitor dead tuples
Many tiny jobsBatch leases (up to 100 per call)[Limit]
Long-running jobsHeartbeats; maximum lease extension of 6 hUse workflow engine above that

Latency budget from due time to handler start (critical, target p99 5 s):

StepBudget
Materialiser poll interval1 000 ms
Flip to ready100 ms
Worker long-poll wait for next claim2 000 ms
Claim transaction50 ms
Payload fetch and handler start500 ms
Headroom1 350 ms

9. Reliability, security and operations

9.1 Failure modes

FailureImpactDetectionMitigation
Worker crash mid-runRun stuck until lease expiryLease expiry countReaper returns run to retry_wait; idempotent handler on rerun
Scheduler node diesShard not materialisedShard ownership gapLease reassignment within 10 s; catch-up with missed-run policy
Shard primary downRuns on that shard delayedDB healthSynchronous standby promotion; other shards unaffected
Poison job (always fails)Retry noise, wasted capacityDLQ growth per jobMax attempts then DLQ; alert owner
Clock skewEarly or late runsNTP monitoringDue-time comparisons use database time, not worker time
Split brain on shard ownershipDouble materialisationDuplicate key errorsUnique (job_name, due_at); fencing on scheduler leases
[Failure][Impact][Signal][Mitigation]

9.2 Security and abuse

ThreatControl
Team A modifies or leases Team B's jobsTokens scoped to team and queues; authorisation on every call
Secrets in payloadsPayloads may reference secrets by name only; scanner rejects obvious credentials
Runaway enqueue loopsPer-team quotas on enqueue rate and pending runs
Audit gapsDefinition changes and DLQ replays logged with actor
[Threat][Control]

9.3 Observability

SignalMetric or logAlert threshold
Schedule lagStart time minus due time, per priorityCritical p99 above 5 s for 5 min
Ready backlogReady runs per queue and age of oldestOldest critical above 60 s
Lease expiriesExpired leases per jobAbove 1% of runs for a job
DLQNew dead runs per jobAny for critical jobs
CanarySynthetic job every minute per shardMissed twice

9.4 Cost estimate

ItemUnit costQuantityMonthly cost
Database shards (primary + standby)[$/instance-month]Example: 32[$]
Scheduler and dispatcher nodes[$/node-month]Example: 12[$]
Coordination service[$/month]3 or 5 nodes[$]
Object storage for payloads[$/TB-month][TB][$]
Total[$]

10. Trade-offs and alternatives

DecisionOptionsChosenWhyWhat we give up
Queue storeMessage broker with delayed delivery; Redis sorted sets; relational with SKIP LOCKEDRelationalTransactions, queries for dashboards, cancel and replay are easyLower ceiling than a broker; vacuum tuning
GuaranteeAt-most-once; at-least-once + idempotency; "exactly-once" via distributed transactionsAt-least-once + idempotencyHonest, achievable, no lossEvery handler must be idempotent
Timer mechanismIn-memory hierarchical timing wheel; database pollingDatabase polling every secondDurable, simple, adequate for second-level precisionNot suitable for sub-second scheduling
OwnershipSingle leader for all; sharded leadersSharded leadersHorizontal scale, smaller blast radiusCoordination service dependency
Build or adoptExisting open-source or managed scheduler; build[Adopt / Build][Reason: multi-tenancy, fairness, integration][Cost]

11. Delivery plan and risks

11.1 Milestones

MilestoneScopeExit criterionTarget date
M1One-off and delayed jobs, leases, retries, DLQChaos test (kill workers, kill DB primary) loses no runsYYYY-MM-DD
M2Cron with time zones, jitter and missed-run policiesDST test suite passesYYYY-MM-DD
M3Priorities, fairness, concurrency limitsFairness test: critical lag unaffected by bulk floodYYYY-MM-DD
M4Migration of VM cronsAll critical crons migrated; old crons disabledYYYY-MM-DD

11.2 Rollout and rollback

[Example: Migrate each cron by running it in the scheduler in "dry-run" mode (handler logs, no effects) alongside the VM cron for a week, then switch. Rollback re-enables the VM cron and pauses the definition.]

11.3 Risks

IDRiskLikelihoodImpactMitigationOwner
R-01Example: Handlers are not idempotent and duplicate side effectsHighHighIdempotency review in onboarding; key provided by platform[@name]
R-02Example: Database becomes the bottleneckMediumMediumShard doubling plan; batch claims[@name]
R-03Example: Teams pin all jobs to :00MediumMediumJitter default; quota on pinned jobs[@name]
[R-nn][Risk][L / M / H][L / M / H][Mitigation][@name]

12. Appendices

12.1 Open questions

IDQuestionOwnerDue
OQ-01Example: Do we support second-level cron fields?[@name]YYYY-MM-DD
OQ-02Example: How long are DLQ entries kept?[@name]YYYY-MM-DD
[OQ-nn][Question][@name]YYYY-MM-DD

12.2 Glossary

TermMeaning
LeaseTime-limited claim of a run by a worker
Visibility timeoutTime a leased run is hidden from other workers
Fencing tokenIncreasing number that lets systems reject a stale lease holder
DLQDead-letter queue: runs that exhausted retries
JitterDeliberate random or hashed offset to spread load
[Term][Meaning]

12.3 References

ReferenceUse
IANA Time Zone DatabaseTime zones for cron
PostgreSQL documentation, SELECT ... FOR UPDATE SKIP LOCKEDClaim query in 7.1
IETF draft "The Idempotency-Key HTTP Header Field"Idempotent enqueue
[Internal handler idempotency guide]Contract for job owners

Standard and licence note

Based on common industry practice for distributed job scheduling and queueing systems. This is a reference design to adapt, with illustrative numbers. Original text written for this collection. Template licence: MIT.

Templat terkait