Recurring Jobs (Cron)
Jobs that repeat
“Generate a sales report every day at midnight.” “Clean up expired sessions every hour.” “Send a weekly digest every Monday at 9 AM.” These are recurring jobs — they run on a schedule, repeatedly.
A cron schedule table
Recurring jobs are not individual job rows — they are schedule definitions that create jobs:
CREATE TABLE IF NOT EXISTS cron_schedules (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
payload TEXT NOT NULL DEFAULT '{}',
cron_expression TEXT NOT NULL,
last_run_at TEXT,
next_run_at TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
); Each row defines a recurring job: what to run (type, payload), when to run it (cron_expression, next_run_at), and when it last ran (last_run_at).
Cron expressions
A cron expression defines a schedule using five fields:
┌───── minute (0-59)
│ ┌───── hour (0-23)
│ │ ┌───── day of month (1-31)
│ │ │ ┌───── month (1-12)
│ │ │ │ ┌───── day of week (0-6, 0 = Sunday)
│ │ │ │ │
* * * * * Common patterns: 0 0 * * * (midnight daily), 0 * * * * (every hour), 0 9 * * 1 (Monday 9 AM), */15 * * * * (every 15 minutes).
A simple cron parser
For this course, we use a simplified approach — computing the next run time:
// src/cron.ts
export function computeNextRun(expression: string, after: Date = new Date()): Date {
const [minute, hour, dayOfMonth, month, dayOfWeek] = expression.split(" ");
// Simple implementation for common patterns
const next = new Date(after);
next.setSeconds(0, 0);
if (expression === "0 0 * * *") {
// Daily at midnight
next.setDate(next.getDate() + 1);
next.setHours(0, 0, 0, 0);
} else if (expression === "0 * * * *") {
// Every hour
next.setHours(next.getHours() + 1);
next.setMinutes(0, 0, 0);
} else if (expression.startsWith("*/")) {
// Every N minutes
const interval = parseInt(expression.split(" ")[0].replace("*/", ""));
const currentMinute = next.getMinutes();
const nextMinute = Math.ceil((currentMinute + 1) / interval) * interval;
next.setMinutes(nextMinute, 0, 0);
} else {
// Fallback: run in 1 hour
next.setHours(next.getHours() + 1);
}
return next;
} [!TIP] For production cron parsing, use a library like
cron-parser. The simplified version above handles the most common patterns. The concept is the same: convert a cron expression into the next concrete datetime.
The cron scheduler
A function that runs periodically, checks which cron schedules are due, and enqueues their jobs:
// src/cron-scheduler.ts
export function runDueCronJobs(): number {
const due = db
.prepare(
`
SELECT * FROM cron_schedules
WHERE is_active = 1 AND next_run_at <= datetime('now')
`,
)
.all() as CronSchedule[];
for (const schedule of due) {
// Enqueue the job
enqueue(schedule.type, JSON.parse(schedule.payload));
// Update last_run_at and compute next_run_at
const nextRun = computeNextRun(schedule.cron_expression);
db.prepare(
`
UPDATE cron_schedules
SET last_run_at = datetime('now'),
next_run_at = ?
WHERE id = ?
`,
).run(nextRun.toISOString(), schedule.id);
}
return due.length;
} Run this in the worker loop alongside job processing:
// In the worker loop
let lastCronCheck = 0;
const CRON_CHECK_INTERVAL = 60_000; // Check every minute
while (running) {
// Check cron schedules every minute
if (Date.now() - lastCronCheck > CRON_CHECK_INTERVAL) {
const count = runDueCronJobs();
if (count > 0) console.log(`[CRON] Enqueued ${count} scheduled jobs`);
lastCronCheck = Date.now();
}
// Process jobs as usual
const job = dequeue(WORKER_ID);
// ... same as before
} Preventing duplicate runs
If two workers check cron schedules at the same time, they might both enqueue the same job. Use the same atomic claim pattern from the dequeue query:
export function runDueCronJobs(workerId: string): number {
// Atomically claim and update each due schedule
const claimAndUpdate = db.prepare(`
UPDATE cron_schedules
SET last_run_at = datetime('now'), next_run_at = ?
WHERE id = ? AND next_run_at <= datetime('now') AND is_active = 1
`);
const due = db
.prepare(
`
SELECT * FROM cron_schedules
WHERE is_active = 1 AND next_run_at <= datetime('now')
`,
)
.all() as CronSchedule[];
let count = 0;
for (const schedule of due) {
const nextRun = computeNextRun(schedule.cron_expression);
const result = claimAndUpdate.run(nextRun.toISOString(), schedule.id);
if (result.changes > 0) {
enqueue(schedule.type, JSON.parse(schedule.payload));
count++;
}
// If changes === 0, another worker already claimed this schedule
}
return count;
} Registering cron schedules
// At startup or via an admin endpoint
function registerCron(
type: string,
expression: string,
payload: Record<string, unknown> = {},
): void {
const nextRun = computeNextRun(expression);
db.prepare(
`
INSERT OR IGNORE INTO cron_schedules (id, type, payload, cron_expression, next_run_at)
VALUES (?, ?, ?, ?, ?)
`,
).run(type, type, JSON.stringify(payload), expression, nextRun.toISOString());
}
// Register schedules
registerCron("daily_sales_report", "0 0 * * *");
registerCron("cleanup_expired_sessions", "0 * * * *");
registerCron("weekly_digest", "0 9 * * 1", { template: "weekly" }); Exercises
Exercise 1: Register a cron schedule that runs every minute. Watch the worker enqueue and process the job.
Exercise 2: Run two workers simultaneously. Verify each cron job is enqueued only once (not duplicated).
Exercise 3: Disable a cron schedule (is_active = 0). Verify it stops producing jobs.
Why does the cron scheduler enqueue jobs into the regular jobs table instead of running them directly?