Overview
How remote steps travel to workers. Transports are config-driven drivers selected by name in config/durable.ts — from the in-process memory driver for zero-infra single-process handlers, to the queue driver over @adonisjs/queue, and a broker-less SQL driver that rides the database you already run.
A transport is how a dispatched ctx.step reaches a step handler and how the result comes back. It's a pluggable driver (dispatch, onResult, onHeartbeat), independent of the state store — mix any transport with any store.
Transports are config-driven drivers: list the ones you use under transports in config/durable.ts, built with the transports factory, and pick the active one by name with transport. Each driver lazily imports its optional peer (@adonisjs/queue, @adonisjs/lucid) only when selected, so nothing extra is loaded for the drivers you don't use.
The drivers
| Driver | Infrastructure | Crosses a process boundary? | Use it for |
|---|---|---|---|
transports.memory() | none | No (in-process) | Tests. Drives a dispatch straight into the handler, so a test is deterministic with no polling and no timers. |
transports.eventEmitter() | none | No (in-process) | Single-process production. Handlers run in this process over a Node EventEmitter, but dispatch → worker → result is decoupled across the event loop, the way a real broker behaves. |
transports.queue(...) | whatever config/queue.ts uses | Yes | True cross-process steps over @adonisjs/queue — a separate worker fleet, on Redis or a database adapter. |
transports.db(...) | the database you already run | Yes (shared DB) | Broker-less, DBOS-style: steps are rows in your own database, via @adonisjs/lucid. No new infrastructure at all. |
transports.bullmq(...) | Redis + the bullmq package | Yes | Cross-ecosystem: byte-compatible with the NestJS nestjs-durable BullMQ transport and its Python worker, so an AdonisJS engine and a Python or NestJS worker interoperate on one Redis. See Python interop. |
memory is for tests; eventEmitter is the single-process production driver
The two look interchangeable and are not. transports.memory() executes the handler inline in the dispatch call, which is exactly what makes a test deterministic — and exactly what makes it a poor model of production, since a real dispatch is always asynchronous. transports.eventEmitter() delivers the result on a later tick, so a workflow that behaves correctly on it behaves correctly on a broker too. Reach for eventEmitter for any single-process app you actually deploy.
Selecting a transport in config
transport names a key of the transports map; that driver is what the engine dispatches every remote step over. Omit transport (or pick memory) for the in-process default.
import { defineConfig, transports } from '@adonis-agora/durable'
export default defineConfig({
transport: 'queue',
transports: {
memory: transports.memory(),
// `connection` names an adapter from config/queue.ts; `db` uses a Lucid connection.
queue: transports.queue({ connection: 'redis' }),
db: transports.db(),
},
})Switching transports is a one-line change to transport — the workflow code never changes.
Single-process production — eventEmitter
transports.eventEmitter() runs real durable workflows with nothing else to deploy: no broker, no Redis, no extra database. Step handlers under app/steps are served in this same process, and a dispatch travels to them over a Node EventEmitter:
export default defineConfig({
transport: 'event-emitter',
transports: {
'event-emitter': transports.eventEmitter(),
},
store: 'lucid',
stores: { lucid: stores.lucid() },
})| Option | Default | Description |
|---|---|---|
group | — | Accepted for parity with the broker drivers. Handlers match by step name in-process, so it does not affect routing. |
instanceId | random | Stable id for this process, stamped on control messages. |
Pair it with the Lucid store and the durability guarantees are the real ones: a crash mid-run resumes from the last checkpoint, because the checkpoints are in Postgres even though the steps never left the process.
What you do not get is a second process. It also doubles as a local control plane, broadcasting to every subscriber in this process — correct for a single instance, but it does not fan out across pods. The moment you run two replicas, move to queue or db and add a control plane.
In-process for tests — memory
transports.memory() is the default when config/durable.ts sets no transport, and it is what createTestEngine() uses. It routes a dispatch straight into the handler registered for that name, so a test never waits on a poll interval.
1@Workflow({ name: 'process-doc', version: '1' })2export default class ProcessDocumentWorkflow {3 constructor(4 private intake: IntakeSteps,5 private render: RenderSteps,6 ) {}7 8 async run(ctx: WorkflowCtx, doc: Document) {9 const clean = await ctx.step(this.intake.validate, doc)10 const pdf = await ctx.step(this.render.toPdf, clean)11 const summary = await ctx.step<Summary>('python:enrich', pdf)12 return { pdf, summary }13 }14}Pinning a transport per step (failover pools)
At the engine level a single instance can dispatch over an ordered pool of named transports and fail over between them; a step pins one with ctx.step(step, input, { transport: id }):
// force this dispatch onto the 'db' transport:
const result = await ctx.step(heavyStep, input, { transport: 'db' })The id of the transport that accepted the dispatch is stamped on the task as task.transport, so a worker consuming several transports replies on the matching one — failover is symmetric.
Control plane (separate from the transport)
The transport is point-to-point: it carries a task to one worker and a result back. But some things every engine instance needs regardless of who runs a given run — a dashboard-only process must live-tail a run executing on a worker, and the process actually running a run must learn it was cancelled elsewhere. That cross-instance fan-out is a second channel, the controlPlane, modelled separately from the task transport:
import { controlPlanes } from '@adonis-agora/durable'
export default defineConfig({
transport: 'queue',
transports,
controlPlane: controlPlanes.redis({ connection: 'main' }),
})Omit it and the engine is local-only: events and cancellation reach subscribers on this instance but don't fan out to other processes (fine for a single-instance app). Supply one and lifecycle events broadcast to every process and a cancel reaches the process running the run. See Control plane for the controlPlanes.redis driver (multi-replica, NestJS-interoperable).
Wire protocol
Every transport carries the same JSON RemoteTask (dispatch) and StepResult (reply). The task also carries an optional transport (the pool id it was dispatched on) and an optional traceparent (see distributed tracing).
A worker process is the durable:work command plus the step handlers it serves (from app/steps, discovered automatically). Run one engine-side process, which consumes results, and one or more worker processes.
Note that a node ace console or REPL process deliberately does not consume from the broker — see consumers.
Delivery under multiple instances
Web and worker instances compete for the SAME result and heartbeat queues — each delivery lands on exactly one of them. What that means for steps with and without timeoutMs, why a persisted heartbeat doesn't re-arm a timer, and when to run a pure producer with consumers: 'never'.
Queue (@adonisjs/queue)
The queue-backed transport driver for cross-process steps, built on @adonisjs/queue. Steps go to a per-group tasks queue; results return on a shared results queue. Run one instance engine-side, one per worker group.