Lesson 17 / 25
Scheduled Tasks
The definition and hazards of calendar-triggered jobs: computing the recurrence rule, the end-of-month and time-zone traps, the fixed-rate versus fixed-delay distinction, and a measured solution that prevents two runs from overlapping with a lease lock.
Contents
The previous lesson took jobs from outside: the pool held a list and distributed it to workers. An important share of background jobs, though, do not come from outside — they come from the calendar. The overdue notification runs every morning at six, the branch inventory summary every fifteen minutes, the monthly loan report on the first of every month.
Summing this up as “set a timer and call the job” is misleading. A scheduled task has two hard questions. First, how recurrence is defined: what happens when “the 31st of every month” lands on a date that does not exist, does a run get skipped when the local clock changes. Second, overlap: if one run does not finish before the next trigger, two runs execute at once and produce the same report twice. This lesson builds both, and solves the second by measuring it.
Recurrence Definition
A recurring task has two parts: what gets done and when it gets done. The second is a data structure, and it can be tested with a single function: “what is the next run instant from right now.”
// schedule.mjs — computes the upcoming run instants from a recurring task definition (all UTC) const DEFINITIONS = { overdueNotification: { type: "daily", hour: 6, minute: 0 }, inventorySummary: { type: "interval", minute: 15 }, monthlyReport: { type: "monthly", day: 1, hour: 2, minute: 30 }, endOfMonth: { type: "monthly", day: 31, hour: 2, minute: 30 }, }; const MIN = 60_000; function nextRun(definition, now) { const d = new Date(now); if (definition.type === "interval") { const step = definition.minute * MIN; return new Date(Math.floor(now / step) * step + step); } if (definition.type === "daily") { const today = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), definition.hour, definition.minute); return new Date(today > now ? today : today + 24 * 60 * MIN); } // monthly: if this month's day has passed, the same day next month const thisMonth = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), definition.day, definition.hour, definition.minute); return new Date(thisMonth > now ? thisMonth : Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, definition.day, definition.hour, definition.minute)); } const START = Date.parse("2026-01-31T23:47:00Z"); // fixed starting instant for the measurement for (const [name, definition] of Object.entries(DEFINITIONS)) { let at = START; const moments = []; for (let i = 0; i < 3; i++) { at = nextRun(definition, at).getTime(); moments.push(new Date(at).toISOString().slice(0, 16).replace("T", " ")); } console.log(name.padEnd(20), moments.join(" ")); }
overdueNotification 2026-02-01 06:00 2026-02-02 06:00 2026-02-03 06:00 inventorySummary 2026-02-01 00:00 2026-02-01 00:15 2026-02-01 00:30 monthlyReport 2026-02-01 02:30 2026-03-01 02:30 2026-04-01 02:30 endOfMonth 2026-03-03 02:30 2026-03-31 02:30 2026-05-01 02:30
Because the starting instant is written as a fixed constant, the output is the same on every run; this turns the scheduling rule into a testable function. Supplying “now” from outside the function is the one decision that makes scheduling testable.
The first three lines are as expected. The fourth line is not.
The End-of-Month Trap
The endOfMonth definition says “the 31st of every month at 02:30.” The instants
produced are March 3, March 31, and May 1. February has no 31st; Date.UTC(2026, 1, 31) carries the overflowing day into the next month and produces March 3. April has no
31st either, so it slides to May 1.
This is not a bug in the computation, it is a gap in the definition. For a task tied to the last day of the month, a choice has to be made between two policies: skip the nonexistent day (no run that month) or clamp to the last actual day of the month (the 28th, 29th, or 30th). Which one is correct depends on the job — a monthly report should clamp, while a subscription renewal ending on the 31st should not be skipped. A policy that is never written down is a policy handed over to the runtime’s overflow behavior.
Time Zone
The whole computation above ran on UTC. A task tied to local time produces two further problems. On the night the clock jumps forward for daylight saving time, local 02:30 never happens; the run for that night is skipped. On the night it jumps back, local 02:30 happens twice; the run fires twice.
This is why the scheduling decision splits into two layers. The task’s trigger instant is kept and stored in UTC; local time is used only when displaying it to a user. If a local calendar boundary is genuinely required (for example, “after the branch closes”), the time zone identity becomes part of the definition and the conversion is redone on every run.
Fixed Rate and Fixed Delay
The second detail of recurrence is what the next run is scheduled against.
In fixed-rate scheduling, runs are pinned to the calendar: 02:30, 03:30, 04:30. No matter how long a run takes, the next one’s instant does not move. In fixed-delay scheduling, the wait is counted from the end of the previous run: if a run took 40 minutes and the delay is one hour, the next run starts at 04:10.
The difference between them is drift. An hourly task that takes forty minutes runs about 14 times a day under fixed delay instead of 24, and its run instants creep forward every day; this creeping is called drift. Fixed rate has no drift, but in exchange it makes overlap between runs possible: if a run takes longer than an hour, the next trigger arrives while the previous one is still running.
So the choice between fixed rate and fixed delay is a choice between drift and overlap. If drift cannot be tolerated, overlap has to be prevented separately.
Overlap
Overlap does not only arise from a long run. A second, and more commonly encountered, source is the application being deployed as multiple instances. If the scheduler runs inside the application, a service deployed across three instances sends the overdue notification three times every morning at six. A third source is redeployment: if the new instance comes up before the old one shuts down, the trigger doubles.
The fix for all three is the same: a task must acquire the right to run before it runs.
Lease Lock
The structure that grants this right is called a lock. The form used for background tasks is the lease lock: the lock is not granted indefinitely but for a fixed duration. The duration is necessary, because if the process holding the lock crashes, there is no one left to release it; the lease lets the lock drop on its own in that case.
The correctness of the lock depends on a single condition: acquisition must be atomic. If two runs both ask “is there a lock” and both get the answer “no,” the lock is useless. This is why the check and the write are combined into a single statement.
// task.mjs — the recurring task that generates the monthly loan report // node task.mjs <label> run without a lock // node task.mjs <label> --lock run with a lease lock import { DatabaseSync } from "node:sqlite"; import { appendFileSync } from "node:fs"; const LABEL = process.argv[2]; const LOCKED = process.argv.includes("--lock"); const LEASE_MS = 5000; // the lock's lease duration const db = new DatabaseSync("schedule.db"); db.exec("PRAGMA busy_timeout = 5000"); db.exec(`CREATE TABLE IF NOT EXISTS lock ( name TEXT PRIMARY KEY, owner TEXT NOT NULL, expires INTEGER NOT NULL)`); // Acquiring the lock is a single atomic write: the row is created if absent, or taken // over if present but its lease has expired. If two runs try at once, only one changes the row. const acquireLock = (name, owner) => { const now = Date.now(); return db.prepare( `INSERT INTO lock (name, owner, expires) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET owner = excluded.owner, expires = excluded.expires WHERE lock.expires < ?`).run(name, owner, now + LEASE_MS, now).changes === 1; }; const releaseLock = (name, owner) => db.prepare("DELETE FROM lock WHERE name = ? AND owner = ?").run(name, owner); function generateReport() { appendFileSync("trace.log", `started ${LABEL}\n`); const deadline = Date.now() + 300; // report generation takes 300 ms while (Date.now() < deadline); appendFileSync("trace.log", `done ${LABEL}\n`); } if (!LOCKED) generateReport(); else if (acquireLock("monthly-report", LABEL)) { try { generateReport(); } finally { releaseLock("monthly-report", LABEL); } } else { appendFileSync("trace.log", `skipped ${LABEL}\n`); }
Three details are what make the lock correct. The lock’s name is the task’s name,
not the process name; different instances use the same name. Releasing the lock also
filters on the owner column, so a run whose lock was taken over because its lease
expired cannot delete someone else’s lock. And the release sits inside finally, so it
runs on failure too.
Measurement
Whether the lock works is not asserted, it is measured. The driver below starts the
same task twice at the exact same time and computes the peak concurrent run count
from the started/done markers in trace.log.
// driver.mjs — starts the same task twice at once and parses the trace import { spawn } from "node:child_process"; import { writeFileSync, readFileSync, rmSync } from "node:fs"; const spawnRun = (argv) => new Promise((c) => spawn("node", argv, { stdio: "ignore" }).on("exit", c)); function parseTrace() { let open = 0, peak = 0, runs = 0, skipped = 0; for (const line of readFileSync("trace.log", "utf8").trim().split("\n")) { if (line.startsWith("started")) { open++; runs++; peak = Math.max(peak, open); } else if (line.startsWith("done")) open--; else skipped++; } return { runs, skipped, peak }; } for (const mode of ["unlocked", "locked"]) { writeFileSync("trace.log", ""); rmSync("schedule.db", { force: true }); const extra = mode === "locked" ? ["--lock"] : []; await Promise.all([ spawnRun(["task.mjs", "A", ...extra]), spawnRun(["task.mjs", "B", ...extra]), ]); const { runs, skipped, peak } = parseTrace(); console.log(`${mode.padEnd(9)} runs=${runs} skipped=${skipped} concurrent-peak=${peak}`); }
unlocked runs=2 skipped=0 concurrent-peak=2 locked runs=1 skipped=1 concurrent-peak=1
In unlocked mode both runs generate the report and the peak comes out 2: the monthly loan report has been computed twice. In locked mode, runs is 1, skipped is 1, and peak is 1. The second run exits without doing the work because it cannot acquire the lock.
Notice that a skipped run is not counted as an error. Failing to acquire the lock is an expected outcome for scheduled tasks; it is logged but raises no alert. What does raise an alert is the same task going a long time without ever running.
Missed Run
The lock prevents overlap, but it leaves a question open: if a run could not happen at all because of the lock or because the service was down, does it get made up for later?
There are two policies. Under the no-catch-up policy, a missed run is forgotten; the next trigger performs a normal run. Tasks that compute a final state, like the branch inventory summary, fit this, because the new run already covers the result of the missed one. Under the catch-up policy, a separate run is produced for every missed interval. The monthly loan report fits this, because each month’s report is a separate output and nothing replaces it if one is skipped.
The danger of the catch-up policy is that every run accumulated during a long outage fires all at once. This is why catch-up is given an upper bound: at most the last N intervals are made up, the rest fall into the log.
Summary
- The recurring task definition reduces to a “next run instant from right now” function; taking “now” as an input from outside makes the schedule testable.
- A definition tied to a nonexistent day of the month overflows into the next month; a skip or clamp-to-last-day policy must be written down explicitly.
- Fixed rate prevents drift but is open to overlap; fixed delay prevents overlap but shifts the run instants.
- Overlap arises from a long run, a multi-instance deployment, and redeployment; the fix for all three is a lease lock acquired before the run starts.
- The lock’s correctness depends on the atomicity of acquisition, and its durability on the lease expiring on its own; the measurement brought the peak concurrent run count down from 2 to 1.
Next Step
The tasks in this lesson were short: a report that finishes in three hundred milliseconds. The lease duration could be chosen accordingly. But a share of background jobs run for minutes — a monthly report scanning thirty thousand loan records, regenerating an uploaded cover image at several sizes. Once a job like that starts, two questions follow: how does the party that started it learn how far it has gotten, and where does the job stop when it is abandoned. The next lesson builds progress reporting and cancellation for long-running jobs.
To keep your progress and take notes, Log in
My notes
Log in to take notes.