Reliability
How @adonis-agora/durable keeps long-running work correct in the face of transient failures, crashes and overload — step retries, saga compensation, flow control, concurrency quotas, singleton workflows, onError routing, the dead-letter queue, retention and multi-instance delivery.
A durable workflow's whole reason to exist is that the world it talks to is unreliable: APIs time out, workers crash mid-step, downstream systems rate-limit you, and the occasional run is a poison pill that takes the process down with it. The engine treats each of those as a first-class concern rather than something you hand-roll on top.
A worker crashing mid-run is handled automatically by self-healing recovery: while a run executes its worker renews a recovery lease, and a crashed worker stops renewing, so its lease expires. Recovery (engine.recoverIncomplete()) runs periodically: the worker loop — durable:work, or the embedded loop you get with worker: { embedded: true } — calls it every tick, so an orphaned running run is reclaimed by another instance within ~leaseMs (default 30s), not only on the next deploy. A run whose remote step is still in flight is not an orphan: recovery parks it suspended and lets the worker's result resume it. See Durability & replay.
The building blocks compose: a remote step can retry with backoff, register a compensation to undo itself, be admitted through a rate-limited queue, and — if recovery can never make it past a crash — land in the dead-letter state for a human or a handler to deal with.
The building blocks
Retries & backoff
Local-step retries with fixed/exponential backoff and jitter, FatalError and retryable:false to opt out, the durable remote-step retry path, the timeoutMs liveness path, and recovering a lost dispatch.
Sagas & compensation
Undo the side effects of a partially-completed run with per-step compensate callbacks that run in reverse.
Flow control
Durable queues that cap concurrency and enforce fixed- or sliding-window rate limits, with priority and fairness — per-instance, or fleet-wide with Redis admission.
Concurrency quotas
A start-time quota across workflows that rejects an over-limit start with ConcurrencyLimitError before anything is created.
Singleton workflows
A durable FIFO mutex per key — at most limit runs in flight, the rest wait suspended — with a maxQueueDepth back-pressure cap.
Routing a run's failure — onError
Route a failed run to a handler workflow by error code, and settle the run with the handler's outcome.
Dead-letter queue
Cap crash-recovery with maxRecoveryAttempts so a poison pill moves to the terminal dead status instead of crash-looping.
Retention & archival
Hard-delete terminal runs past a per-status age, and archive each one first with engine.onEvict — a throwing hook skips the delete.
Failure modes & recovery
An operator's symptom → diagnosis → fix map: a stuck suspended run, a lost remote dispatch, the stalled-run pager, transport-level reclaim, draining on deploy, and the namespace mistake.
Delivery under multiple instances
Web and worker instances compete for the same result and decision queues — what that means for timeoutMs steps and remote workflow turns, and when to run a producer with consumers set to never.
At a glance
- Retries — both
ctx.step(dispatched) andctx.localStep(in-process) retry on failure. A local step retries the in-process function; a dispatched step has a durable retry path (a failed dispatch re-dispatches by suspending the run on a persistedwakeAt, so retries survive a crash) plus atimeoutMsheartbeat-liveness path for presumed-dead workers. Throw aFatalError(or report aretryable: falseerror from the worker) to opt a deterministic business failure out of retrying. A dispatch that is lost outright is re-driven byengine.redispatchPending(runId)or the opt-inremoteRedispatchMsself-heal. - Sagas — attach a
compensateclosure to actx.localStep, or acompensatestep ref to a dispatchedctx.step; the engine runs registered undos in reverse order when the run fails.compensationRetriesretries a transient undo, every compensation surfaces as acompensate:<step>event so a stranded undo is visible, and you can trigger the saga deliberately withengine.cancel(runId, { compensate: true }). - Flow control — register a queue with
engine.registerQueueand reference it fromctx.step(step, input, { queue }). A call that can't be admitted re-suspends with a retry time, and the timer poller retries admission when it comes due, so the limit survives a crash.priorityandfairnessKeyon the call decide who gets the next contended slot. For a global cross-process cap, use the@adonis-agora/durable/admission-redisbackend. - Concurrency quotas —
concurrency(on the workflow config,engine.register, or per start viaStartOptions.concurrency) caps how many runs sharing a key are in flight across workflows; an over-limit start is rejected withConcurrencyLimitErrorbefore anything is created. - Singleton workflows —
singleton: { key, limit?, maxQueueDepth? }serializes runs of one workflow that share a key: excess runs wait suspended and are admitted in creation order, andmaxQueueDepthrejects a start withSingletonQueueFullErroronce the backlog is full. - onError routing —
onError: [{ code, workflow }]hands a run that failed with a matching errorcodeto a handler workflow, and the run settles with the handler's outcome, so a fan-out over it keeps going when one item fails. - Dead-lettering —
maxRecoveryAttemptscaps how many times crash-recovery retries a run before moving it to the terminaldeadstatus, where it stays inspectable and retriable from the dashboard.engine.onDeadroutes the dead run to a handler. - Retention — the
retentionconfig hard-deletes terminal runs past a per-status age, swept by the worker tick;engine.onEvictarchives each run (with its checkpoints) before deletion, and a throwing hook skips the delete.engine.purgeRuns(scope)deletes a scope on demand. - The stalled-run pager —
engine.onStalled+stalledAfterturn the stranded signature into a page instead of a query you remember to run: once per stranded episode, a listener receives the run and why it looks stuck. - Multiple instances — web and worker processes compete for the same result queue (and, on
queue/db, heartbeat queue), so each delivery lands on exactly one of them. Steps withouttimeoutMsresume on whichever instance gets the result; a pure producer (on thequeue,dborbullmqtransport) can opt out of the race withconsumers: 'never'. - Deploys — on
SIGTERMthe worker loop stops picking up work andengine.drain()waits for in-flight executions, bounded by one shutdown budget (--drain-timeout, default 10s); leases still held when it runs out are released so another instance replays those runs immediately. See Drain on deploy.
Events
Name-based pub/sub between your app and its workflows. engine.publishEvent(name, payload) resumes every run parked on a matching ctx.waitForEvent and starts every onEvent subscriber, with idempotent publishing, debounce/batch coalescing for bursty sources, and buffered delivery when nobody is listening yet.
Retries & backoff
In-process localStep retries with fixed/exponential backoff and jitter, FatalError to opt out, the durable dispatched-step retry path (re-dispatch on a persisted wakeAt), retryable:false worker verdicts, retrying a run after its budget is spent, the in-memory timeoutMs + heartbeat liveness path, and the lost-dispatch nets.