Skip to main content

every — repeat at interval

Accepted formats

Any duration: a number of milliseconds or a string.

schedule — cron expression

Cron format (5 fields)

Each field takes *, a number, a range (1-5), a step (*/15, 1-30/5) or a comma-separated list of those. A * step counts from the field’s minimum (*/3 in the month field is January, April, July and October; */2 in the day-of-month field is the odd days). Day rule of standard cron: when both day of month and day of week are restricted, a day matches if either does — 0 9 13 * 5 runs every 13th and every Friday. When one of them starts with * (including a step such as */2), both must match, so 0 9 * * 1 is just “Mondays”. Names (mon, jan) and macros (@daily) are not supported: bot.schedule() throws a TypeError naming the offending field instead of creating a task that never runs. So does an expression that can never fire: a value out of its field’s range (60 minutes, hour 24, month 13, weekday 8), a step of 0, an inverted range (5-1) or a day no selected month has (0 0 30 2 *). Likewise bot.every() needs a positive duration and bot.delay() a non-negative one (RangeError otherwise).

Time zones

By default the fields are read in the process time zone (TZ of the host). Pass an IANA zone to read them in another one, per task or for the whole bot:
Zones are validated with Intl when the task (or the bot) is created: an unknown name throws a RangeError. every and delay reject timeZone (it only applies to cron fields). task.timeZone tells which zone a task uses. Nothing changes for a task without a zone. Daylight-saving transitions follow one documented policy. The hour field is a wildcard when it starts with * (*, */2), as for the day fields: Run times are always strictly increasing, and every replica computes the same ones, so durable tasks agree on their slots, missed runs and catch-up across a transition. The search looks up to four years ahead, with or without a zone (so 0 0 29 2 * always works).

delay — once after X time

Cancelling a task

bot.schedule, bot.every and bot.delay all return a ScheduledTask with a .cancel() method:

Durable tasks (survive restarts, one replica per run)

The tasks above live in memory: a restart forgets when they were due, and with several replicas each one runs its own copy. Give a task a stable id and it becomes durable:
  • its next run time is stored, so a restart keeps the schedule instead of planning again from “now”;
  • the occurrences missed while the bot was down follow a policy (missed);
  • each occurrence is claimed under a fenced lease in the shared store, so N replicas never run it at the same time (it is still at least once: see Guarantees and limits).
It is opt-in: tasks without id stay in memory, as described above.
Register durable tasks at boot, on every replica: only the schedule is stored, never the handler, so after a restart a task runs only if the code registers its id again (see Registering tasks). The handler receives a ScheduledRun:

Options

Missed runs

A slot is missed when it is claimed more than a grace period after it fell due, which happens when no replica was running it (the bot was down, or every replica was stopped). The grace period is tickMs (1 s by default), plus twice leaderTtlMs when a leader lease is set (a follower or a failover can take that long to step in, and that is not a missed run). Every replica decides it from the stored state alone, so they all agree. Missed slots follow the task’s policy:
  • 'skip': run none of them and wait for the next slot after now.
  • 'run-once': run one occurrence (missed: true, scheduledAt is the first missed slot), then wait for the next slot.
  • 'catch-up': run every missed slot in order, each with its own scheduledAt and occurrenceKey, up to maxCatchUp; the rest are skipped and logged. The whole catch-up runs under one claim on one replica and is committed at the end: if that process dies halfway, the catch-up starts over from its first slot (deduplicate with run.occurrenceKey); if it is stopped, it resumes from the first slot it did not run.
An occurrence interrupted by a crash (its runner died holding the lease) is not missed: once the lease expires it runs again as itself (missed: false), and the policy applies to the slots after it. Slots that fall due while an occurrence is still running (a handler slower than the interval, or a run that started late after a fail-over) are never dropped unseen under 'run-once' and 'catch-up': they are claimed next and follow the policy (run normally within the grace period, otherwise as missed). Under 'skip' the task goes on with the first slot after the run ended. A one-shot delay counts as one slot: 'skip' drops a reminder that fell due while the bot was down, so use 'run-once' for reminders. Its run time is fixed when the id is first stored; a restart does not push it back. After it runs, the row stays as a tombstone (registering the id again does not run it again) until cancel() or bot.forgetTask(id) deletes it. Interval slots do not drift: the next slot is the previous slot plus the interval, not “now” plus the interval. Cron uses the process zone unless the task or scheduler.timeZone sets one (see Time zones).

Registering tasks

  • Register recurring tasks at boot, on every replica, with the same id and spec. A task can also be registered while the bot runs (the first durable task starts the durable engine), but it is only durable for as long as some code registers it again after each restart.
  • Per-entity reminders (one id per order, per user…) are fine as long as you can rebuild them at boot from your own data: registering the same id again keeps its original run time.
A per-entity id that no code registers after a restart is an orphan: it is kept and never run.

Backends

BotConfig.scheduler takes: Every store package ships its schedule store, which shares the store’s connection and prefix: With several replicas, put the schedules in the shared store and, optionally, give the scheduler a leader lease:
The bot starts its store before the scheduler, and the tables are created for you (SQLite creates them with the rest of its schema; the other stores create them idempotently on first use). If you use a store only for schedules, await store.start() first. The SQL and Mongo stores delete softly: a forgotten id keeps its row with deleted set so its fencing token never goes backwards. Purge old tombstones by hand only when no runner can still hold their token (for example DELETE FROM schedules WHERE deleted AND updated_at < <a day ago>). Any object implementing ScheduleStore works (the type is exported from @botmesh/core and @botmesh/core/spi). @botmesh/core/spi has MemoryScheduleStore as the reference implementation and the record types; the botmesh repository checks every bundled schedule store against the same shared suite (test-utils/schedule-store-conformance.ts).

Guarantees and limits

  • At least once per occurrence, never two runs of a task at the same time (while leases hold). If a runner crashes, or stalls longer than leaseMs, its lease expires and another replica (or the restarted process) runs the same slot again. The stale runner’s signal aborts and its commit is rejected (logged). Make handlers idempotent with run.occurrenceKey.
  • Only the schedule is stored, not the handler. A stored id that no registered task claims is an orphan: it is logged once at start, kept, and never run. Remove it with bot.forgetTask(id), which applies the bot’s namespace for you. That namespace includes the botId, the provider and the connection id: changing any of them leaves the old rows as orphans.
  • Deleted ids keep a small marker with their last fencing token (so tokens never go backwards), and one-shot tasks keep their tombstone: with many per-entity ids, forget them once done.
  • Changing a task’s spec (cron or interval) between deploys plans it again from now; the missed policy does not apply to the old spec.
  • stop() pauses: rows are kept. task.cancel() deletes the row.
  • Lease deadlines use each replica’s clock: keep clocks in sync (NTP) and leaseMs far above the expected skew.
  • Time zones come from the ICU data of each Node build. When replicas run different tzdata (or a government changes its DST rules), the stored nextRunAt wins: replicas follow the row, and only the next computation picks up the new rules. A zone unknown to a replica’s Node fails its registration at boot, visibly.
  • bot.onIdle() stays in memory on purpose: per-chat idle timers are rebuilt by inbound traffic.

Next steps