@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,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.
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: durable-transports-stores
|
|
3
|
+
description: >-
|
|
4
|
+
Wire @adonis-agora/durable drivers in config/durable.ts: transports
|
|
5
|
+
(memory/eventEmitter/queue over @adonisjs/queue, broker-less db over Lucid,
|
|
6
|
+
bullmq for cross-runtime), per-step transport pinning and failover pools,
|
|
7
|
+
controlPlanes.redis broadcast channel, the lucid StateStore (published
|
|
8
|
+
migration delegating to createDurableTables, autoSchema, repair warning,
|
|
9
|
+
updateRunIf compare-and-set), CodecStateStore payload encryption with a
|
|
10
|
+
PayloadCodec, and namespace vs partition isolation. Use when moving steps
|
|
11
|
+
across processes, persisting runs durably, sharing one database/broker
|
|
12
|
+
between pools, or encrypting payloads at rest.
|
|
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/transports/index.mdx'
|
|
21
|
+
- 'DavideCarvalho/adonis-durable:docs/transports/queue.mdx'
|
|
22
|
+
- 'DavideCarvalho/adonis-durable:docs/transports/db.mdx'
|
|
23
|
+
- 'DavideCarvalho/adonis-durable:docs/transports/control-plane.mdx'
|
|
24
|
+
- 'DavideCarvalho/adonis-durable:docs/stores/index.mdx'
|
|
25
|
+
- 'DavideCarvalho/adonis-durable:docs/stores/lucid.mdx'
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
# Transports & state stores
|
|
29
|
+
|
|
30
|
+
A transport carries a dispatched `ctx.step` to its handler and the result back;
|
|
31
|
+
a state store persists runs and checkpoints. They are independent pluggable,
|
|
32
|
+
config-selected drivers — mix any store with any transport. Drivers are named
|
|
33
|
+
from the `transports`/`stores` maps and lazily import their optional peer only
|
|
34
|
+
when selected.
|
|
35
|
+
|
|
36
|
+
## Setup
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
node ace configure @adonis-agora/durable # publishes config + migrations
|
|
40
|
+
node ace migration:run # durable tables (+ db-transport tables)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```ts title="config/durable.ts"
|
|
44
|
+
import { defineConfig, stores, transports } from '@adonis-agora/durable'
|
|
45
|
+
|
|
46
|
+
export default defineConfig({
|
|
47
|
+
transport: 'queue',
|
|
48
|
+
transports: {
|
|
49
|
+
memory: transports.memory(),
|
|
50
|
+
'event-emitter': transports.eventEmitter(),
|
|
51
|
+
queue: transports.queue({ connection: 'redis' }), // names a config/queue.ts adapter
|
|
52
|
+
db: transports.db(), // pass { connection } for a non-default Lucid connection
|
|
53
|
+
},
|
|
54
|
+
store: 'lucid',
|
|
55
|
+
stores: { lucid: stores.lucid() },
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Switching transports is a one-line change; workflow code never changes.
|
|
60
|
+
|
|
61
|
+
## Core patterns
|
|
62
|
+
|
|
63
|
+
### Pattern 1 — cross-process steps without new infrastructure (`db`)
|
|
64
|
+
|
|
65
|
+
The `db` transport makes dispatched steps rows in the database you already run —
|
|
66
|
+
no Redis, no broker. Workers claim rows with an atomic portable lease (no
|
|
67
|
+
`FOR UPDATE SKIP LOCKED` needed):
|
|
68
|
+
|
|
69
|
+
```ts title="start/worker.ts (a separate worker process)"
|
|
70
|
+
import db from '@adonisjs/lucid/services/db'
|
|
71
|
+
import { DbTransport } from '@adonis-agora/durable'
|
|
72
|
+
|
|
73
|
+
const transport = new DbTransport({ db }) // { partition } isolates this pool
|
|
74
|
+
transport.handle('pipeline:extract', async (input) => extract(input))
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Trade-off: throughput is bounded by polling + row contention — right for
|
|
78
|
+
workflow-scale modest rates, not high-fanout firehoses. Delivery is
|
|
79
|
+
at-least-once (crash between insert-result and delete-task), so handlers should
|
|
80
|
+
be idempotent on `step_id`. Source: `docs/transports/db.mdx`.
|
|
81
|
+
|
|
82
|
+
### Pattern 2 — real brokers (`queue`, `bullmq`) and stalled-claim tuning
|
|
83
|
+
|
|
84
|
+
The `queue` transport rides any `@adonisjs/queue` adapter defined once in
|
|
85
|
+
`config/queue.ts`; `bullmq` is the cross-ecosystem wire shared byte-for-byte
|
|
86
|
+
with NestJS engines and Python workers:
|
|
87
|
+
|
|
88
|
+
```ts title="config/durable.ts"
|
|
89
|
+
export default defineConfig({
|
|
90
|
+
transports: {
|
|
91
|
+
queue: transports.queue({
|
|
92
|
+
connection: 'redis',
|
|
93
|
+
// A claim is NEVER renewed while a worker processes it — its age is the
|
|
94
|
+
// step's elapsed time. Raise the threshold above your longest step:
|
|
95
|
+
stalledThresholdMs: 2 * 60 * 60 * 1000, // default 30min
|
|
96
|
+
maxStalledCount: 3, // bounds a poison job
|
|
97
|
+
onError: (err) => logger.error({ err }, 'durable queue transport'),
|
|
98
|
+
}),
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
One instance consumes engine-side results; run one or more `node ace
|
|
104
|
+
durable:work` workers serving the step names. Source:
|
|
105
|
+
`docs/transports/queue.mdx`.
|
|
106
|
+
|
|
107
|
+
### Pattern 3 — multi-replica fan-out: the control plane
|
|
108
|
+
|
|
109
|
+
The task transport is point-to-point; lifecycle events and cancellation need a
|
|
110
|
+
broadcast channel across every replica. Omit it and the engine is local-only
|
|
111
|
+
(correct for single-instance):
|
|
112
|
+
|
|
113
|
+
```ts title="config/durable.ts"
|
|
114
|
+
import { controlPlanes, defineConfig } from '@adonis-agora/durable'
|
|
115
|
+
|
|
116
|
+
export default defineConfig({
|
|
117
|
+
// …
|
|
118
|
+
controlPlane: controlPlanes.redis({ connection: 'main' }), // config/redis.ts connection
|
|
119
|
+
})
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Channel is `${prefix}-control` (default prefix `durable`) — wire-compatible with
|
|
123
|
+
the NestJS fleet's channel, so an AdonisJS and NestJS deployment sharing one
|
|
124
|
+
Redis fan out across both runtimes. Source: `docs/transports/control-plane.mdx`.
|
|
125
|
+
|
|
126
|
+
### Pattern 4 — schema ownership and encryption at rest
|
|
127
|
+
|
|
128
|
+
The migration **delegates** to the library instead of snapshotting DDL, so the
|
|
129
|
+
schema cannot drift from what the installed version writes:
|
|
130
|
+
|
|
131
|
+
```ts title="database/migrations/…_create_durable_tables.ts"
|
|
132
|
+
import { BaseSchema } from '@adonisjs/lucid/schema'
|
|
133
|
+
import db from '@adonisjs/lucid/services/db'
|
|
134
|
+
import { createDurableTables, dropDurableTables } from '@adonis-agora/durable'
|
|
135
|
+
|
|
136
|
+
export default class extends BaseSchema {
|
|
137
|
+
static disableTransactions = true
|
|
138
|
+
|
|
139
|
+
async up() {
|
|
140
|
+
await createDurableTables(db, this.db.connectionName)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async down() {
|
|
144
|
+
await dropDurableTables(db, this.db.connectionName)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
By default the provider also provisions idempotently at boot (`ensureSchema`);
|
|
150
|
+
turn that off to own every change through reviewed migrations:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
export default defineConfig({ /* … */ autoSchema: false })
|
|
154
|
+
// then `node ace migration:run` after EVERY package upgrade
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Encrypt run/step/signal/event payloads by decorating any store with a codec
|
|
158
|
+
(searchable metadata and structured error stay plaintext):
|
|
159
|
+
|
|
160
|
+
```ts title="config/durable.ts"
|
|
161
|
+
import { CodecStateStore, LucidStateStore, defineConfig } from '@adonis-agora/durable'
|
|
162
|
+
import type { PayloadCodec } from '@adonis-agora/durable'
|
|
163
|
+
import db from '@adonisjs/lucid/services/db'
|
|
164
|
+
|
|
165
|
+
const aesCodec: PayloadCodec = {
|
|
166
|
+
encode(value) {
|
|
167
|
+
const body = Buffer.from(JSON.stringify(value), 'utf8').toString('base64')
|
|
168
|
+
return { body }
|
|
169
|
+
},
|
|
170
|
+
decode(value) {
|
|
171
|
+
return JSON.parse(Buffer.from((value as { body: string }).body, 'base64').toString('utf8'))
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export default defineConfig({
|
|
176
|
+
store: 'encrypted',
|
|
177
|
+
stores: { encrypted: async () => new CodecStateStore(new LucidStateStore(db), aesCodec) },
|
|
178
|
+
})
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Source: `docs/stores/lucid.mdx`, `docs/stores/index.mdx`.
|
|
182
|
+
|
|
183
|
+
## Common mistakes
|
|
184
|
+
|
|
185
|
+
### HIGH setting `stalledThresholdMs` below the longest legitimate step
|
|
186
|
+
|
|
187
|
+
Wrong:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
transports.queue({ connection: 'redis', stalledThresholdMs: 60_000 }) // imports take ~90min
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Correct:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
transports.queue({ connection: 'redis', stalledThresholdMs: 2 * 60 * 60 * 1000 })
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Mechanism: a claim's age equals the step's elapsed runtime because nothing
|
|
200
|
+
renews it mid-processing; a threshold below the longest step redelivers jobs
|
|
201
|
+
from merely-slow workers and double-runs the step. For tighter detection use
|
|
202
|
+
per-step `timeoutMs`, which IS heartbeat-aware.
|
|
203
|
+
Source: `docs/transports/queue.mdx` ("Stalled-job reclaim").
|
|
204
|
+
|
|
205
|
+
### HIGH hand-copying the DDL into the migration
|
|
206
|
+
|
|
207
|
+
Wrong:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
export default class extends BaseSchema {
|
|
211
|
+
async up() {
|
|
212
|
+
this.schema.createTable('durable_workflow_runs', (t) => {
|
|
213
|
+
/* copied column list */
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Correct:
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
export default class extends BaseSchema {
|
|
223
|
+
static disableTransactions = true
|
|
224
|
+
async up() {
|
|
225
|
+
await createDurableTables(db, this.db.connectionName)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Mechanism: the snapshot is only right until the next release drifts past it, and
|
|
231
|
+
the two details are load-bearing — without `disableTransactions = true` a
|
|
232
|
+
`pool.max: 1` setup hangs until acquire timeout (createDurableTables checks out
|
|
233
|
+
its own connection), and `this.db` has no `.connection()` method, only
|
|
234
|
+
`.connectionName`. Source: `docs/stores/lucid.mdx`.
|
|
235
|
+
|
|
236
|
+
### MEDIUM leaving `autoSchema: false` with no upgrade discipline
|
|
237
|
+
|
|
238
|
+
Wrong:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
export default defineConfig({ store: 'lucid', stores: { lucid: stores.lucid() }, autoSchema: false })
|
|
242
|
+
// …package upgraded twice, migrations never re-run
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Correct:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
node ace migration:run # after every @adonis-agora/durable upgrade
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Mechanism: with boot provisioning off, nothing repairs the schema; when the boot
|
|
252
|
+
path DOES repair (autoSchema on), it logs a one-time "repaired the durable
|
|
253
|
+
schema in place" warning meaning exactly this — the database was behind and the
|
|
254
|
+
fix was never recorded in migration history. Source: `docs/stores/lucid.mdx`
|
|
255
|
+
("The repair warning").
|
|
256
|
+
|
|
257
|
+
### MEDIUM using the deprecated `group` option to isolate worker pools
|
|
258
|
+
|
|
259
|
+
Wrong:
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
transports.db({ group: 'tenant-a' }) // silently accepted and ignored
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Correct:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
transports.db({ partition: 'tenant-a' })
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Mechanism: routing is by handler name — an instance serves whatever names it
|
|
272
|
+
registers, so there is no group to declare. Isolation comes from `partition`
|
|
273
|
+
(suffixes routing tokens `<name>@<partition>`) plus `namespace` (scopes which
|
|
274
|
+
runs this engine polls). Source: `docs/transports/db.mdx` options table,
|
|
275
|
+
`docs/concepts/tenancy.mdx`.
|
|
276
|
+
|
|
277
|
+
### MEDIUM wrapping a store in CodecStateStore and expecting heartbeats
|
|
278
|
+
|
|
279
|
+
Wrong:
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
stores: { encrypted: async () => new CodecStateStore(new LucidStateStore(db), aesCodec) }
|
|
283
|
+
// then relying on persisted heartbeat progress for long remote steps
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Correct: accept the documented trade-off explicitly — the decorator does not
|
|
287
|
+
implement the optional `recordStepHeartbeat`, so persisted liveness progress is
|
|
288
|
+
traded for encryption at rest (the live `step.started` event still fires).
|
|
289
|
+
|
|
290
|
+
Mechanism: the optional member list defines what decorators can silently drop;
|
|
291
|
+
heartbeat persistence is one of them. Source: `docs/stores/index.mdx`
|
|
292
|
+
(optional members table + warning).
|
|
293
|
+
|
|
294
|
+
See also: `durable-reliability` — retries/timeouts interact with transport
|
|
295
|
+
claim semantics; the stuck-run playbook.
|
|
296
|
+
See also: `durable-cluster` — namespace/partition across topologies and tenants.
|