@adonis-agora/durable 0.24.3 → 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.
- package/CHANGELOG.md +39 -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/src/dashboard/index.d.ts +1 -1
- package/dist/src/dashboard/index.js +1 -1
- package/dist/src/engine.d.ts +10 -4
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +54 -22
- package/dist/src/engine.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- 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,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.
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: durable-setup
|
|
3
|
+
description: >-
|
|
4
|
+
Install and configure @adonis-agora/durable in an AdonisJS app: node ace add,
|
|
5
|
+
config/durable.ts with defineConfig and driver-by-name transports/stores maps
|
|
6
|
+
(memory, event-emitter, queue, db, bullmq; lucid store), the WorkflowEngine
|
|
7
|
+
container singleton from @adonis-agora/durable/services/main,
|
|
8
|
+
Workflow.dispatch vs .start, where runs execute (in-process dispatcher,
|
|
9
|
+
NOOP_RUN_DISPATCHER, durable:work), consumers, leaseMs. Use when setting up
|
|
10
|
+
durable workflows for the first time, moving from dev defaults to production
|
|
11
|
+
infrastructure, or diagnosing "runs vanish on restart" / "workflows compete
|
|
12
|
+
with HTTP traffic".
|
|
13
|
+
license: MIT
|
|
14
|
+
metadata:
|
|
15
|
+
type: core
|
|
16
|
+
library: "@adonis-agora/durable"
|
|
17
|
+
library_version: "0.25.0"
|
|
18
|
+
framework: adonisjs
|
|
19
|
+
sources:
|
|
20
|
+
- 'DavideCarvalho/adonis-durable:docs/getting-started.mdx'
|
|
21
|
+
- 'DavideCarvalho/adonis-durable:docs/concepts/durability.mdx'
|
|
22
|
+
- 'DavideCarvalho/adonis-durable:docs/transports/index.mdx'
|
|
23
|
+
- 'DavideCarvalho/adonis-durable:docs/reliability/failure-modes.mdx'
|
|
24
|
+
- 'DavideCarvalho/adonis-durable:README.md'
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
# Setting up @adonis-agora/durable
|
|
28
|
+
|
|
29
|
+
`@adonis-agora/durable` is a durable workflow engine for AdonisJS: workflows are
|
|
30
|
+
plain async code whose steps are checkpointed, so a crash or deploy resumes from
|
|
31
|
+
the last checkpoint instead of starting over. The published `config/durable.ts`
|
|
32
|
+
defaults to zero infrastructure (in-process transport + in-memory store) — that
|
|
33
|
+
default is for development only.
|
|
34
|
+
|
|
35
|
+
## Setup
|
|
36
|
+
|
|
37
|
+
Install via ace (registers `@adonis-agora/durable/durable_provider` in
|
|
38
|
+
`adonisrc.ts` and publishes `config/durable.ts`):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
node ace add @adonis-agora/durable
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The provider binds a singleton `WorkflowEngine` built from the config. Import it
|
|
45
|
+
anywhere with `import engine from '@adonis-agora/durable/services/main'`.
|
|
46
|
+
|
|
47
|
+
```ts title="config/durable.ts"
|
|
48
|
+
import { defineConfig, stores, transports } from '@adonis-agora/durable'
|
|
49
|
+
|
|
50
|
+
export default defineConfig({
|
|
51
|
+
transport: 'event-emitter',
|
|
52
|
+
transports: {
|
|
53
|
+
memory: transports.memory(),
|
|
54
|
+
'event-emitter': transports.eventEmitter(),
|
|
55
|
+
// queue: transports.queue({ connection: 'redis' }), // names a config/queue.ts adapter
|
|
56
|
+
// db: transports.db(),
|
|
57
|
+
},
|
|
58
|
+
store: 'lucid',
|
|
59
|
+
stores: { lucid: stores.lucid() },
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Define a workflow class under `app/workflows/` (auto-registered at boot):
|
|
64
|
+
|
|
65
|
+
```ts title="app/workflows/checkout_workflow.ts"
|
|
66
|
+
import { BaseWorkflow } from '@adonis-agora/durable'
|
|
67
|
+
import type { WorkflowCtx } from '@adonis-agora/durable'
|
|
68
|
+
|
|
69
|
+
export default class CheckoutWorkflow extends BaseWorkflow {
|
|
70
|
+
static workflow = { name: 'checkout', version: '1' }
|
|
71
|
+
|
|
72
|
+
async run(ctx: WorkflowCtx, order: { id: number; total: number }) {
|
|
73
|
+
await ctx.localStep('reserveStock', async () => ({ reserved: true }))
|
|
74
|
+
const approval = await ctx.waitForSignal<{ approved: boolean }>(`approve:${order.id}`)
|
|
75
|
+
if (!approval.approved) return { status: 'rejected' }
|
|
76
|
+
return { status: 'shipped' }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Start a run from a controller — `dispatch` enqueues and returns immediately:
|
|
82
|
+
|
|
83
|
+
```ts title="app/controllers/checkout_controller.ts"
|
|
84
|
+
import type { HttpContext } from '@adonisjs/core/http'
|
|
85
|
+
import CheckoutWorkflow from '#workflows/checkout_workflow'
|
|
86
|
+
import Order from '#models/order'
|
|
87
|
+
|
|
88
|
+
export default class CheckoutController {
|
|
89
|
+
async store({ request, response }: HttpContext) {
|
|
90
|
+
const total = request.input('total') as number
|
|
91
|
+
const order = await Order.create({ total })
|
|
92
|
+
const { runId } = await CheckoutWorkflow.dispatch(
|
|
93
|
+
{ id: order.id, total: order.total },
|
|
94
|
+
{ runId: `checkout:${order.id}` }, // stable runId = idempotency key
|
|
95
|
+
)
|
|
96
|
+
return response.accepted({ runId })
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Source: `docs/getting-started.mdx`, `docs/index.mdx`.
|
|
102
|
+
|
|
103
|
+
## Core patterns
|
|
104
|
+
|
|
105
|
+
### Pattern 1 — production single-process config
|
|
106
|
+
|
|
107
|
+
For a deployed app that stays one process, pair the Lucid store with the
|
|
108
|
+
`eventEmitter` transport. Checkpoints survive restarts in Postgres while steps
|
|
109
|
+
never leave the process:
|
|
110
|
+
|
|
111
|
+
```ts title="config/durable.ts"
|
|
112
|
+
import { defineConfig, stores, transports } from '@adonis-agora/durable'
|
|
113
|
+
|
|
114
|
+
export default defineConfig({
|
|
115
|
+
transport: 'event-emitter',
|
|
116
|
+
transports: { 'event-emitter': transports.eventEmitter() },
|
|
117
|
+
store: 'lucid',
|
|
118
|
+
stores: { lucid: stores.lucid() },
|
|
119
|
+
leaseMs: 30_000,
|
|
120
|
+
})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Run `node ace durable:work` alongside (or rely on the embedded worker) so
|
|
124
|
+
recovery, timers and schedules are driven even if you later split processes.
|
|
125
|
+
Source: `docs/transports/index.mdx`.
|
|
126
|
+
|
|
127
|
+
### Pattern 2 — split web from workers
|
|
128
|
+
|
|
129
|
+
`engine.start()` never blocks the caller, but by default the body executes on a
|
|
130
|
+
microtask **on the calling instance**. On an API/dashboard pod that means heavy
|
|
131
|
+
runs compete with HTTP traffic and the DB pool. Pass `NOOP_RUN_DISPATCHER` so
|
|
132
|
+
the API pod only creates runs, and let `durable:work` execute them:
|
|
133
|
+
|
|
134
|
+
```ts title="config/durable.ts (API/dashboard pod)"
|
|
135
|
+
import { NOOP_RUN_DISPATCHER, defineConfig, stores, transports } from '@adonis-agora/durable'
|
|
136
|
+
|
|
137
|
+
export default defineConfig({
|
|
138
|
+
transport: 'queue',
|
|
139
|
+
transports: { queue: transports.queue({ connection: 'redis' }) },
|
|
140
|
+
store: 'lucid',
|
|
141
|
+
stores: { lucid: stores.lucid() },
|
|
142
|
+
runDispatcher: NOOP_RUN_DISPATCHER, // create runs; never execute bodies here
|
|
143
|
+
})
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```bash title="a separate worker process"
|
|
147
|
+
node ace durable:work --interval=500 --drainTimeout=15000
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Each `durable:work` tick runs `engine.runPending()` → `recoverIncomplete()` →
|
|
151
|
+
`resumeDueTimers()` → `sweepTimeouts()` → fires due schedules, then drains on
|
|
152
|
+
`SIGINT`/`SIGTERM`. Source: `docs/concepts/durability.mdx`,
|
|
153
|
+
`docs/reliability/failure-modes.mdx`, `docs/cli.mdx`.
|
|
154
|
+
|
|
155
|
+
### Pattern 3 — dispatch vs start vs waitForRun
|
|
156
|
+
|
|
157
|
+
One rule: `.dispatch(input)` = fire-and-forget (`{ runId }`); `.start(input)` =
|
|
158
|
+
block until the run settles (terminal state or suspended) and get the result;
|
|
159
|
+
`engine.waitForRun(runId)` resolves a previously started run.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
const { runId } = await OrderWorkflow.dispatch(order) // hot path — respond now
|
|
163
|
+
const result = await OrderWorkflow.start(order) // scripts / short runs only
|
|
164
|
+
await engine.start('order', order, runId)
|
|
165
|
+
const settled = await engine.waitForRun(runId) // resolves on settle
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`.start` blocks the calling context — avoid it on a hot HTTP path for
|
|
169
|
+
long-running workflows. Source: `docs/getting-started.mdx`,
|
|
170
|
+
`docs/authoring/app-workflows.mdx`.
|
|
171
|
+
|
|
172
|
+
### Pattern 4 — optional peers install only when selected
|
|
173
|
+
|
|
174
|
+
Every driver lazily imports its optional peer (`@adonisjs/lucid`,
|
|
175
|
+
`@adonisjs/queue`, `@adonisjs/redis`, `bullmq`, `@opentelemetry/api`) only when
|
|
176
|
+
that driver is named in config. Add the peer to the app that uses it:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
npm i @adonisjs/lucid # lucid store / db transport
|
|
180
|
+
node ace configure @adonisjs/lucid && node ace migration:run
|
|
181
|
+
npm i bullmq # bullmq transport (cross-ecosystem fleets)
|
|
182
|
+
npm i cron-parser # cron-based schedules
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`cron-parser` is needed wherever schedules with a `cron` field fire (the worker
|
|
186
|
+
host). Source: `docs/stores/lucid.mdx`, `docs/authoring/scheduling.mdx`,
|
|
187
|
+
`packages/adonis/package.json` (peerDependencies).
|
|
188
|
+
|
|
189
|
+
## Common mistakes
|
|
190
|
+
|
|
191
|
+
### CRITICAL shipping the in-memory default store
|
|
192
|
+
|
|
193
|
+
Wrong:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
// Works locally, then every run and checkpoint vanishes on restart/deploy.
|
|
197
|
+
export default defineConfig({
|
|
198
|
+
transport: 'memory',
|
|
199
|
+
transports: { memory: transports.memory() },
|
|
200
|
+
})
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Correct:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
export default defineConfig({
|
|
207
|
+
transport: 'event-emitter',
|
|
208
|
+
transports: { 'event-emitter': transports.eventEmitter() },
|
|
209
|
+
store: 'lucid',
|
|
210
|
+
stores: { lucid: stores.lucid() },
|
|
211
|
+
})
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Mechanism: with no `store` key the engine uses the in-memory store — no error,
|
|
215
|
+
no warning, just state loss across restarts. The in-memory store is documented
|
|
216
|
+
as "for tests and local dev. Not durable."
|
|
217
|
+
Source: `docs/stores/index.mdx`, `docs/getting-started.mdx`.
|
|
218
|
+
|
|
219
|
+
### HIGH using `transports.memory()` as the single-process production driver
|
|
220
|
+
|
|
221
|
+
Wrong:
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
transport: 'memory' // "it's all one process anyway"
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Correct:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
transport: 'event-emitter',
|
|
231
|
+
transports: { 'event-emitter': transports.eventEmitter() },
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Mechanism: `memory` executes the handler **inline inside the dispatch call**;
|
|
235
|
+
`eventEmitter` delivers on a later tick like a real broker, so code correct on
|
|
236
|
+
`eventEmitter` is correct on a broker too. The docs warn explicitly:
|
|
237
|
+
"memory is for tests; eventEmitter is the single-process production driver".
|
|
238
|
+
Source: `docs/transports/index.mdx`.
|
|
239
|
+
|
|
240
|
+
### HIGH letting workflows share the API pod's event loop
|
|
241
|
+
|
|
242
|
+
Wrong:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
// API/dashboard pod, default config — a burst of CPU-heavy runs starves requests.
|
|
246
|
+
export default defineConfig({ transport: 'queue', transports: { /* … */ } })
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Correct:
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
import { NOOP_RUN_DISPATCHER, defineConfig } from '@adonis-agora/durable'
|
|
253
|
+
|
|
254
|
+
export default defineConfig({
|
|
255
|
+
transport: 'queue',
|
|
256
|
+
transports: { /* … */ },
|
|
257
|
+
runDispatcher: NOOP_RUN_DISPATCHER,
|
|
258
|
+
})
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
plus `node ace durable:work` as its own process. Mechanism: the default
|
|
262
|
+
in-process dispatcher executes bodies on the same instance, event loop and DB
|
|
263
|
+
pool as the HTTP handler that called `start()`; a saturated pool starves both.
|
|
264
|
+
Source: `docs/reliability/failure-modes.mdx` ("The in-process default
|
|
265
|
+
surprise"), `docs/concepts/durability.mdx`.
|
|
266
|
+
|
|
267
|
+
### MEDIUM expecting a `node ace` script to await a remote step result
|
|
268
|
+
|
|
269
|
+
Wrong:
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
// inside `node ace run-report` — hangs forever on a queue/db/bullmq transport
|
|
273
|
+
const { runId } = await OrderWorkflow.dispatch(input)
|
|
274
|
+
const result = await engine.waitForRun(runId)
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Correct:
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
export default defineConfig({
|
|
281
|
+
// …
|
|
282
|
+
consumers: 'always', // opt this process into broker consumption
|
|
283
|
+
})
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
—or don't round-trip inline: start the run, exit, let the worker fleet finish
|
|
287
|
+
it. Mechanism: console/repl processes deliberately do not start broker consumer
|
|
288
|
+
loops (they would claim jobs and die holding them), so nothing receives results
|
|
289
|
+
in that process unless `consumers: 'always'`.
|
|
290
|
+
Source: `docs/transports/queue.mdx` ("consumers").
|
|
291
|
+
|
|
292
|
+
See also: `durable-transports-stores` — picking drivers, namespaces, migrations.
|
|
293
|
+
See also: `durable-workflows` — authoring the workflow classes you just wired.
|