Control plane
The cross-instance broadcast channel for lifecycle events and cancellation — separate from the point-to-point task transport. Omit it and the engine is local-only; pick controlPlanes.redis to fan out across every replica over Redis pub/sub, interoperable with a NestJS fleet.
The task transport is point-to-point: it carries one task to one worker and a result back. The control plane is a separate, broadcast channel across every engine instance (every pod). It carries what each instance may need regardless of who runs a given run:
- Lifecycle events — so a dashboard-only pod can live-tail a run executing on a worker pod.
- Cancellation — so the pod actually running a run learns it was cancelled elsewhere.
- Enqueue nudges — so a worker picks up a freshly enqueued run now instead of on the next poll.
- Schedule pause/resume — so a schedule paused or resumed at runtime (from the dashboard or the API) stops or restarts ticking on every instance. This is runtime state: a deploy resets it to the config's
paused.
This is purely out-of-band signalling. It carries no replay/determinism weight — the deterministic engine never depends on it; the engine dedupes its own broadcasts by the originating instanceId, so a publish that Redis echoes back to its own subscriber is ignored.
Local-only by default
Omit controlPlane and the engine is local-only: events and cancellation reach subscribers on this instance but don't fan out to other processes. That's exactly right for a single-instance app — no extra infrastructure.
import { defineConfig, transports } from '@adonis-agora/durable'
// No controlPlane → single-instance, local-only events + cancellation.
export default defineConfig({ transport: 'queue', transports: { /* ... */ } })Multi-replica: controlPlanes.redis
Running more than one replica? Without a control plane, a cancel issued on the API pod never reaches the worker pod running the run, and a dashboard pod can't live-tail runs executing elsewhere. Pick the Redis driver to fan both out over Redis pub/sub:
import { defineConfig, transports, controlPlanes } from '@adonis-agora/durable'
export default defineConfig({
transport: 'queue',
transports: { queue: transports.queue({ connection: 'redis' }) },
// Fan out lifecycle events + cancellation across every replica.
controlPlane: controlPlanes.redis({ connection: 'main' }),
})connection names a connection from config/redis.ts (defaults to 'main'). Like the other drivers, @adonisjs/redis is imported lazily — only when this driver is selected — so it stays an optional peer. The subscription rides that connection's own subscriber, which @adonisjs/redis manages and closes with the app.
| Option | Default | Meaning |
|---|---|---|
connection | 'main' | config/redis.ts connection used for pub/sub. |
prefix | 'durable' | Channel is `${prefix}-control`. The engine's namespace is not folded in. |
Every instance that should hear each other must use the same channel, so give every engine (and every NestJS or Python process on the same Redis) the same prefix. The engine drops a message whose from is its own instanceId, so its own publishes echoed back by Redis are ignored.
Namespaces share the channel
The bullmq transport puts an engine's namespace into every queue name (durable-<namespace>-…), but the control channel stays `${prefix}-control` whatever the namespace. Engines of different namespaces on one Redis therefore share it unless you give each its own prefix. A Python or NestJS process pointed at the namespaced prefix (durable-<namespace>) listens on durable-<namespace>-control, so for cancellation to reach it, set prefix: 'durable-<namespace>' here too. See Python interop.
Interoperates with a NestJS fleet. The channel (`${prefix}-control`) and the JSON payload match the NestJS BullMQ transport's control plane, so an AdonisJS fleet and a NestJS fleet sharing one Redis fan out across both runtimes on the same channel.
Passing an instance directly
controlPlane also accepts a ready ControlPlane instance instead of a factory — useful for tests or a bespoke broker. Any object with publishControl(msg) and onControl(handler) works, as long as every instance receives every message. None of the shipped broker transports qualifies: bullmq has no control plane, and queue and db implement the two methods over a single-consumer queue or table, so each message reaches one instance only.
import { RedisControlPlane } from '@adonis-agora/durable'
import { Redis } from 'ioredis'
const controlPlane = new RedisControlPlane({ connection: new Redis(), prefix: 'durable' })
export default defineConfig({ controlPlane })RedisControlPlane accepts either a raw ioredis instance (it duplicates a dedicated subscriber connection) or an @adonisjs/redis connection (which manages its own subscriber); it detects which by feature. The two carry the same messages on the same channel and differ only in who owns the subscriber connection, which only changes which connection the health check below pings.
| Constructor option | Default | Meaning |
|---|---|---|
connection | — (required) | A raw ioredis client or an @adonisjs/redis connection. |
prefix | 'durable' | Channel is `${prefix}-control`. |
pingIntervalMs | 30_000 | How often the watchdog pings the subscriber connection (both forms; controlPlanes.redis({ pingIntervalMs }) too). 0 or false disables it. |
Subscriber health
A pub/sub subscriber connection never writes on its own, so when a VPN, NAT gateway or idle timeout drops it silently, it stays "subscribed" while Redis has already dropped the subscription, and cancellations and live-tail events stop arriving.
- Raw
ioredisform. The control planePINGs its dedicated subscriber everypingIntervalMs. A ping that fails, or takes longer than the smaller of 5 s and the interval, disconnects it so ioredis reconnects and resubscribes. The first subscriber error of each reconnect burst is logged withconsole.warn. This is the same watchdog thebullmqtransport runs. controlPlanes.redis()(the@adonisjs/redisform). The same watchdog pings the connection's own subscriber (ioSubscriberConnection) everypingIntervalMsand disconnects it on a failed or slow ping so it reconnects and resubscribes. The app-owned connection itself is never closed by the control plane.
SQL (database)
A broker-less, DBOS-style transport driver — remote steps are rows in the Lucid database you already run. Workers claim tasks with an atomic, portable lease so a row is never run twice. The migration ships with @adonis-agora/durable.
Overview
Where durable state lives. A config-driven StateStore with a Lucid driver that runs on Postgres, MySQL, or SQLite, an in-memory store for tests, and a migration shipped with @adonis-agora/durable.