Delivery under multiple instances
Web and worker instances compete for the SAME result and decision queues (and, on queue and db, heartbeats) — each delivery lands on exactly one of them. What that means for steps with and without timeoutMs, how a heartbeat that lands on another instance still keeps a timeoutMs step alive, how a remote workflow turn's decision is applied by whichever replica takes it, and when to run a pure producer with consumers: 'never'.
Web and worker instances consume the SAME result queue, in competition, and on the queue and db transports the same heartbeat queue too (a beat is a job or a row that one instance takes). On bullmq, heartbeats travel over Redis pub/sub instead, so every instance hears every beat; results are still point-to-point. Each point-to-point delivery lands on exactly one instance:
- It landed on the dispatcher: the fast in-memory path — the result resolves (or the heartbeat re-arms the liveness timer) directly.
- It landed on another instance: that instance writes the checkpoint — the result (
completeRemoteResult), or the heartbeat'slastHeartbeatAt(at most one write per step perheartbeatPersistThrottleMs, default 10s — aWorkflowEngineconstructor option, not aconfig/durable.tsfield) — and, for a result, tries to resume the run, which fails while the dispatcher still holds the lease (the lease covers the whole execution). The dispatcher's in-memory timer doesn't hear it; it reads the checkpoint when the timer fires.
Practical rules:
- Steps WITHOUT
timeoutMssuspend durably: any instance can resume the run from the checkpoint. This is the dead-pod-proof mode (scale-down/crash/deploy). Combine it withremoteRedispatchMs+remoteRedispatchMaxagainst a lost dispatch, and withreconcileMs(default 5 min) against a lost wake. - Steps WITH
timeoutMsget fast dead-worker detection. When the window runs out, the dispatcher re-reads the checkpoint before failing: a completed step resolves with the recorded output; a step whose persistedlastHeartbeatAtis still withintimeoutMs(a beat consumed by another instance) re-arms for the rest of the window instead of failing over a live worker; only a step silent everywhere times out. - A beat consumed elsewhere costs at most one throttle window of extra detection. Another instance persists a step's beats at most once per
heartbeatPersistThrottleMs(default 10s), so the newest beat it heard can be up to that much newer than the one on the checkpoint. Once the dispatcher sees a persisted beat it did not hear itself, it allows that much extra silence before timing out — a live worker is never failed over, and a dead one is detected withintimeoutMsplus the throttle. A single instance (or a dispatcher that heard the beats itself) gets no allowance and detects a dead worker withintimeoutMs, as before. - Due timers come in batches: each timer pass loads at most
timerBatchSize(config option, default 500) due runs, most overdue first, and walks them in a shuffled order, so a recovery wave of thousands of due runs drains over a few ticks and the instances don't contend for the same leases row by row. - Processes that never execute steps should set
consumers: 'never'(a pure producer): no point-to-point delivery lands on them, so the race with the worker fleet is over.durable:workre-enables consumption for itself viaengine.startConsumers(). Thequeue,dbandbullmqtransports all defer their consumers this way.
Where a run executes
On bullmq, queue and db, runs are spread over the instances the same way steps are. These transports carry a run queue that every driving instance consumes (<P>-runs on bullmq, <prefix>:runs on queue, run rows of durable_transport_messages on db). Every run the engine would otherwise execute on the instance that triggered it goes through it, and whichever instance takes it from the queue executes the turn:
- a
start(including a schedule, an event trigger,signalWithStart, a dead-letter handler, anonErrorhandler) and the starts ofctx.child/ctx.allchildren; - the next run of a
continue-as-new, arequeue(dashboard retry), and a run that crash recovery puts back topending; - a wake: a delivered signal or event, a finished child waking its parent, a due timer, a freed singleton or flow-control slot.
There is nothing to configure: the engine picks this up from the transport, on every instance that drives runs (standalone, without drive: false). An instance with drive: false and a control plane never consume the run queue. Runs started in the same burst go out together: a fan's 40,000 child starts become a few dozen addBulk / pushManyOn / multi-row inserts, not 40,000 round trips. On bullmq, a start's job id is derived from its run id and the pending state it is for, so a start sent twice for the same state is queued once, while a run that went pending again (a retry, a recovery) gets a job of its own even if the previous one is still being processed.
What stays on the instance that already holds the run: a step result resumes the run on the instance that consumed the result, a turn's own re-check after it suspends, and an explicit engine.resume() or cancellation. Results are already spread over the instances by their own queue, and a second hop would add a poll interval (up to 200 ms, 800 ms after a quiet spell) to every step of a sequential workflow on queue and db.
On a run-queue transport, engine.signal() resolves once the wake is handed to the queue (status: 'suspended'), not after the resumed turn. Use waitForRun for the outcome. eventEmitter and memory have no run queue: runs execute on the instance that triggers them, as before.
Each namespace has its own run queue: an engine dispatches to, and consumes, its own namespace's queue, so a pool's runs stay on that pool's instances. An operator (an engine with no namespace) drives every pool's runs and uses the transport's own queue. A run's priority is carried along: on bullmq and queue the more urgent run is taken first; db takes the oldest first.
The queue is at-least-once and every delivery is checked against the store: a start whose run is no longer pending (another instance's pending-run poll took it first, or it already ran) is skipped, a wake for a finished run is skipped, and the run lease admits one executor either way.
- A failed enqueue loses nothing. A start that could not be enqueued stays
pending, and the next pending-run poll (durable:work, the embedded worker) runs it. A wake that could not be enqueued resumes the run on the instance that delivered it. The engine reports the failures at most once a minute. - A due timer is claimed before it is handed off. The timer poll that finds a due run moves its
wakeAtoneleaseMsahead under the run's lease, then enqueues a wake, so the other instances' polls do not hand it off again. A wake lost on the way comes due again afterleaseMs. - Shutdown.
drain()stops the instance's run-queue consumer first, then waits for the turns it is executing. A run taken from the queue during the drain is handed back to the queue. - Each instance bounds what it executes. An instance takes at most 64 runs from the run queue at once, and executes at most 256 in-process runs it dispatched to itself (no run queue) or child starts at once; the rest wait as run ids. A run id still waiting when the instance drains is dropped, not lost: the run is persisted, so the pending-run poll or the parent's next turn picks it up.
Wide fans and lost completions
A child persists its terminal state first and tells its parent after. A crash, a scale-down or a store error between the two leaves the parent waiting on a child that already finished. Three things make sure it is not stranded:
- The notification retries. A store error while delivering a child's completion is retried with backoff (250 ms, 1 s, 4 s), and
drain()waits for a delivery in progress. Every way a child can finish notifies: completed, failed, dead, and cancelled (bycancel(), a compensating cancel, or the execution-timeout sweep). - The parent's next turn reconciles. Every turn of a
ctx.allorctx.allSettledparent reads the children it still waits on in batches of 500 (StateStore.getRuns, oneIN (...)query per batch; onegetRunper child on a store without it), lists the fan's buffered completions once (listBufferedSignalTokens), and settles the children that already finished exactly as their notifications would have: a completed child as its output, a failed, cancelled or dead one as a failure (arejectedentry ofctx.allSettled, aGatherErrorofctx.all). The parent's next turn is itsreconcileMswake at the latest. A child settled this way is taggeddurable:child:deliveredfirst, so its notification, if it arrives later, is dropped instead of being buffered where nothing would read it. - A wake refused by a held lease is kept on the run. A wake that finds the run leased by another instance (its turn is executing) stamps
wakeRequestedAton the run row. The holder lets go of the lease only while the stamp is absent (one conditional write); with it, the holder keeps the lease and runs another turn, which sees what the wake wrote and clears the stamp. Asuspendedrun carrying the stamp is also due for the timer poll, so the request survives the refused instance and the holder both going down: any instance's poll resumes the run once the holder's lease runs out. The refused instance also keeps looking in memory while the lease is held, as the fast path when the holder goes down mid-turn; it stops once a turn served the request. A child that finishes while its parent's turn is still executing is covered the same way:ctx.child, likectx.all, writes the child's waiter and placeholder before it starts the child, so the child's completion is never written over.
A redelivered step result whose step already settled resumes a suspended run too (the instance that settled it may have died before resuming), and with remoteRedispatchMs set a run waiting on a dispatched step parks no later than reconcileMs, even when the redispatch deadline is an hour away.
Remote workflow turns
A remote workflow advances one turn at a time: the engine sends the run's history to a workflow worker, which replays the body and sends back a decision. The decisions queue (<P>-decisions on bullmq, <prefix>:decisions on queue) is point-to-point too, so with several engine replicas the replica that takes a decision is often not the one that dispatched the turn. Nothing waits for the decision in memory:
- Mark, release, then enqueue. Before enqueueing a turn, the engine mints a random, globally unique
taskId(two replicas re-driving the same run never mint the same one), records it on the run (awaitingDecisionTaskId), suspends the run and releases its lease. A decision that arrives a millisecond later, on any replica, finds both. - Any replica applies it. Every engine that registers a remote workflow consumes decisions. The replica that takes one leases the run, checks that it still awaits that exact
taskId, clears the marker in the same conditional write that claims the turn, and applies the decision — checkpoints, child starts, the settle. A decision for a turn the run no longer awaits (a duplicate redelivery, the late reply of a turn already re-driven) is dropped, so a decision applies at most once. - A wake does not re-dispatch over a turn in flight. A step result, a signal or a recheck that lands while a turn is out leaves the run alone; the turn's decision is applied as usual and its suspend rechecks what it waits on (
reconcileMsis the fallback). - A lost decision is re-driven, not failed. Set
remoteAdvanceSilenceMsinconfig/durable.ts(ortimeoutMson aRemoteWorkflowExecutor, per workflow; the shorter wins). A turn with no decision and no run-scoped heartbeat for that long is re-dispatched by the timer poller under a freshtaskId, with the same history; the worker's replay is deterministic. Without a window, a turn waits for its decision. - Heartbeats keep a live turn alive. A worker beats for the run (
{ runId, seq: 0, group }, nostepId) while it replays a turn: the Pythondurable-workerand the bundled TypeScript transports every 5 s. Each beat pushes the deadline forward, throttled to one write per half window, so a long turn is never re-driven while its worker is alive. Keep the window well above the beat interval.
waitForRun does not resolve on the suspend of a run awaiting a turn; it waits for the decision. An in-process executor that returns its decision directly (LocalWorkflowTurnExecutor) is awaited under the lease instead; with remoteAdvanceSilenceMs set, a silent one releases the lease and leaves the run to crash recovery rather than failing it.
The marker is a nullable column of durable_workflow_runs, awaiting_decision_task_id (and the refused-wake stamp is another, wake_requested_at). createDurableTables creates it, and the boot-time ensureSchema() adds it to an existing table (with the usual one-time repair warning).
Failure modes & recovery
An operator-facing map from symptom to knob — where a run actually executes (runDispatcher), what reclaims a worker that crashed mid-run, why a lost remote dispatch does NOT auto-redrive by design, the three nets that catch it (timeoutMs, remoteRedispatchMs, redispatchPending), the stalled-run pager (engine.onStalled + stalledAfter), queue-transport specifics, draining on deploy, namespaces, and how to reproduce each failure in a test.
Overview
How remote steps travel to workers. Transports are config-driven drivers selected by name in config/durable.ts — the in-process memory and eventEmitter drivers, the queue driver over @adonisjs/queue, a broker-less SQL driver on the database you already run, and the BullMQ driver shared with NestJS and Python workers.