Design a Job Scheduler
Leader election by lease, at-least-once dispatch, cron semantics across a cluster, and jobs that outlive a deploy.
INSERT INTO job_runs (job_id, scheduled_for) VALUES ($1, $2) ON CONFLICT DO NOTHINGA job scheduler runs work because of the time, not because of a request. The naive version is a crontab on one machine, and it works until that machine is replaced during a routine deploy, at which point the nightly invoice run stops without an error anywhere.
One decider, many doers
Run the scheduler on several replicas and let exactly one of them decide, by holding a lease. Kubernetes models this directly: a Lease object in coordination.k8s.io/v1 carries holderIdentity, leaseDurationSeconds and renewTime, and candidates race to update it, with the object's resourceVersion providing the optimistic concurrency that makes exactly one update win. The leader should not execute jobs — it should enqueue them, and ordinary workers should run them. Otherwise a two-hour job is tied to the lifetime of a fifteen-second lease.
Exactly once does not exist
A leader can crash after dispatching a job and before recording that it did. Google's internal cron, described in the SRE book, resolves this by failing closed — a skipped launch is easier to recover from than a double launch — and by deriving each launch's name from its scheduled time, so a new leader can ask whether that launch already exists. Do both: identify every run by (job_id, scheduled_for), insert that pair under a uniqueness constraint before dispatching, and make the job itself idempotent.
Cron semantics in a cluster
Decide explicitly what happens to ticks missed while the scheduler was down: skip them, run once on recovery, or replay every one. Pin a timezone per job, and remember that a 02:30 daily job does not exist on the spring-forward day and happens twice in autumn.
Example
// Lease-based leader election over Postgres, plus a claim row that makes a
// double dispatch impossible. Every replica runs this loop; only the lease
// holder dispatches.
//
// CREATE TABLE scheduler_lease (
// name text PRIMARY KEY,
// holder text NOT NULL,
// expires_at timestamptz NOT NULL);
//
// CREATE TABLE job_runs (
// job_id text NOT NULL,
// scheduled_for timestamptz NOT NULL,
// state text NOT NULL DEFAULT 'queued',
// worker text,
// heartbeat_at timestamptz,
// checkpoint jsonb,
// PRIMARY KEY (job_id, scheduled_for)); -- the whole fire-once story
import { CronExpressionParser } from 'cron-parser'; // cron-parser 5.x
const LEASE_MS = 15_000;
const RENEW_MS = 5_000; // renew well inside the lease, not at its edge
const ME = `${process.env.HOSTNAME}:${process.pid}`;
async function acquireOrRenew(db) {
// The WHERE clause is the entire election: you win if the lease is already
// yours or has expired. The primary key serialises the race, so concurrent
// candidates cannot both succeed.
const { rowCount } = await db.query(
`INSERT INTO scheduler_lease (name, holder, expires_at)
VALUES ('cron', $1, now() + ($2 || ' milliseconds')::interval)
ON CONFLICT (name) DO UPDATE
SET holder = $1, expires_at = now() + ($2 || ' milliseconds')::interval
WHERE scheduler_lease.holder = $1
OR scheduler_lease.expires_at < now()`,
[ME, String(LEASE_MS)],
);
return rowCount === 1;
}
async function dispatchDue(db, jobs, queue) {
for (const job of jobs) {
const it = CronExpressionParser.parse(job.schedule, {
tz: job.timeZone, // always explicit; never the host's zone
currentDate: new Date(),
});
// prev() returns a CronDate in cron-parser 5.x; getTime() exists on both
// that and a Date, so this works either way.
const scheduledFor = new Date(it.prev().getTime());
// Claim before dispatching. If another replica already claimed this tick -
// or a previous leader claimed it, dispatched, and then crashed - rowCount
// is 0 and we do nothing. This, not the lease, is what prevents a double
// run: the lease reduces contention, the unique key enforces correctness.
const { rowCount } = await db.query(
`INSERT INTO job_runs (job_id, scheduled_for) VALUES ($1, $2)
ON CONFLICT DO NOTHING`,
[job.id, scheduledFor],
);
if (rowCount === 0) continue;
// Enqueue rather than execute. The job must be able to outlive this
// process's lease, and this loop must stay fast enough to keep renewing it.
await queue.send({ jobId: job.id, scheduledFor: scheduledFor.toISOString() });
}
}
export async function run(db, jobs, queue) {
for (;;) {
let leader = false;
try {
leader = await acquireOrRenew(db);
if (leader) await dispatchDue(db, jobs, queue);
} catch (err) {
// Losing the database means losing the lease. Doing nothing is correct:
// another replica will take over when the lease expires.
console.error({ msg: 'scheduler tick failed', leader, err });
}
await new Promise((r) => setTimeout(r, RENEW_MS));
}
}When to use it
- Move a crontab off one box onto three replicas without the nightly billing run firing twice.
- Run a six-hour backfill that must survive the deploy happening halfway through it.
- Give each tenant its own report schedule in its own timezone, from one scheduler.
- Work out why a job silently stopped running after the cluster was unavailable for an afternoon.
More examples
The same decisions in a Kubernetes CronJob
Reach for a CronJob when the platform is already there and the job is self-contained. Read the field list as the set of questions any scheduler has to answer - overlap, missed starts, hung runs, retries - because they do not disappear when you build your own, they just stop being named. backoffLimit is the one to notice: it means the platform will run your pod again after a failure, so the job must be idempotent whether or not you wrote the scheduler.
# CronJob reached batch/v1 in Kubernetes 1.21. .spec.timeZone went beta and on
# by default in 1.25 and stable in 1.27, so 1.25 and 1.26 honour it as well. On
# 1.24 it is alpha and needs the CronJobTimeZone feature gate; on anything older
# the field does not exist and is dropped, and the schedule then runs in the
# controller manager's zone, usually UTC - silently.
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-invoices
spec:
schedule: "30 2 * * *"
timeZone: "Europe/London" # 02:30 does not exist on the spring-forward day
concurrencyPolicy: Forbid # default is Allow, which lets a slow run
# overlap itself; Replace kills the running one
startingDeadlineSeconds: 300 # also caps how many missed starts accumulate
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 2 # up to 3 pod attempts: at-least-once, again
activeDeadlineSeconds: 3600 # a hung job is failed rather than left running
template:
spec:
restartPolicy: Never
terminationGracePeriodSeconds: 120 # time to checkpoint after SIGTERM
containers:
- name: invoices
image: registry.example.com/invoices:1.8.3
args: ["run", "nightly-invoices"]
---
# The lease the control plane itself uses for leader election, for comparison.
apiVersion: coordination.k8s.io/v1
kind: Lease
metadata:
name: invoices-scheduler
namespace: billing
spec:
holderIdentity: invoices-scheduler-7d9f-abc12
leaseDurationSeconds: 15 # a candidate waits this long past renewTime
renewTime: "2026-03-01T02:29:58.000000Z"A worker that survives a deploy
This is the shape to reach for whenever a job runs longer than a deploy cycle: a nightly export, a backfill, a reconciliation sweep. The heartbeat plus stale-takeover is the same mechanism as a queue's visibility timeout, written out so you can see the failure mode - a worker that is alive but partitioned will have its job reclaimed, and both copies will be running for up to the heartbeat window.
// The job runs in a worker rather than in the scheduler, so restarting the
// scheduler cannot kill it. Restarting the *worker* still can, so: claim with a
// heartbeat, checkpoint progress, and handle SIGTERM.
const STALE = "interval '60 seconds'";
export async function runJob(db, { jobId, scheduledFor }, work) {
// Claim it, or take it over if whoever had it stopped heartbeating. This
// reclaim is exactly what makes the system at-least-once: a worker that is
// merely slow and partitioned can have its job taken.
const claimed = await db.query(
`UPDATE job_runs
SET state = 'running', worker = $3, heartbeat_at = now()
WHERE job_id = $1 AND scheduled_for = $2
AND (state = 'queued'
OR (state = 'running' AND heartbeat_at < now() - ${STALE}))
RETURNING checkpoint`,
[jobId, scheduledFor, process.env.HOSTNAME],
);
if (claimed.rowCount === 0) return { skipped: true }; // someone else has it
const beat = setInterval(() => {
db.query(`UPDATE job_runs SET heartbeat_at = now()
WHERE job_id = $1 AND scheduled_for = $2`, [jobId, scheduledFor])
.catch((err) => console.error({ msg: 'heartbeat failed', err }));
}, 15_000);
const stopping = new AbortController();
// A deploy sends SIGTERM and then waits terminationGracePeriodSeconds. Use
// that window to finish the current batch and checkpoint, not to start one.
process.once('SIGTERM', () => stopping.abort());
try {
await work({
from: claimed.rows[0].checkpoint ?? null,
signal: stopping.signal,
// Called by the job after each batch. The checkpoint shortens the replay
// after a crash; it does not prevent one, so each batch must still be
// idempotent on its own.
checkpoint: (c) => db.query(
`UPDATE job_runs SET checkpoint = $3
WHERE job_id = $1 AND scheduled_for = $2`,
[jobId, scheduledFor, c],
),
});
await db.query(`UPDATE job_runs SET state = 'done'
WHERE job_id = $1 AND scheduled_for = $2`, [jobId, scheduledFor]);
return { done: true };
} finally {
clearInterval(beat); // runs on the abort path too, so the row goes stale
} // and another worker can resume from the checkpoint
}Deciding what to do about missed ticks
Worth writing before the first outage rather than during it. The useful habit is the explicit default case: a job with no declared policy fails loudly at startup instead of quietly choosing skip. The bound on 'all' matters too - unbounded catch-up turns a four-hour scheduler outage into a thundering herd against the database the moment it recovers.
// The scheduler was down from 01:00 to 05:00 and four hourly ticks passed.
// There is no correct general answer, only three policies. Set one per job;
// do not inherit whichever one your framework happens to implement.
import { CronExpressionParser } from 'cron-parser';
export function missedTicks(job, lastDispatched, now = new Date()) {
const it = CronExpressionParser.parse(job.schedule, {
tz: job.timeZone,
currentDate: lastDispatched,
endDate: now,
});
const ticks = [];
while (it.hasNext() && ticks.length <= job.maxCatchUp) {
ticks.push(new Date(it.next().getTime()));
}
switch (job.catchUp) {
case 'skip':
// Hourly cache warm, metrics rollup: the next run supersedes all of them.
return [];
case 'latest':
// Nightly report: run it once, now, for the most recent window only.
return ticks.slice(-1);
case 'all':
// Billing, ledger postings: every window must be processed exactly once,
// in order. Bound it - replaying 2,000 ticks at once is its own incident,
// which is the same hazard behind Kubernetes refusing to schedule after
// 100 missed start times.
if (ticks.length > job.maxCatchUp) {
throw new Error(
`${job.id}: ${ticks.length} missed ticks exceeds maxCatchUp=${job.maxCatchUp};` +
' refusing to replay automatically',
);
}
return ticks;
default:
throw new Error(`${job.id}: no catchUp policy set`);
}
}
// Note what this needs to work at all: a persisted lastDispatched per job, and
// a scheduled_for on every run. A scheduler that only knows "now" cannot have
// a catch-up policy, because it cannot name the ticks it missed.
Discussion