@adonis-agora/durable 0.25.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,260 @@
1
+ ---
2
+ name: durable-determinism
3
+ description: >-
4
+ Keep @adonis-agora/durable runs replay-safe: the determinism rule (no
5
+ Date.now, Math.random, new Date, crypto.randomUUID in a workflow body), the
6
+ checkpointed ctx.now() and ctx.sideEffect() capture sources,
7
+ NonDeterminismError at replay time, workflow versions for breaking shape
8
+ changes (skew protection), ctx.patched for surgical in-place guards, and the
9
+ @adonis-agora/durable-eslint-plugin no-nondeterminism rule with its
10
+ recommended preset. Use when authoring or changing workflow code that has
11
+ runs in flight, fixing NonDeterminismError, or wiring determinism linting.
12
+ license: MIT
13
+ metadata:
14
+ type: core
15
+ library: "@adonis-agora/durable"
16
+ library_version: "0.25.0"
17
+ framework: adonisjs
18
+ sources:
19
+ - 'DavideCarvalho/adonis-durable:docs/concepts/durability.mdx'
20
+ - 'DavideCarvalho/adonis-durable:docs/authoring/versioning.mdx'
21
+ - 'DavideCarvalho/adonis-durable:docs/tooling/linting.mdx'
22
+ ---
23
+
24
+ # Determinism, replay & versioning
25
+
26
+ Durability is **checkpoint + deterministic replay**: each step records its
27
+ result at a deterministic position (`seq`); recovery re-runs the body from the
28
+ top and completed steps return their saved output instead of executing. The one
29
+ rule: the workflow body must take the same path every time it runs — all
30
+ non-determinism (network, queries, clocks, randomness) lives inside steps or
31
+ the context's capture primitives.
32
+
33
+ ## Setup
34
+
35
+ Use the deterministic sources instead of raw clocks/entropy — each captures its
36
+ value once and replays it verbatim:
37
+
38
+ ```ts
39
+ engine.register('invoice', '1', async (ctx, order: Order) => {
40
+ const issuedAt = await ctx.now() // epoch ms — captured once, replayed verbatim
41
+ const nonce = await ctx.sideEffect(() => crypto.randomUUID()) // any generated value
42
+ const sampled = (await ctx.sideEffect(() => Math.random())) < 0.1
43
+
44
+ await ctx.localStep('issue', () => issue(order, { issuedAt, nonce, sampled }))
45
+ })
46
+ ```
47
+
48
+ `ctx.now()` is the deterministic wall clock; `ctx.sideEffect(fn)` runs `fn` once
49
+ and replays the recorded result without re-running it — wrap UUIDs, randomness,
50
+ env reads, anything non-deterministic you control. Raw `Date.now()` /
51
+ `crypto.randomUUID()` belong only inside step bodies, whose whole result is
52
+ checkpointed.
53
+
54
+ ## Core patterns
55
+
56
+ ### Pattern 1 — catch drift at author time with the ESLint plugin
57
+
58
+ `@adonis-agora/durable-eslint-plugin` flags `Date.now()`, `performance.now()`,
59
+ `new Date()` (no args), `Math.random()` and `crypto.randomUUID()` inside a
60
+ workflow body — AST-scoped to the function form (`engine.register(...)`) and the
61
+ class form (`BaseWorkflow.run` / any class with `static workflow`). It stops at
62
+ `ctx.localStep(...)` / `ctx.task(...)` / `ctx.sideEffect(...)` callback
63
+ boundaries, where such calls are fine.
64
+
65
+ ```bash
66
+ npm i -D @adonis-agora/durable-eslint-plugin
67
+ ```
68
+
69
+ ```js title="eslint.config.js"
70
+ import durable from '@adonis-agora/durable-eslint-plugin'
71
+
72
+ export default [
73
+ {
74
+ files: ['**/*.ts'],
75
+ plugins: { '@adonis-agora/durable': durable },
76
+ rules: {
77
+ '@adonis-agora/durable/no-nondeterminism': 'error',
78
+ },
79
+ },
80
+ ]
81
+
82
+ // or simply:
83
+ // export default [durable.configs.recommended]
84
+ ```
85
+
86
+ Each report names its replacement ("use `ctx.now()`"). The plugin is the cheap
87
+ early layer; `NonDeterminismError` is the replay-time backstop for calls it
88
+ cannot see (transitive helpers). Source: `docs/tooling/linting.mdx`.
89
+
90
+ ### Pattern 2 — breaking changes: register versions side by side
91
+
92
+ A run records the code version it started on (`workflowVersion`) and replays on
93
+ that version. For a structural change (new/removed/reordered steps, different
94
+ control flow), bump the version and drain the old:
95
+
96
+ ```ts
97
+ engine.register('checkout', '1', async (ctx, order: Order) => {
98
+ /* original body — keep registered until its in-flight runs drain */
99
+ })
100
+
101
+ engine.register('checkout', '2', async (ctx, order: Order) => {
102
+ /* new body */
103
+ })
104
+ ```
105
+
106
+ In-flight v1 runs finish on v1; new starts pick v2. A run whose version is no
107
+ longer registered fails loudly rather than corrupting. Remove v1 only after
108
+ every run that started under it reached a terminal state.
109
+ Source: `docs/authoring/versioning.mdx`, `docs/concepts/durability.mdx`.
110
+
111
+ ### Pattern 3 — surgical changes: `ctx.patched(id)`
112
+
113
+ For a small in-place change on a live workflow, guard it instead of shipping a
114
+ whole second body:
115
+
116
+ ```ts
117
+ engine.register('checkout', '1', async (ctx, order: Order) => {
118
+ const quote = await ctx.localStep('quote', () => price(order))
119
+
120
+ if (await ctx.patched('add-fraud-check')) {
121
+ // Runs started AFTER this code shipped take this branch...
122
+ const risk = await ctx.step(scoreFraud, { orderId: order.id })
123
+ if (risk.score > 0.9) throw new FatalError('high fraud risk', 'fraud')
124
+ }
125
+ // ...runs already recorded under the old code rewind past the marker here.
126
+
127
+ await ctx.step(chargeCard, { orderId: order.id, amountCents: quote.total })
128
+ })
129
+ ```
130
+
131
+ The marker is position-transparent: old runs replay straight into the real step
132
+ they recorded; fresh runs record `patch:add-fraud-check` and take the new branch.
133
+ Once everything old drained, delete the guard and keep the new branch.
134
+ Source: `docs/authoring/versioning.mdx`.
135
+
136
+ ### Pattern 4 — what triggers the runtime guard
137
+
138
+ Replay pairs each position with its recorded checkpoint by name. If the name at
139
+ a position changed — an insert, removal, reorder — the engine throws rather than
140
+ silently mis-pairing:
141
+
142
+ ```text
143
+ NonDeterminismError(runId, seq)
144
+ ```
145
+
146
+ It tells you a code change is incompatible with runs that started under the old
147
+ code — fix with versions (Pattern 2) or patches (Pattern 3).
148
+ Source: `docs/authoring/versioning.mdx`.
149
+
150
+ ## Common mistakes
151
+
152
+ ### CRITICAL reading clocks or randomness in the orchestration prefix
153
+
154
+ Wrong:
155
+
156
+ ```ts
157
+ const startedAt = Date.now()
158
+ const idempotencyKey = crypto.randomUUID()
159
+ const jitter = Math.random()
160
+ const at = new Date()
161
+ ```
162
+
163
+ Correct:
164
+
165
+ ```ts
166
+ const startedAt = await ctx.now()
167
+ const idempotencyKey = await ctx.sideEffect(() => crypto.randomUUID())
168
+ const jitter = await ctx.sideEffect(() => Math.random())
169
+ const at = new Date(await ctx.now())
170
+ ```
171
+
172
+ Mechanism: each banned call returns a different value on every replay, so the
173
+ replayed path diverges from the recorded one and silently corrupts the run until
174
+ `NonDeterminismError` fires far from the offending line. Source:
175
+ `docs/tooling/linting.mdx`, `docs/concepts/durability.mdx`.
176
+
177
+ ### HIGH reshaping a live workflow without a version bump
178
+
179
+ Wrong:
180
+
181
+ ```ts
182
+ // v1 was [quote, charge]; edited in place to [quote, fraudCheck, charge]
183
+ static workflow = { name: 'checkout', version: '1' }
184
+ ```
185
+
186
+ Correct:
187
+
188
+ ```ts
189
+ static workflow = { name: 'checkout', version: '2' } // alongside v1 until drained
190
+ ```
191
+
192
+ Mechanism: replay is positional — inserting/reordering steps while runs are in
193
+ flight pairs old checkpoints with new positions and throws
194
+ `NonDeterminismError`; the version split is exactly the skew protection for it.
195
+ Source: `docs/authoring/versioning.mdx`.
196
+
197
+ ### HIGH calling injected services directly from `run`
198
+
199
+ Wrong:
200
+
201
+ ```ts
202
+ async run(ctx, input) {
203
+ const receipt = await this.billing.charge(input) // unchecked I/O, re-runs on replay
204
+ }
205
+ ```
206
+
207
+ Correct:
208
+
209
+ ```ts
210
+ async run(ctx, input) {
211
+ const receipt = await ctx.step(this.billing.charge, input)
212
+ }
213
+ ```
214
+
215
+ Mechanism: constructor DI resolves services outside the checkpoint system;
216
+ calling them from the body performs I/O that re-executes on every replay and is
217
+ never checkpointed. Source: `docs/authoring/app-workflows.mdx` (warning).
218
+
219
+ ### MEDIUM adding `timeoutMs` to a webhook wait as an unversioned edit
220
+
221
+ Wrong:
222
+
223
+ ```ts
224
+ const r = await hook.wait({ timeoutMs: 1_800_000 }) // edited onto a live workflow
225
+ ```
226
+
227
+ Correct:
228
+
229
+ ```ts
230
+ // Ship under version+1, or gate with ctx.patched('add-wait-deadline').
231
+ const r = await hook.wait({ timeoutMs: 1_800_000 })
232
+ ```
233
+
234
+ Mechanism: a deadline claims an extra logical position, so toggling it shifts
235
+ positions for in-flight runs — the docs call it "a versioned change" explicitly.
236
+ Source: `docs/authoring/webhooks.mdx`.
237
+
238
+ ### MEDIUM reading env/config directly in the body
239
+
240
+ Wrong:
241
+
242
+ ```ts
243
+ const region = env.get('REGION') // may differ between the original run and a replay
244
+ if (region === 'eu') await ctx.step(gdprStep, input)
245
+ ```
246
+
247
+ Correct:
248
+
249
+ ```ts
250
+ const region = await ctx.sideEffect(() => env.get('REGION'))
251
+ ```
252
+
253
+ Mechanism: an env value changed between the first execution and a replay makes
254
+ the body branch differently, diverging from recorded history; captured via
255
+ `sideEffect`, the value is fixed once. Source: `docs/authoring/versioning.mdx`
256
+ ("a config/env read" belongs behind `ctx.sideEffect`).
257
+
258
+ See also: `durable-workflows` — the authoring surface these rules constrain.
259
+ See also: `durable-reliability` — poison-pill runs and the DLQ path when
260
+ NonDeterminismError keeps crashing recovery.
@@ -0,0 +1,352 @@
1
+ ---
2
+ name: durable-reliability
3
+ description: >-
4
+ Recover and harden @adonis-agora/durable runs: step retries with fixed/exp
5
+ backoff and jitter, FatalError and worker retryable:false, timeoutMs liveness
6
+ vs the durable suspend path, pickupTimeoutMs, log.heartbeat, saga
7
+ compensation (compensate callbacks/refs, UndoOf, compensationRetries,
8
+ cancel({compensate:true})), flow-control queues (registerQueue, concurrency,
9
+ rateLimit, fairnessKey) with Redis-backed global admission (admission-redis),
10
+ the dead-letter queue (maxRecoveryAttempts, onDead, retryWithInput), the
11
+ stuck-run playbook (stranded signature, redispatchPending,
12
+ remoteRedispatchMs), ace ops commands (durable:work/runs/retry --stale), and
13
+ the testing harness (createTestEngine, tick, failOnce, conformance kits).
14
+ license: MIT
15
+ metadata:
16
+ type: core
17
+ library: "@adonis-agora/durable"
18
+ library_version: "0.25.0"
19
+ framework: adonisjs
20
+ sources:
21
+ - 'DavideCarvalho/adonis-durable:docs/reliability/retries.mdx'
22
+ - 'DavideCarvalho/adonis-durable:docs/reliability/sagas.mdx'
23
+ - 'DavideCarvalho/adonis-durable:docs/reliability/dead-letter.mdx'
24
+ - 'DavideCarvalho/adonis-durable:docs/reliability/flow-control.mdx'
25
+ - 'DavideCarvalho/adonis-durable:docs/reliability/failure-modes.mdx'
26
+ - 'DavideCarvalho/adonis-durable:docs/cli.mdx'
27
+ - 'DavideCarvalho/adonis-durable:docs/testing.mdx'
28
+ ---
29
+
30
+ # Reliability & operations
31
+
32
+ A run's world is unreliable: APIs time out, workers crash, downstream systems
33
+ rate-limit. The engine treats retries, compensation, admission control and
34
+ dead-lettering as first-class primitives — and gives operators a symptom → fix
35
+ map for the runs that still get stuck.
36
+
37
+ ## Setup
38
+
39
+ Retries are declared per step; `FatalError` opts a deterministic business
40
+ failure out of the retry path:
41
+
42
+ ```ts title="app/steps/charge.ts"
43
+ import { defineStep } from '@adonis-agora/durable'
44
+
45
+ export const chargeCard = defineStep(
46
+ 'payments:charge-card',
47
+ async (input: { orderId: number; amountCents: number }) => {
48
+ const res = await stripe.charge(input)
49
+ if (res.declined) {
50
+ // deterministic verdict — don't make the engine retry it
51
+ throw Object.assign(new Error('card declined'), { code: 'declined', retryable: false })
52
+ }
53
+ return { chargeId: res.id }
54
+ },
55
+ { retries: 4, backoff: 'exp', backoffMs: 500, backoffMaxMs: 30_000, jitter: true },
56
+ )
57
+ ```
58
+
59
+ A dispatched step WITHOUT `timeoutMs` takes the durable path: failure → persist
60
+ `wakeAt = now + backoff(attempt)` on the checkpoint → suspend → the timer poller
61
+ re-dispatches. Crash-safe, never held in memory.
62
+
63
+ ## Core patterns
64
+
65
+ ### Pattern 1 — sagas: undo completed steps in reverse
66
+
67
+ Attach a `compensate` to each side-effecting step; when the run fails, undos run
68
+ last-completed-first:
69
+
70
+ ```ts
71
+ engine.register('checkout', '1', async (ctx, order: Order) => {
72
+ await ctx.localStep('reserve-inventory', () => inventory.reserve(order.items), {
73
+ compensate: () => inventory.release(order.items),
74
+ })
75
+
76
+ await ctx.localStep(
77
+ 'charge-card',
78
+ () => payments.charge(order.customerId, order.totalCents),
79
+ { compensate: () => payments.refundOnce(order.customerId, order.totalCents) },
80
+ )
81
+
82
+ await ctx.localStep('allocate-shipment', () => shipping.allocate(order))
83
+ return { ok: true }
84
+ })
85
+ ```
86
+
87
+ The saga is reconstructed from history on replay, so it works after a crash.
88
+ Dispatched steps compensate with a **step ref** receiving an `UndoOf<...>`
89
+ envelope (`{ input, output }`). Engine-level `compensationRetries` (default 1)
90
+ bounds transient undo failures; failing compensations are skipped so they can't
91
+ mask the original error. Trigger deliberately with
92
+ `await engine.cancel(runId, { compensate: true })` or
93
+ `engine.cancelWhere(filter, { compensate: true })`. Every undo surfaces as a
94
+ visible `compensate:<step>` event. Source: `docs/reliability/sagas.mdx`.
95
+
96
+ ### Pattern 2 — flow-control queues + fleet-wide admission
97
+
98
+ Cap concurrency/rate durably — a blocked call re-suspends with a persisted
99
+ `retryAt`, driven by the timer poller (nothing waits in memory):
100
+
101
+ ```ts title="start/durable.ts"
102
+ engine.registerQueue({ name: 'emails', concurrency: 10, rateLimit: { limit: 1000, periodMs: 3_600_000 } })
103
+ ```
104
+
105
+ ```ts
106
+ const sent = await ctx.step(sendEmail, input, { queue: 'emails', priority: 10, fairnessKey: order.tenantId })
107
+ ```
108
+
109
+ Accounting is per engine instance by default. For a cap that holds across every
110
+ pod, select the Redis admission backend (ships in the main package):
111
+
112
+ ```bash
113
+ npm i @adonisjs/redis
114
+ ```
115
+
116
+ ```ts title="config/durable.ts"
117
+ import { admissions, defineConfig } from '@adonis-agora/durable'
118
+
119
+ export default defineConfig({
120
+ // …
121
+ admission: admissions.redis({ connection: 'main' }),
122
+ })
123
+ ```
124
+
125
+ Each admit is one atomic Lua script (priority desc → fairness round-robin →
126
+ FIFO/LIFO); a crashed pod's slots free within `instanceTtlMs`.
127
+ Source: `docs/reliability/flow-control.mdx`.
128
+
129
+ ### Pattern 3 — dead-letter poison pills
130
+
131
+ Crash-recovery reclaims orphaned runs within ~`leaseMs`; a run that crashes the
132
+ process every time would loop forever. Cap it and route the terminal `dead`
133
+ status into a handler workflow (idempotent by the `dlq:<runId>` start id):
134
+
135
+ ```ts title="config/durable.ts"
136
+ export default defineConfig({
137
+ // …
138
+ maxRecoveryAttempts: 5, // then dead-letter instead of crash-looping
139
+ })
140
+ ```
141
+
142
+ ```ts title="start/durable.ts"
143
+ engine.onDead((run) => {
144
+ void engine.start(
145
+ 'dlq',
146
+ { deadRunId: run.id, workflow: run.workflow, input: run.input, error: run.error },
147
+ `dlq:${run.id}`,
148
+ )
149
+ })
150
+ ```
151
+
152
+ A dead run stays inspectable and retriable (`durable:retry <runId>`, dashboard
153
+ retry, or `engine.retryWithInput(runId, fixedInput)` for bad data).
154
+ Source: `docs/reliability/dead-letter.mdx`.
155
+
156
+ ### Pattern 4 — the stuck-run playbook
157
+
158
+ A lost remote dispatch does **not** self-heal by default: the run cycles
159
+ `suspended → reconcile → suspended` forever with no error. The stranded
160
+ signature: status `suspended`, stale `updatedAt`, newest checkpoint
161
+ `kind: 'remote' / status: 'pending'`, old `enqueuedAt`. Find it, then heal it:
162
+
163
+ ```bash
164
+ node ace durable:runs --stale=1h # threshold above your longest legitimate step
165
+ node ace durable:retry checkout:1234
166
+ ```
167
+
168
+ ```ts title="app/controllers/ops_controller.ts"
169
+ import { inject } from '@adonisjs/core'
170
+ import {
171
+ DEFAULT_STALE_MS,
172
+ WorkflowEngine,
173
+ attachLiveness,
174
+ filterStale,
175
+ listRuns,
176
+ } from '@adonis-agora/durable'
177
+
178
+ @inject()
179
+ export default class OpsController {
180
+ constructor(private engine: WorkflowEngine) {}
181
+
182
+ async strandedRuns({ response }: HttpContext) {
183
+ const runs = await listRuns(this.engine, { statuses: ['running', 'suspended'], limit: 200 })
184
+ const stale = filterStale(await attachLiveness(this.engine, runs), DEFAULT_STALE_MS)
185
+ return response.ok({ stranded: stale.map(({ run }) => run.id) })
186
+ }
187
+ }
188
+ ```
189
+
190
+ Three nets, in preference order:
191
+ 1. Per-step `timeoutMs` (+ heartbeat-aware liveness; split queue wait with
192
+ `pickupTimeoutMs`) — but its timer dies with the coordinating process.
193
+ 2. Engine-level `remoteRedispatchMs` / `remoteRedispatchMax` (default bound 10;
194
+ fails the step with `RemoteStepError` code `'remote_step_lost'` beyond it) —
195
+ must exceed the longest legitimate step; steps must be idempotent.
196
+ 3. `engine.redispatchPending(runId)` — the operator escape hatch, safe on a
197
+ healthy run (only touches already-pending checkpoints).
198
+
199
+ Source: `docs/reliability/failure-modes.mdx`, `docs/cli.mdx`.
200
+
201
+ ### Pattern 5 — test workflows without infrastructure
202
+
203
+ ```ts
204
+ import { assertRunStatus, createTestEngine, failOnce } from '@adonis-agora/durable/testing'
205
+
206
+ test('checkout completes', async () => {
207
+ const t = createTestEngine() // in-memory store + transport + controllable clock
208
+ t.engine.register('checkout', '1', async (ctx) => {
209
+ await ctx.localStep('charge', failOnce({ ok: true }), { retries: 3 })
210
+ await ctx.sleep('7 days')
211
+ return { done: true }
212
+ })
213
+
214
+ await t.run('checkout', {}, 'run1') // enqueues AND waits for settle
215
+ await t.tick(7 * 24 * 60 * 60 * 1000) // advance the clock past the sleep
216
+ await assertRunStatus(t.store, 'run1', 'completed')
217
+ })
218
+ ```
219
+
220
+ Custom adapters prove themselves against the shipped contracts:
221
+ `runStateStoreContract(name, factory)` and
222
+ `runAdmissionBackendContract(name, factory)` live on
223
+ `@adonis-agora/durable/testing/conformance` (they generate vitest suites);
224
+ `assertTransportConformance(transport)` stays on `/testing` as a plain async
225
+ call. Source: `docs/testing.mdx`.
226
+
227
+ ## Common mistakes
228
+
229
+ ### CRITICAL assuming a lost remote dispatch self-heals
230
+
231
+ Wrong:
232
+
233
+ ```ts
234
+ // Deploy and hope: the run stays suspended, its remote step pending forever.
235
+ ```
236
+
237
+ Correct:
238
+
239
+ ```ts
240
+ export default defineConfig({
241
+ remoteRedispatchMs: 30 * 60_000, // opt in explicitly
242
+ remoteRedispatchMax: 10,
243
+ })
244
+ // or per-step timeoutMs — or engine.redispatchPending(runId) right now
245
+ ```
246
+
247
+ Mechanism: the periodic reconcile replays straight back into the "still pending"
248
+ guard and re-suspends rather than re-dispatching, because the engine cannot tell
249
+ a dead worker from a slow one. The docs state this is deliberate ("by design").
250
+ Source: `docs/reliability/failure-modes.mdx`.
251
+
252
+ ### HIGH tuning detection windows below the slowest real step
253
+
254
+ Wrong:
255
+
256
+ ```ts
257
+ export default defineConfig({ remoteRedispatchMs: 60_000 }) // imports legitimately take ~20min
258
+ ```
259
+
260
+ Correct:
261
+
262
+ ```ts
263
+ export default defineConfig({ remoteRedispatchMs: 30 * 60_000, remoteRedispatchMax: 10 })
264
+ ```
265
+
266
+ Mechanism: once a pending checkpoint ages past the window the SAME `stepId` is
267
+ re-dispatched — a slow-but-live worker's step double-executes. Both requirements
268
+ are non-negotiable in the docs: window > longest legitimate step, and idempotent
269
+ steps. Source: `docs/reliability/failure-modes.mdx`.
270
+
271
+ ### HIGH inferring in-flight runs from `run.started − run.completed`
272
+
273
+ Wrong:
274
+
275
+ ```ts
276
+ const inFlight = counts['run.started'] - counts['run.completed'] // wildly overcounts
277
+ ```
278
+
279
+ Correct:
280
+
281
+ ```ts
282
+ const inFlight = counts['run.started'] - (counts['run.completed'] + counts['run.failed'])
283
+ // or read the store: listRuns({ status: ['running', 'suspended'] })
284
+ ```
285
+
286
+ Mechanism: `run.suspended` fires on EVERY park — each sleep, signal wait, child
287
+ wait — and suspended runs have not completed; counting starts minus completions
288
+ treats every parked run as finished-or-inflight incorrectly either way. The docs
289
+ warn: "Never count run.started against run.completed to infer 'runs in flight'".
290
+ Source: `docs/observability/events-and-interceptors.mdx`.
291
+
292
+ ### MEDIUM reading an empty `workerHealth()` as a healthy fleet
293
+
294
+ Wrong:
295
+
296
+ ```ts
297
+ const stalled = (await engine.workerHealth()).some((g) => g.stalled) // always false on queue/db
298
+ ```
299
+
300
+ Correct:
301
+
302
+ ```ts
303
+ const stale = filterStale(
304
+ await attachLiveness(engine, await listRuns(engine, { statuses: ['running', 'suspended'], limit: 200 })),
305
+ DEFAULT_STALE_MS,
306
+ )
307
+ ```
308
+
309
+ Mechanism: group health is BullMQ-only — on `queue`, `db` or `eventEmitter`,
310
+ `workerHealth()` returns `[]`, not an error, so "no stalled groups reported" says
311
+ nothing. Use the stranded-signature query instead. Source:
312
+ `docs/reliability/failure-modes.mdx`.
313
+
314
+ ### MEDIUM writing non-idempotent saga compensations
315
+
316
+ Wrong:
317
+
318
+ ```ts
319
+ { compensate: () => payments.refund(order.customerId, order.totalCents) } // refunds again on retry
320
+ ```
321
+
322
+ Correct:
323
+
324
+ ```ts
325
+ { compensate: () => payments.refundOnce(order.customerId, order.totalCents) } // keyed, idempotent
326
+ ```
327
+
328
+ Mechanism: compensations may run more than once (`compensationRetries`) and are
329
+ reconstructed from history across crashes; refund/release must no-op when
330
+ already applied or a retry double-refunds. Source: `docs/reliability/sagas.mdx`.
331
+
332
+ ### MEDIUM expecting `failFast` fan-out to clean up siblings
333
+
334
+ Wrong:
335
+
336
+ ```ts
337
+ await ctx.all(ProcessItemWorkflow, items, { mode: 'failFast' }) // assumes undone work
338
+ ```
339
+
340
+ Correct:
341
+
342
+ ```ts
343
+ // Give ProcessItemWorkflow its own compensations; cancel deliberately with:
344
+ await engine.cancel(childRunId, { compensate: true })
345
+ ```
346
+
347
+ Mechanism: failFast cancellation is best-effort and carries NO saga — a sibling
348
+ mid-step observes cancellation only at its next checkpoint. Source:
349
+ `docs/authoring/child-workflows.mdx` (warning callout).
350
+
351
+ See also: `durable-transports-stores` — claim/stall semantics behind these nets.
352
+ See also: `durable-cluster` — where bodies actually execute in a fleet.