Persisted schedules
engine.schedules — Temporal-style schedules created, updated, paused, resumed, triggered and deleted at runtime, persisted in the store (durable_schedules), fired by the durable:work worker tick when persistedSchedules is on, with cron + timezone or fixed intervals, jitter, overlap and catch-up policies. Safe with any number of workers.
The schedules config option and colocated static schedule (Scheduling) cover cadences you know at deploy time. Many apps need schedules that users create: a morning brief per person at their 9:00, an automation per tenant on a cron they typed, a weekly digest per team. Those belong in the database, next to the runs they start — that is engine.schedules.
Turning it on
import { defineConfig, stores } from '@adonis-agora/durable'
export default defineConfig({
store: 'lucid',
stores: { lucid: stores.lucid() },
// the worker tick fires due persisted schedules
persistedSchedules: true,
})With persistedSchedules on, every worker tick — node ace durable:work or the embedded worker — fires the due persisted schedules, right after the config schedules. It is off by default.
Schedules live in the durable_schedules table. The Lucid store creates it with the rest of its tables — at boot via autoSchema, or through createDurableTables in a migration when autoSchema is off (see Lucid store). The in-memory store keeps them in memory. The table is only read when persistedSchedules is on or when you call engine.schedules yourself, so a deployment that doesn't use it never touches it. A custom store must implement saveSchedule / getSchedule / updateSchedule / deleteSchedule / listSchedules; otherwise every engine.schedules call throws.
Managing schedules
Import the engine and use engine.schedules:
import engine from '@adonis-agora/durable/services/main'
import MorningBriefWorkflow from '#workflows/morning_brief_workflow'
await engine.schedules.upsert({
id: `brief:${user.id}`, // stable id — also part of every run id it starts
workflow: MorningBriefWorkflow, // class or registered name
input: { userId: user.id },
cron: '0 9 * * 1-5', // or `every: '15m'`
timezone: user.timezone, // IANA zone the cron is evaluated in
jitter: '2m', // spread thousands of 9:00 briefs over two minutes
overlap: 'skip', // don't start while yesterday's is still running
catchup: 'latest', // after an outage, fire the most recent missed window once
tags: [`tenant:${user.tenantId}`], // find schedules by tag; stamped on every run too
namespace: user.tenantId, // optional: partition of the schedule and its runs
})
await engine.schedules.pause(`brief:${user.id}`, 'user on vacation')
await engine.schedules.resume(`brief:${user.id}`) // continues from the next window
await engine.schedules.trigger(`brief:${user.id}`) // one run now, outside the cadence
await engine.schedules.list({ tag: `tenant:${tenantId}` })
await engine.schedules.get(`brief:${user.id}`) // next fire, last fire/run, fires, lastError
await engine.schedules.delete(`brief:${user.id}`) // its past runs are left alonecreatethrowsScheduleAlreadyExistsErroron a taken id;upsertcreates or replaces the definition while keeping history (last fire, last run, fire count) and — whenpausedis omitted — the current paused state.- Changing a definition recomputes the next fire from now, so an upsert never re-fires a window. An invalid cron or timezone is rejected up front, exactly one of
cron/everyis required, andeverymust be a positive duration. createandupsertreturn the resultingScheduleDescription, as dopauseandresume;triggerreturns the started run'sRunResult.pause,resumeandtriggerthrowScheduleNotFoundErroron an unknown id;getreturnsnullanddeletereturnsfalse.resumecontinues from the next window after now — windows skipped while paused are not caught up.triggerstarts one run right now, paused or not, with the run idsched:<id>:manual:<nowMs>; it doesn't move the schedule's next fire.listfilters bynamespace,workflow,tagandpaused(withlimit/offset) and returns the soonest-due first.- Every run carries the schedule's tags plus
schedule:<id>, its namespace, and the optionalsearchAttributes/priorityfrom the definition. A window's run id issched:<id>:<windowMs>. get/listdescribe a schedule with its definition plusnextFireAt(jitter included;nullwhen paused),lastFireAt,lastRunId,fires(manual triggers excluded),lastErrorand an optional free-formnote(set on the definition, or bypause(id, note)).
Policies
| Option | Values | Meaning |
|---|---|---|
cron / every | cron expression / duration ('15m' or ms) | Exactly one. Intervals are aligned to the epoch. Cron is 5 fields, or 6 with leading seconds, and needs the cron-parser peer. |
timezone | IANA zone, default UTC | Zone the cron is evaluated in (DST-aware). |
jitter | duration ('30s' or ms) | A stable per-(schedule, window) delay in [0, jitter) so schedules sharing a cron don't all start in the same second. The run id stays the nominal window. |
overlap | 'allow' (default) / 'skip' | 'skip' skips a window while the previous run of this schedule has not reached a terminal status (completed / failed / cancelled / dead). Unlike config schedules, this works for cron too. |
catchup | 'latest' (default) / 'skip' | Windows missed while nothing polled: fire the most recent one once, or drop them ('skip' still fires a window polled up to 60 s, plus jitter, after it came due). Never more than one catch-up run. |
paused | boolean | Create paused. On upsert, omit to keep the current state. |
namespace | string | Namespace of the schedule and of every run it starts. Defaults to the engine's own ('default' for an operator). |
tags | string[] | Find schedules by tag (list({ tag })); stamped on every run, plus schedule:<id>. |
searchAttributes / priority | as in StartOptions | Stamped on every run it starts. |
note | string | Free-form note shown by get / list (pause(id, note) overwrites it). |
concurrency | { key, limit, countStatuses? } | A concurrency quota applied to every run it starts; an over-limit window is skipped and recorded as lastError. |
A start that fails (unregistered workflow, input validation, a concurrency limit…) is recorded as the schedule's lastError and the schedule moves on to its next window; the next successful start clears it. A definition that stops evaluating (e.g. a timezone removed from the tz database) pauses the schedule with the reason in lastError.
Multi-worker safety
Any number of worker instances can run with persistedSchedules at once. Each tick is one indexed "due by now" query per instance (paused = false AND next_fire_at <= now, served by the durable_schedules_due_idx index), in batches of 100. For each due schedule:
- the window's run is started with its deterministic id — racing instances collapse into one run, because
startis idempotent by id; - the schedule is advanced with a compare-and-set on its
next_fire_at— exactly one instance moves it on; the others see the CAS fail and leave it.
A crash between 1 and 2 just re-fires the same (idempotent) start on the next tick. No locks, no leases, no leader election. A draining engine stops firing.
A tick fires the due schedules in the engine's own namespace; a control plane with no namespace (the operator) fires them for every namespace.
Driving it yourself
Outside the worker loop, call await engine.schedules.tick() from any poller; it fires every due schedule in the engine's namespace (all namespaces for an operator engine) and returns the run ids it started. It does not consult persistedSchedules — that flag only controls whether the worker tick calls it.
The dashboard's Schedules tab lists the config and colocated schedules (engine.listSchedules). Persisted schedules are managed through engine.schedules; their runs show up in the run list with the schedule:<id> tag.
Scheduling
Recurring workflows with ScheduledWorkflow — fixed intervals via everyMs or DST-aware cron via cron + timezone — registered under schedules in config/durable.ts and fired by the durable:work worker tick, started exactly once per window by an idempotent time-bucket run id, with runtime pause/resume/trigger control via engine.listSchedules, setSchedulePaused and triggerSchedule.
Event-triggered workflows
Start a workflow when an external event fires — an AdonisJS emitter event (`@OnEvent`) or a `@adonis-agora/diagnostics` channel (`@OnDiagnostic`) — with the event payload as the run input.