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.
The other pages in this section explain each reliability primitive on its own — retries, sagas, flow control, the dead-letter queue. This page is the other direction: you're staring at a suspended run that isn't moving, and you need to know which failure that is and which knob heals it. Each entry names the symptom you can actually observe — a status, a checkpoint field, a dashboard panel — and the one configuration or command that resolves it.
Where a run actually executes
Before diagnosing anything, know which of the three runDispatcher modes your app runs — it changes who is on the hook when a run doesn't move:
| Dispatcher | What happens on engine.start() | Who runs the body |
|---|---|---|
In-process (default on eventEmitter, memory) | The run is created 'pending' and dispatched on a microtask on the calling instance | Whatever process called start — an HTTP handler included |
Run queue (default on bullmq, queue, db) | The run is created 'pending' and enqueued on the transport's run queue | Whichever driving instance takes it from the queue |
No-op (drive: false, runDispatcher: { dispatch: () => {} }, or NOOP_RUN_DISPATCHER; the control-plane role's default) | The run is created 'pending' and left there | Nothing, until a worker's engine.runPending() poll picks it up |
| Broker-backed | Custom RunDispatcher.dispatch(runId) hands the run id to a queue | Whatever consumes that queue |
The in-process default surprise. start() never blocks the caller — it dispatches on a microtask — but that microtask still runs on the same instance, on the same event loop, against the same DB connection pool as the HTTP request that called it. If that instance is your API/dashboard pod, a CPU-heavy step or a burst of concurrent runs competes with the traffic it's meant to be serving, and a saturated DB pool starves both the workflow and the request that started it. This is exactly why a no-op dispatcher exists: set drive: false (with consumers: 'never') on an API-only engine (the control-plane role uses one by default) so start() never executes a body there, and run node ace durable:work (which drives runPending whatever drive says, so both pods can share one config file) as a separate worker process. A resume the pod is asked for directly (an engine.signal or webhook it handles, a compensating cancel) still executes there, under the run's lease. See Durability & replay.
The matrix
| Symptom | Failure mode | Detection | Fix |
|---|---|---|---|
Run stuck running forever after a deploy/crash | Worker crashed mid-run (leased run) | recoveryAttempts climbing on the run; dashboard shows running with no progress | Nothing to do — recoverIncomplete() self-heals within leaseMs. Cap poison pills with maxRecoveryAttempts |
Run stuck suspended, one step frozen pending, nothing happens for hours | Lost remote dispatch (worker died after claiming the job, or the transport dropped it) | See the stranded signature below | engine.redispatchPending(runId) now; remoteRedispatchMs to self-heal future ones |
A timeoutMs step never times out, run running with a stale lease, then suspended | timeoutMs step whose coordinating process crashed (the liveness timer died with it) | Run's lease is stale (lockedUntil passed) and its step is pending | Nothing to do — recovery parks the run suspended and the reconcileMs wake re-dispatches the step. See below |
/durable/api/health shows nothing for a pool you know is stalled | You're on eventEmitter, or on queue with no worker-key store (workerKeys: false, or no database) — only bullmq, queue and db implement groupHealth | engine.workerHealth() returns [] | Use the stranded-signature query instead of the health panel |
| You learned about a stuck run from a customer, not an alert | Nothing pages on the stranded signature — suspended is not an error | Nothing, until you query for it | Wire engine.onStalled to your pager |
A deploy leaves runs running for ~leaseMs before another pod picks them up | The process was killed without draining (a SIGKILL, or a drain cut short by the orchestrator's grace period) | Runs reclaimed by recoverIncomplete() right after a rollout | Let the drain finish (see Deploys). With a stable, unique-per-process instanceId, the restarted process reclaims its own leftovers at once (see below) |
| Two pools sharing one Redis/DB seem to steal each other's runs | No namespace set — both standalone engines default to 'default' | A run started by pool A gets executed by a worker in pool B | Set a distinct namespace per pool (see below) |
Worker crashes mid-run (leased run)
Covered in depth by Durability & replay and the dead-letter queue — the short version, for this matrix: a live worker renews a recovery lease while it executes a run; a crashed worker stops renewing, so engine.recoverIncomplete() (called every tick of the worker loop — durable:work or the embedded one, starting with the first tick after boot) reclaims the orphaned run within ~leaseMs. Every reclaim increments the run's recoveryAttempts before resuming — that field is the trace of how many times this run has been picked back up. Set maxRecoveryAttempts in config/durable.ts so a genuine poison pill (a run that crashes the process every time it's resumed) moves to the terminal dead status instead of crash-looping the instance forever.
This net catches any crash while a run is actively executing. Recovery re-reads the run once it holds the lease and only acts on one that still reads running, so a run that settled after the orphan listing is left alone and not counted.
One shape is deliberately not counted as a crash: a run with a remote step still pending on the transport. Its work is with a worker that holds no run lease, so a free lease proves nothing. Recovery parks such a run suspended on the step's deadline (or the reconcileMs wake) instead, without touching recoveryAttempts, and the worker's result resumes it. A slow remote step therefore can't be dead-lettered while its worker is busy.
A timeoutMs step whose coordinator crashed
A timeoutMs step is awaited in memory by the process that dispatched it, and its liveness timer is a setTimeout there. It still writes a pending checkpoint before it dispatches. If the coordinating process dies, the run reads running with a stale lease, and recovery finds that pending step — so it parks the run suspended on the reconcileMs wake (default 5 minutes) rather than counting a recovery attempt. When the wake comes due, the replay reaches the step and dispatches it again from its first attempt (the step is idempotent by its stepId). If the worker finished in the meantime, its result completes the checkpoint and resumes the run first. With reconcileMs: 0 there is no wake, and the run strands until the stalled-run pager flags it as wakeForever or someone runs engine.redispatchPending(runId).
A remote workflow's run found this way (running, free lease, a call still pending) is different: it reads running only while a turn is being computed or applied, so its turn was lost — an inline advance hit remoteAdvanceSilenceMs, or the replica applying a decision died. Recovery parks it on a wake about a second out instead of the reconcileMs window, and the timer pass then dispatches a fresh turn with the same history. The call already in flight is not dispatched again: its pending checkpoint dedupes it, and its result resumes the run as usual.
Lost remote dispatch: the run suspends and nothing auto-redrives it
This is the failure mode that actually costs an on-call engineer a night: a dispatched ctx.step (no timeoutMs) persists a pending checkpoint and suspends the run durably — it is not held in memory. If the worker that claimed the job then crashes before producing a result, or the transport drops the job outright (a Redis eviction, a broker moving a stalled job to failed and removing it), that checkpoint just sits pending forever. The run's status reads suspended, not failed — there's no error to alert on.
By design, this does not self-heal by default. The run does periodically wake — a suspend with no natural timer gets a fallback wakeAt (reconcileMs, default 5 minutes) so it isn't literally invisible — but the reconcile re-drive replays straight back into the same guard, finds the step still pending, and re-suspends rather than re-dispatching. That's deliberate: the engine cannot tell "the worker died" apart from "the worker is just slow," and re-dispatching on every 5-minute reconcile would double-run a slow-but-live worker's step. So by default a lost dispatch cycles quietly through suspended → reconcile → suspended forever, never erroring, never progressing.
There are exactly three nets, in order of preference:
- Per-step
timeoutMs(+retries/backoff). Opts the step out of the durable-suspend path entirely into an in-memory await with heartbeat-rearmed liveness: if the worker produces neither a result nor a heartbeat withintimeoutMs, the engine presumes it dead and retries. The catch: the timer issetTimeoutinside the coordinating process — if that process (not the worker) dies, the timer dies with it, and the step is only re-dispatched once recovery has parked the run and itsreconcileMswake comes due (see above). Reach for this only when you genuinely need to detect and replace a stuck worker. remoteRedispatchMs/remoteRedispatchMax(engine config). The store-driven net for a no-timeoutMsstep. Unset by default — opt in explicitly. When set, a dispatched remote step'spendingcheckpoint carries a lease in itswakeAt: the run suspends on that deadline, and only a lapsed lease re-dispatches the samestepIdinstead of re-suspending, bumpingattemptseach time, bounded byremoteRedispatchMax(default 10) before it gives up and fails the step with aRemoteStepError(stepError.code: 'remote_step_lost'). A worker that is still holding the step renews the lease with its heartbeats (throttled to one write per half-window, so a hot beat loop is not anUPDATEstorm), so a long-but-live step is not re-dispatched just for outliving the window. A step fan writes its pending checkpoints with the lease already stamped and the run parks on it, and the instance that sent the fan answers its steps' pickup beats from memory until the half-window, so a fan of thousands of steps costs no extra checkpoint reads or writes on pickup. A replay re-reads apendingstep from the store before stamping or re-driving it, so a result that landed after the replay took its checkpoint snapshot is replayed, never overwritten by the stalependingrow. This covers both paths a remote step reaches the broker by — a nativectx.stepand a polyglot workflow'scallfan-out (ctx.gather_calls) — and a re-drive is marked as such: thestep.startedevent carriesredispatched: trueand the checkpoint's own event trail gets awarnstep.redispatched(step.lostat the bound), so it reads differently from a failure retry. Two requirements are still non-negotiable: the window must exceed the longest legitimate step (or you double-run a slow-but-live worker that isn't beating), and steps must be idempotent (re-dispatch can genuinely double-execute a step whose original job merely arrives late).engine.redispatchPending(runId)— the operator escape hatch. Re-enqueues everypendingremote checkpoint on a run right now, regardless of how long they've been sitting. It's explicitly documented as safe to call on a healthy run — it only touches checkpoints alreadypending, and a dispatched step is idempotent by its stablestepId(runId:seq) by contract, so a redundant re-dispatch just produces a duplicate (dedupable) attempt rather than corrupting state. This is what you reach for at 2am on a run you've confirmed is stuck, without waiting forremoteRedispatchMsto be configured or to elapse.
The stranded signature
Query for this pattern to confirm "lost dispatch" before reaching for redispatchPending:
- The run's
statusis'suspended'and itsupdatedAtis stale (older than a few reconcile cycles). - Its most recent checkpoint has
kind: 'remote',status: 'pending', and anenqueuedAtfar older than the step should ever take. attemptson that checkpoint is1(never re-dispatched) ifremoteRedispatchMsis unset, or climbing but stillpendingif it is set and still underremoteRedispatchMax.- The checkpoint's
wakeAtisNULLwhenremoteRedispatchMsis unset (nothing is leasing the step); with it set, aNULLwakeAton apendingremote checkpoint means the engine has not seen this step since it was dispatched, and a lease in the past means the re-drive is due on the next timer tick.
That combination — suspended run, one pending remote checkpoint, old enqueuedAt, stale updatedAt — is the signature. A run that's merely waiting on a slow-but-live worker looks identical for a while, which is exactly why the engine won't auto-redispatch it; the operator's judgment call ("this has been pending for way longer than the step ever legitimately takes") is what redispatchPending exists to encode manually. You don't have to run this query by hand, though — the stalled-run pager below watches for exactly this signature and pages you.
The stalled-run pager — onStalled + stalledAfter
engine.onStalled(listener) turns the stranded signature into an alert instead of a query you remember to run at 2am. Register a listener (exactly like engine.onDead) and the worker tick sweeps for stranded in-flight runs — self-throttled to one pass a minute, skipped while the engine drains, and a no-op when no listener is registered:
import engine from '@adonis-agora/durable/services/main'
engine.onStalled(({ run, ageMs, wakeForever, stalePending }) => {
pager.alert(`durable run ${run.id} (${run.workflow}) stalled for ${Math.round(ageMs / 60_000)}min`, {
wakeForever, // suspended with NO wake timer — nothing will ever re-drive it
stalePending, // the oldest silent pending remote step, when that's the cause
})
})A run is paged when it has sat untouched past stalledAfter (config, duration string or ms, default '15m') and matches one of the two stranded shapes — disambiguated from healthy long work:
wakeForever—suspendedwith no wake timer at all, so nothing will ever re-drive it (thereconcileMs: 0hazard).stalePending— an oldpendingremote step whose worker heartbeat has gone silent past the threshold; a long step that is still heartbeating is not stalled, and neither is a longctx.sleep(itswakeAtis in the future).
Each stranded episode pages once: a run you were already paged about is skipped until it makes progress and strands again, so a wedged run doesn't re-fire your pager every minute. The listener receives a StalledRunInfo — the run, its ageMs since last activity, the wakeForever flag, and the stalePending detail (seq, name, attempts, ageMs, heartbeatAgeMs) when a silent remote step is the cause. A listener that throws never breaks the sweep, and onStalled returns an unsubscribe function.
Where it runs matters:
- Only in the worker loop. The sweep is a phase of
durable:work(or the embedded loop), so register the listener in a preload the worker processes load. A listener on a web-only process never fires. - Per instance. Each worker sweeps, and remembers what it already paged, on its own (in memory). With several workers holding a listener, one stranded episode pages once per worker, and a restart forgets what was paged. Register it on one process, or de-duplicate in the pager by
run.id+run.updatedAt. - Bounded and namespaced. Each pass looks at up to 100
running/suspendedruns untouched for longer thanstalledAfter, in the engine's ownnamespace.
export default defineConfig({
stalledAfter: '30m', // page only after 30 minutes of silence — above your longest legitimate step
})Set stalledAfter above your longest legitimate remote step, for the same reason --stale and remoteRedispatchMs need headroom: a slow-but-healthy step under the threshold is exactly the case the engine refuses to guess about.
Queue-transport specifics
The queue driver (transports.queue(...)) rides an @adonisjs/queue adapter. Two recovery mechanisms live in the transport itself, below the engine:
- Failed job processing: a step handler that throws does not fail the job — the worker reports a failed result and the engine's retry policy decides. What the transport redelivers (one poll interval later) is a job whose processing threw: the result could not be pushed, or an engine-side result or decision consumer hit a store error.
- Stalled claims: a coarse background sweep (default every 30s) calls
adapter.recoverStalledJobsfor every queue this instance pops from — a worker that crashes silently mid-job (no throw, just gone) stops renewing its claim, and once the claim has gone unrenewed forstalledThresholdMs(default 2min on an adapter that can renew claims — every@adonisjs/queue0.6/0.7 driver; 30min on a custom adapter withoutrenewJobs, whose claims are never renewed) the job is re-delivered. A live worker renews every claim it holds each half threshold, so a long step is never mistaken for a dead one — only a worker that is gone, or whose event loop is blocked past the threshold. A step job reclaimed more thanmaxStalledCounttimes is failed and, on@adonisjs/queue0.7, bridged to a retryable failed step result, so the engine's retry policy (then the workflow's error path) takes over instead of the run waiting forever (0.6 fails and removes the job itself, with no result bridged). TunestalledCheckIntervalMs(0disables the sweep),stalledThresholdMs(raise it above the longest event-loop block), andmaxStalledCount(default 3 — bounds a poison job) ontransports.queue(...); see the queue transport. Adapters that don't implementrecoverStalledJobsare detected and skipped — on those, a silently-dead worker's job is simply lost, andengine.redispatchPending(runId)is the fix, since the checkpoint (not the queue job) is the source of truth the engine acts on.
For contrast, the db transport claims rows with a leaseMs (default 30s), renews every claim a live worker holds, and reclaims a crashed worker's rows once their claims lapse; a row reclaimed more than maxStalledCount times (default 3) is failed into a retryable step failure, like the queue transport's poison jobs. The queue transport's stalled-claim sweep is the same idea — a renewed claim, re-delivered once it goes silent — with up to stalledThresholdMs plus one sweep interval of latency. For a tighter or transport-independent net, remoteRedispatchMs at the engine level acts on the checkpoint regardless of which transport lost the job.
engine.workerHealth() (and the dashboard's /durable/api/workers and /durable/api/health) reads group health off the transport, and only the bullmq, queue and db transports report it (queue only with a worker-key store, its default when the app has a database). On eventEmitter it returns an empty list, not an error. Don't read "no stalled groups reported" as "nothing is stalled" there — use the stranded-signature query above instead. On queue, a group's depth counts only pending jobs, not jobs in flight (see worker health); on db it counts pending and claimed task rows (see worker health).
Deploys: drain before you exit
A process killed mid-run leaves its runs to lease expiry: nothing is lost, but each one waits ~leaseMs (default 30s) before another instance's recovery picks it up. There is one exception. A process that restarts under the same stable instanceId (the pod name) reclaims the runs still leased to its id on its first recovery passes after boot. It never takes a run it is executing itself, and other instances still wait for expiry. That only holds if no two live processes share an id. Draining avoids the wait altogether:
durable:worktrapsSIGINT/SIGTERM, stops leasing new work, waits for the tick in flight, then callsengine.drain().--drain-timeout(default 10 000 ms) is the budget for that whole sequence, the tick wait included.- The embedded loop (
worker: { embedded: true }) drains the same way on app shutdown, bounded byworker.drainTimeoutMs(default 10 000). - Every other store-backed process — a web process whose in-process dispatcher may be mid-turn, a
drive: falsepod, a control plane — is drained by the provider'sshutdown()before the transport closes, bounded bydrainTimeoutMs(default 10 000;worker.drainTimeoutMswhen set). See Drain on deploy.
drain(timeoutMs) stops recovery, timer and pending pickups and waits for in-flight executions — including continue-as-new links and deferred children — to settle. It never cancels anything. When the budget runs out, it releases the leases this process still holds, so the next instance's recoverIncomplete() replays those runs immediately instead of after leaseMs. A hot continue-as-new loop keeps the drain busy for its whole budget, so size the timeout for your longest in-flight turn, and keep it below your orchestrator's kill grace period (Kubernetes' terminationGracePeriodSeconds, 30s by default).
Namespaces: the classic two-pools mistake
namespace (set on the engine in config/durable.ts) partitions a deployment: it's stamped on every run the engine creates, scopes runPending / recoverIncomplete / resumeDueTimers / sweepTimeouts to that namespace, and (for the queue/eventEmitter transports) folds into every queue/channel name. Two standalone engines that both leave namespace unset both default to 'default' — if they share one state store or one broker, they are, by construction, the same pool: pool B's worker can lease and execute a run pool A created, because nothing distinguishes them.
That's the classic mistake: standing up a second, supposedly-isolated worker pool (a second environment, a second tenant) against the same Postgres/Redis without giving it its own namespace. The symptom isn't an error — it's runs quietly executing on the "wrong" pool, or (calling resume directly rather than through the poll paths) a NamespaceMismatch thrown for a run that belongs to another pool. The fix is a one-line config change, not a topology change: set a distinct namespace per pool. (Only a control-plane with no namespace drives every pool; a standalone engine never does — it is in 'default' when unset.) See Namespaces — sharing one broker across pools for the full mechanics and the exact queue-name scheme.
Testing failure modes
The two in-process transports behave differently on an unregistered handler, which matters when you're writing a test for any of the above:
InMemoryTransport(@adonis-agora/durable/testing, the default increateTestEngine()) drivesdispatchstraight intorunStepHandler. With no handler registered for the step name,runStepHandlerreturns immediately withstatus: 'failed',error: { message: 'no handler for <name>', retryable: false }— the step fails, synchronously and non-retryably. Good for testing "the worker doesn't have this handler."EventEmitterTransport(the production in-process transport,transports.eventEmitter()) is closer to a real broker: dispatch emits an event, and if nothing is listening for that step name, nothing answers and no result is ever emitted. The step'spendingcheckpoint sits there exactly like a lost broker job. Use this transport in a test to reproduce the "lost remote dispatch" failure mode above (e.g. to exerciseremoteRedispatchMsorredispatchPendingagainst a step that will never complete on its own), by never registering a handler for the step you want to strand.
A registered handler behaves identically on both — the difference is only what happens when nothing answers.
Retention & archival
Hard-delete terminal runs past a per-status age with the retention config, swept by the durable:work tick (throttled to one pass a minute), and archive each run before deletion with engine.onEvict — a throwing hook skips the delete, so a broken archive never loses data.
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'.