@adonis-agora/durable 0.25.0 → 0.26.0
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.
- package/CHANGELOG.md +38 -0
- package/dist/assets/spa/assets/{index-CV232q_B.js → index-ClD9G7pa.js} +10 -10
- package/dist/assets/spa/assets/index-ClD9G7pa.js.map +1 -0
- package/dist/assets/spa/assets/index-Ds6dCWMa.css +1 -0
- package/dist/assets/spa/index.html +2 -2
- package/dist/providers/durable_provider.d.ts.map +1 -1
- package/dist/providers/durable_provider.js +25 -0
- package/dist/providers/durable_provider.js.map +1 -1
- package/dist/src/dashboard/index.d.ts +1 -1
- package/dist/src/dashboard/index.js +1 -1
- package/dist/src/engine.d.ts +7 -0
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +11 -0
- package/dist/src/engine.js.map +1 -1
- package/dist/src/event-trigger-bridge.d.ts +32 -0
- package/dist/src/event-trigger-bridge.d.ts.map +1 -0
- package/dist/src/event-trigger-bridge.js +122 -0
- package/dist/src/event-trigger-bridge.js.map +1 -0
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/workflow-discovery.d.ts.map +1 -1
- package/dist/src/workflow-discovery.js +13 -1
- package/dist/src/workflow-discovery.js.map +1 -1
- package/dist/src/workflow-events.d.ts +85 -0
- package/dist/src/workflow-events.d.ts.map +1 -0
- package/dist/src/workflow-events.js +58 -0
- package/dist/src/workflow-events.js.map +1 -0
- package/package.json +5 -2
- package/skills/durable-cluster/SKILL.md +308 -0
- package/skills/durable-determinism/SKILL.md +260 -0
- package/skills/durable-reliability/SKILL.md +352 -0
- package/skills/durable-setup/SKILL.md +293 -0
- package/skills/durable-transports-stores/SKILL.md +296 -0
- package/skills/durable-workflows/SKILL.md +399 -0
- package/dist/assets/spa/assets/index-CTsYsi6p.css +0 -1
- package/dist/assets/spa/assets/index-CV232q_B.js.map +0 -1
|
@@ -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.
|