ZenZip logozenzip

Schedules

Cron and interval schedules that persist in the store: they survive restarts, respect timezones, and let you choose what happens to missed ticks.

Basics#

schedules.ts
// Cron string (5-field, optional seconds field supported):
app.schedule("daily-report", "0 9 * * *", async (tick) => {
  // tick: { schedule: "daily-report", firedAt: number }
  await buildReport();
});

// Interval:
app.schedule("heartbeat", { every: "30s" }, async () => ping());

// Full options:
app.schedule(
  "eu-digest",
  {
    cron: "0 8 * * MON-FRI",
    timezone: "Europe/Berlin",
    overlap: "skip",
    catchup: "runOnce",
    jitter: "30s",
  },
  async () => sendDigest(),
);

Schedules are validated at app.start() — a bad cron expression or unknown timezone fails fast with a clear error, not at 3am when the tick was due.

Cron & intervals#

  • Cron: standard 5-field expressions with an optional leading seconds field (*/10 * * * * * = every 10s). Parsed by the croner engine in Rust.
  • Timezones: any IANA name — "Asia/Kolkata", "Europe/Berlin". Cron evaluation happens in that zone, DST handled by the timezone database.
  • Intervals: { every: "5m" } — next fire = previous fire + interval.

Options#

OptionTypeDefaultDescription
timezonestringUTCIANA timezone for cron evaluation.
overlap"skip" | "allow" | "queue""skip"What to do when the previous tick is still running.
catchup"skip" | "runOnce" | "all""skip"Missed-while-down policy, applied at startup.
jitterDuration0Random 0..=jitter delivery delay per fire — spreads thundering herds across a fleet of schedules.

Overlap policy#

PolicyBehavior
skipDon't fire while a previous tick is still pending or running. For idempotent sync-style jobs.
queueAlways fire, but execute ticks one at a time in order.
allowAlways fire; ticks may run concurrently.

Catch-up policy#

The next fire time is persisted. When a process starts and finds it in the past, the catch-up policy decides what happens:

// Deploy timeline for catchup policies, schedule = every 1h:
//
//   10:00  fires normally
//   10:30  process stops (deploy, crash, laptop lid…)
//   13:45  process starts again — 3 ticks were missed (11:00, 12:00, 13:00)
//
//   catchup: "skip"     → nothing fires; next tick 14:00
//   catchup: "runOnce"  → fires ONCE at 13:45, then 14:00, 15:00, …
//   catchup: "all"      → fires 3 times at 13:45 (capped at 100,
//                          cap is logged, never silent), then 14:00, …

catchup: 'all' is capped

A schedule that was down for a month would otherwise flood the queue. Replay is capped at 100 fires; hitting the cap logs a warning with the number of dropped ticks — never silent.

How it works#

  • Definitions are persisted with a canonical spec string. On restart with the same spec, the stored next-fire time wins (continuity); a changed spec recomputes from now.
  • A fire enqueues a job onto a hidden queue (zenzip.schedule.<name>) — one engine, so schedule execution inherits leases, crash recovery, and graceful drain from the queue engine.
  • The overlap policy maps to that queue's concurrency (skip/queue → 1, allow → 8) plus an active-count guard for skip.
  • The scheduler loop ticks in Rust (default 250ms, configurable via schedulerTick) — zero JS wakeups between fires.