every — repeat at interval
Accepted formats
Any duration: a number of milliseconds or a string.schedule — cron expression
Cron format (5 fields)
*, 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:
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 stableid 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).
id stay in memory, as described above.
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 istickMs (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,scheduledAtis the first missed slot), then wait for the next slot.'catch-up': run every missed slot in order, each with its ownscheduledAtandoccurrenceKey, up tomaxCatchUp; 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 withrun.occurrenceKey); if it is stopped, it resumes from the first slot it did not run.
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.
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:
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’ssignalaborts and its commit is rejected (logged). Make handlers idempotent withrun.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 thebotId, 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
leaseMsfar 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
nextRunAtwins: 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
- Sub-APIs — groups, profile, chats, etc.
- Deployment — production setup