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.

yatta/func/cron.tsts
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.

yatta/func/cron.tsts
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

yatta/func/cron.tsts
/** * 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.

yatta/func/cron.tsts
/** 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.