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, 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) | The run is created 'pending' and dispatched on a microtask on the calling instance | Whatever process called start — an HTTP handler included |
No-op (NOOP_RUN_DISPATCHER) | 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 NOOP_RUN_DISPATCHER exists: pass it to an API-only engine so start() never executes a body there, and run node ace durable:work (which drives runPending) as a separate worker process. 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 |
Remote step just... never fails or completes, run running the whole time, no dashboard pending step | timeoutMs step whose coordinating process crashed (the liveness timer died with it) | Run's lease is stale (lockedUntil passed) — same signature as any crashed leased run | recoverIncomplete() reclaims it like any other orphaned run; the step re-dispatches from scratch on replay |
/durable/api/health shows nothing for a pool you know is stalled | You're on the queue or db transport — groupHealth is BullMQ-only | 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 |
| Two pools sharing one Redis/DB seem to steal each other's runs | No namespace set — both 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 on boot and every durable:work tick) 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, including a timeoutMs remote step whose in-memory liveness timer died with the coordinating process (see below) — the run is still running/leased the whole time it awaits, so it's still reachable by the same lease-expiry path.
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 run just sitsrunninguntilrecoverIncompletereclaims it on lease expiry (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. Once apendingcheckpoint has sat pastremoteRedispatchMs, the next reconcile re-drive re-dispatches the samestepIdinstead of re-suspending, bumpingattemptseach time, bounded byremoteRedispatchMax(default 10) before it gives up and fails the step with aRemoteStepError(code: 'remote_step_lost'). Two requirements are non-negotiable: the window must exceed the longest legitimate step (or you double-run a slow-but-live worker), 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.
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, and a no-op when no listener is registered:
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.
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:
- Thrown handlers: a job whose handler threw is redelivered one poll interval later, so a transient failure inside a step handler does not need the engine's retry policy to notice it.
- 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) leaves its claim'sacquiredAtfrozen, and once it exceedsstalledThresholdMs(default 30min) the job is re-delivered. The threshold is deliberately generous because the claim is never renewed while a worker processes: a legitimately long step holds one claim for its whole duration, so a threshold below your longest step would double-run a merely-slow worker. TunestalledCheckIntervalMs(0disables the sweep),stalledThresholdMs(raise it above your longest step), 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) and reclaims a crashed worker's claimed-but-undeleted rows automatically. The queue transport's stalled-claim sweep is the same idea at a coarser grain (claim age, not a renewed lease) — and up to stalledThresholdMs 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 transport reports it. On queue, db, or eventEmitter it returns an empty list, not an error. Don't read "no stalled groups reported" as "nothing is stalled" on those transports — use the stranded-signature query above instead.
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 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. 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 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'.