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.
A scheduled workflow is just a normal workflow that you start on a recurring cadence — a nightly report, an hourly sync, a cleanup every five minutes. You describe the cadence with a ScheduledWorkflow and register it under schedules in config/durable.ts; the durable:work worker loop then fires any due windows on every tick. The interesting part is the exactly-once guarantee: even with several worker instances racing on the same tick, each schedule window starts exactly once.
Schedules trigger by time. To trigger a workflow when an event happens instead — an Adonis emitter event or a @adonis-agora/diagnostics channel — see Event-triggered workflows.
Describing a schedule — ScheduledWorkflow
interface ScheduledWorkflow {
/** Stable key identifying this schedule — part of the deterministic run id. */
key: string
workflow: string
input?: unknown
/** Start one run every `everyMs`. Mutually exclusive with `cron`. */
everyMs?: number
/** A cron expression evaluated in `timezone`. Mutually exclusive with `everyMs`. */
cron?: string
/** IANA timezone the `cron` fires in (e.g. `America/Sao_Paulo`). Defaults to UTC. */
timezone?: string
/** Skip windows while paused. */
paused?: boolean
/** `'skip'` won't start a window while the previous run is still in-flight; `'allow'` (default) does. */
overlap?: 'allow' | 'skip'
/** Random pre-dispatch delay (ms), to spread a fleet's simultaneous fires. */
jitter?: number
/** Opt in to firing missed windows: at most `maxCatchup` of them. */
backfill?: { maxCatchup: number }
/** Pin the fired runs to a worker-pool namespace, overriding the ticking engine's own. */
namespace?: string
}Every schedule has a stable key, the workflow name to start, and an optional input. The cadence is either everyMs (a fixed interval) or cron + timezone (a calendar expression) — they are mutually exclusive.
Colocate the schedule with the workflow
A cadence is usually a property of the workflow, not of the deployment — and keeping it next to the class means renaming or deleting the workflow cannot leave an orphaned entry in a config file across the repo. Declare it as a static schedule on the class:
import { BaseWorkflow } from '@adonis-agora/durable'
import type { WorkflowCtx } from '@adonis-agora/durable'
export default class DailyReportWorkflow extends BaseWorkflow {
static workflow = { name: 'daily-report', version: '1' }
static schedule = { cron: '0 2 * * *', timezone: 'America/Sao_Paulo' }
async run(ctx: WorkflowCtx) {
const rows = await ctx.localStep('gather', () => reports.gatherYesterday())
await ctx.localStep('email', () => reports.email(rows))
return { rows: rows.length }
}
}make:workflow already scaffolds this line, commented out. Two fields are filled in for you, since the class already knows them: workflow is the registered name, and key defaults to that same name (or <name>:<i> when a class declares several). Everything else — everyMs, cron, timezone, paused, overlap, jitter, backfill, namespace, input — works exactly as above.
Declare several cadences by passing an array:
static schedule = [
{ key: 'sync-fast', everyMs: 60_000 },
{ key: 'sync-full', cron: '0 3 * * *', timezone: 'UTC' },
]The @Scheduled decorator
@Scheduled(...) is the decorator form of the same thing — reach for it when you prefer the cadence to read as an annotation above the class rather than as a field inside it:
import { BaseWorkflow, Scheduled } from '@adonis-agora/durable'
@Scheduled({ cron: '0 4 * * *', timezone: 'America/Sao_Paulo' })
export default class CrawlWorkflow extends BaseWorkflow {
static workflow = { name: 'crawl', version: '1' }
async run(ctx: WorkflowCtx) {
/* ... */
}
}It stamps the very same static schedule, so the two forms are interchangeable and can be combined — stacked decorators and a static schedule field all accumulate, in source order. A decorated class with no static workflow is not a registrable workflow, so its schedules are ignored.
Colocated schedules are picked up by app/workflows auto-discovery and merged with the config list below. Registration is idempotent by key: a duplicate key is ignored with a warning, and the first registration wins.
Registering schedules — config/durable.ts
Add a schedules array to your durable config. The durable:work worker loop reads it and fires any due windows on every tick — the 5th phase of the tick, right after it sweeps execution timeouts. No code to call yourself:
import { defineConfig, transports } from '@adonis-agora/durable'
export default defineConfig({
transport: 'memory',
transports: { memory: transports.memory() },
schedules: [
// Nightly report at 02:00 in São Paulo — DST-aware.
{ key: 'nightly-report', workflow: 'daily-report', cron: '0 2 * * *', timezone: 'America/Sao_Paulo' },
// Sync the cache every five minutes.
{ key: 'cache-sync', workflow: 'cache-sync', everyMs: 5 * 60 * 1000 },
],
})Then run the worker: node ace durable:work. The loop fires the schedules each tick on worker instances; the idempotent time-bucket run id (below) keeps each window to exactly one run no matter how many workers tick at once.
Config wins over a colocated schedule
The two sources are merged by key, and an entry in config/durable.ts wins. That is the deliberate override: the colocated cadence is the workflow's own default, and the config file is where a specific deployment overrules it — pausing a job in staging, or running a five-minute sync hourly on a small instance:
schedules: [
// The class says `cron: '0 2 * * *'`; this environment says "not at all".
{ key: 'daily-report', workflow: 'daily-report', cron: '0 2 * * *', paused: true },
]Match the key exactly — a config entry under a different key does not override, it adds a second schedule.
Schedules only fire from a worker loop — either the durable:work command or the embedded worker below. A dashboard-/dispatch-only instance that runs neither will not start them.
Running the loop in the web process
A dedicated durable:work pod is the right shape when workers scale independently of web traffic. It is the wrong shape when the app's entire background workload is one nightly sync: a second container idles 24h to fire a job that takes a minute.
For that case, run the loop inside the web process:
export default defineConfig({
worker: { embedded: true },
schedules: [
{ key: 'daily-report', workflow: 'daily-report', cron: '0 2 * * *', timezone: 'America/Sao_Paulo' },
],
})That is the whole change — one image, one process, one CMD. The loop is the same one the command drives, with the same five phases and the same exactly-once guarantee.
| Option | Default | |
|---|---|---|
embedded | false | Start the loop in this process |
intervalMs | 1000 | Poll interval between ticks |
drainTimeoutMs | 10_000 | Drain timeout applied on shutdown |
The loop starts only in the web environment. Without that gate, node ace migration:run would quietly become a worker, and node ace durable:work would run two loops in one process — the command's and the embedded one — ticking every phase twice. The gate is also what lets you keep embedded: true in config while still running dedicated worker pods: the command runs in the console environment, so no embedded loop competes with it.
Shutdown hangs off the provider, not a process signal handler: the web environment already turns SIGTERM into app.terminate(). The loop is stopped and awaited — draining in-flight executions — before the transport and control plane close, so a deploy hands off exactly as it does for a dedicated worker.
Every web instance that boots with embedded: true ticks. That is safe — the idempotent time-bucket run id keeps each window to exactly one run no matter how many instances race — but each one polls the store. On a fleet of any size, prefer dedicated workers and add jitter to spread the dispatch.
The referenced workflows are ordinary registered workflows:
engine.register('daily-report', '1', async (ctx) => {
const rows = await ctx.localStep('gather', () => reports.gatherYesterday())
await ctx.localStep('email', () => reports.email(rows))
return { rows: rows.length }
})1@Workflow({ name: 'daily-report', version: '1' })2export default class DailyReportWorkflow {3 constructor(private reports: ReportService) {}4 async run(ctx: WorkflowCtx) {5 const rows = await ctx.localStep('gather', () => this.reports.gatherYesterday())6 await ctx.localStep('email', () => this.reports.email(rows))7 return { rows: rows.length }8 }9}Fixed intervals — everyMs
everyMs starts one run every interval. The current window is identified by the time bucket floor(now / everyMs), which becomes part of the run id (sched:<key>:<bucket>). Every tick inside the same window resolves to the same run id, so re-firing the schedule within a window is a no-op.
{ key: 'cache-sync', workflow: 'cache-sync', everyMs: 5 * 60 * 1000 } // every 5 minutesUse a fixed interval when "roughly every N" is what you want and you don't care about wall-clock alignment to the calendar.
Cron — cron + timezone
For calendar-aligned schedules, use a cron expression evaluated in an IANA timezone. The expression is the standard 5 fields (m h dom mon dow), or 6 with a leading seconds field. Because it is evaluated in the named timezone, it is DST-aware — 0 2 * * * in America/Sao_Paulo fires at 02:00 local regardless of daylight-saving shifts, not at a fixed UTC offset.
{ key: 'nightly-report', workflow: 'daily-report', cron: '0 2 * * *', timezone: 'America/Sao_Paulo' }The window for a cron schedule is its most recent fire time at or before now — the deterministic "bucket" the run is keyed on (sched:<key>:<prevFireMs>). Polling repeatedly between two fires resolves to the same bucket, and thus the same run id, so the run for a given fire starts once.
Cron parsing uses cron-parser, an optional peer, version 5 or newer. Install it where you run the
engine: npm i cron-parser. An older major fails at load with a message naming the version it wants,
rather than being detected and driven with the wrong entry point.
Pausing and overlap
paused: trueskips a schedule entirely while it's set — handy for disabling a job without removing it.overlap: 'skip'won't start a new window while the previous run for that schedule is still in-flight (pending/running/suspended). The default,'allow', starts each window regardless. It applies to fixed-interval schedules only.
{ key: 'slow-sync', workflow: 'slow-sync', everyMs: 60_000, overlap: 'skip' }Runtime control — pause, resume, run now
paused in config is a deploy-time switch. For the operator's version — "stop the nightly sync now, without shipping a config change" — the engine exposes runtime schedule control, and the dashboard drives the same three calls from its Schedules tab:
// Every schedule this engine ticks, with its live control state and fire windows:
const schedules = await engine.listSchedules()
// [{ key, workflow, cron?, everyMs?, timezone?, overlap?, namespace?,
// paused, pausedAtRuntime, lastFireAt, nextFireAt, currentWindowRunId, lastRunStatus? }]
// Pause / resume at runtime — applied fleet-wide:
engine.setSchedulePaused('nightly-report', true) // stop firing windows
engine.setSchedulePaused('nightly-report', false) // resume
// Fire the CURRENT window right now (the console's "run now"):
const result = await engine.triggerSchedule('nightly-report')listSchedules()merges the config and colocated schedules and reports each one's effective state:pausedis the runtime override when one was issued, else the config'spaused, andpausedAtRuntimetells you which.lastFireAt/nextFireAtare the current window's bounds,currentWindowRunIdis its deterministic run id, andlastRunStatusis that run's status when it exists.setSchedulePaused(key, paused)is fleet-wide: it applies on this instance and broadcasts on the control plane, so every ticking peer applies it too. The override wins over the config'spaused— in either direction — but it is runtime-only state: a deploy resets every schedule to its config. Pinpausedin config for permanence. Returnsfalsefor an unknown key.triggerSchedule(key)starts the schedule's current window immediately. It is idempotent by the window's deterministic run id — triggering a window that already fired returns the existing run instead of forking a duplicate. Returnsnullfor an unknown key.
Because a runtime pause rides the control plane, a fleet without one (a single instance, or in-process transports) still pauses — just only on the instance you called. With several ticking workers, wire a controlPlane so a console pause reaches them all.
Spreading the fleet — jitter
Every worker ticks on its own clock, but a schedule's window is shared, so a fleet firing the same due window at the same instant hits downstream systems in one spike. jitter delays each dispatch by a random amount up to that many milliseconds:
{ key: 'tenant-sync', workflow: 'tenant-sync', everyMs: 60_000, jitter: 5_000 }The delay never changes the run id — the window still maps to the same time bucket, so the exactly-once guarantee is untouched. Off by default; a schedule with no jitter fires immediately.
Catching up after downtime — backfill
By default only the current window fires: if the worker fleet was down for six hours, an hourly schedule starts one run when it comes back, not six. That is usually right — six identical syncs racing on restart is rarely what anyone wanted.
When each window is genuinely distinct work (one report per day, one billing cycle per month), opt in to firing the missed ones, bounded so a long outage cannot stampede:
{
key: 'daily-report',
workflow: 'daily-report',
cron: '0 2 * * *',
timezone: 'America/Sao_Paulo',
backfill: { maxCatchup: 7 }, // at most a week of missed days
}Each backfilled window has its own time-bucket run id, so it is started exactly once just like a live window — and re-running the worker does not re-fire it.
Pinning the runs to a pool — namespace
A colocated static schedule is discovered by every durable:work process that loads the workflow, and a run is stamped with the namespace of the engine that fires it. When a workflow's steps can only be serviced by one pool — browser steps needing Chrome in a dedicated worker image, a GPU pool, a tenant partition — that default is a race you lose: a pool without the capability can win the tick and strand the window in a pool that can never run it (the run's remote steps land on queues nobody polls, and it dies in RemoteStepTimeout or a missing-binary error).
Pin the pool on the schedule and the fire becomes deterministic — whoever ticks, the run is stamped for (and thus polled and resumed only by) the named pool:
static schedule: WorkflowScheduleConfig = {
cron: '0 3 * * *',
timezone: 'America/Sao_Paulo',
namespace: 'bulas', // only the Chrome+Xvfb worker pool services these runs
}The pin only sets the run's namespace; it does not change the run id (still the window's time bucket), so the exactly-once guarantee across racing instances is untouched. Unset keeps the historical behavior — the run inherits the ticking engine's namespace.
Pin namespace to a pool you actually run a durable:work in. A pinned schedule whose pool is never started just leaves its windows pending forever — the same failure as a missing worker, now decoupled from which pool happened to win the tick.
Exactly-once across instances
The guarantee that makes scheduling safe in a multi-instance deployment is the idempotent time-bucket run id. Each window — a fixed-interval index, or a cron fire time — maps to a deterministic run id, and engine.start is idempotent by run id: starting the same id twice is a no-op. So when several worker instances tick at the same moment and all try to start the current window, they all compute the same run id and only the first actually creates the run.
The result for scheduling: each window starts exactly once, no matter how many worker instances are racing the tick.
Lower-level — runSchedules
The worker loop fires schedules for you, so registering them in config/durable.ts is all you normally need. If you drive ticks yourself (a custom loop, an external scheduler), call runSchedules(engine, schedules, nowMs) directly — it evaluates each schedule against nowMs, starts any due window, and returns the run ids it started:
import { WorkflowEngine, runSchedules, type ScheduledWorkflow } from '@adonis-agora/durable'
await runSchedules(engine, schedules, Date.now())Versioning & determinism
Keeping in-flight runs replay-safe across code changes — workflow versions for breaking changes, the NonDeterminismError guard, the deterministic ctx.now and ctx.sideEffect capture sources, and ctx.patched for guarding an in-place change without a new version.
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.