Examples
Cron scheduler
Scheduled work that does not double-run, does not stampede, and survives a restart.
The overlap problem
A nightly job that takes 40 minutes on a 30-minute schedule will run twice by morning. A lease taken atomically is the only reliable guard.
import { createCron } from "yatta/jobs";import { jobs } from "./jobs"; export const cron = createCron(jobs); /** * Take a lease, or report that it is already held. * * The insert carries the condition, so the database decides the winner. A * read-then-write here reintroduces the race the lease exists to prevent. */export function tryAcquire(name: string, ttlMs: number): string | null { const now = Date.now(); const row = db.run( `INSERT INTO cron_leases (name, expires_at) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET token = excluded.token, expires_at = excluded.expires_at WHERE cron_leases.expires_at <= ? RETURNING token`, [name, now + ttlMs, now], "get", ); return row?.token ?? null;} export function release(name: string, token: string): void { // Only the holder may release; a stale holder must not clear a live lease. db.run(`DELETE FROM cron_leases WHERE name = ? AND token = ?`, [name, token]);}Schedule with jitter
Every tenant’s digest fires at 09:00 exactly, which is a thundering herd against your own database. Jitter spreads the load with no coordination.
export interface ScheduleOptions { /** Spread starts across this many milliseconds. */ jitterMs?: number; /** Run even if the previous run is still going. */ allowOverlap?: boolean; /** Skip rather than queue when a run is missed. */ misfirePolicy?: "skip" | "catch-up";} cron.schedule("0 9 * * *", async (ctx) => { const jitter = ctx.options.jitterMs ?? 0; if (jitter > 0) { await Bun.sleep(Math.floor(Math.random() * jitter)); } const token = allowOverlap ? null : tryAcquire("daily-digest", 30 * 60_000); if (!allowOverlap && !token) { // Already running. Log and move on rather than queueing behind it. observer.log.warn("Skipped daily-digest: previous run still active"); return; } try { const users = await db.users.all(); for (const user of users) { await jobs.enqueue("todo-digest", { userId: user.id }, { priority: "low" }); } } finally { // Always release, including on a throw. A leaked lease blocks the next run // for its whole TTL. if (token) release("daily-digest", token); }}, { jitterMs: 10 * 60_000 } satisfies ScheduleOptions);Reap dead leases
/** * A process killed mid-run leaves its lease behind. Sweep expired ones on a * short interval so a crash costs a TTL, not a day. */cron.schedule("*/5 * * * *", async () => { const purged = db.run( `DELETE FROM cron_leases WHERE expires_at <= ? RETURNING name`, [Date.now()], "all", ); if (purged.length > 0) { observer.log.warn("Reaped expired cron leases", { count: purged.length }); }});Timezones
Cron is evaluated in the server’s local time. If the server is UTC and your users are not, schedule by UTC and convert explicitly.
/** 09:00 in the user's own timezone, expressed as a UTC hour. */function utcHourFor(localHour: number, timeZone: string, at = new Date()): number { const local = new Date(at.toLocaleString("en-US", { timeZone })); const offsetMinutes = (local.getTime() - at.getTime()) / 60_000; return (localHour - Math.round(offsetMinutes / 60) + 24) % 24;} // One schedule per distinct offset, not per user.const buckets = new Map<number, string[]>();for (const user of users) { const hour = utcHourFor(prefs.digestHour, prefs.timeZone); buckets.set(hour, [...(buckets.get(hour) ?? []), user.id]);} for (const [hour, ids] of buckets) { cron.schedule(`${hour} * * * *`, async () => { for (const userId of ids) { await jobs.enqueue("todo-digest", { userId }, { priority: "low" }); } });}Tip
Group users by hour and schedule once per bucket. Registering a schedule per user means one timer per user, which does not scale past a few hundred.