@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,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.
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: durable-workflows
|
|
3
|
+
description: >-
|
|
4
|
+
Author durable workflows and steps with @adonis-agora/durable: BaseWorkflow
|
|
5
|
+
classes under app/workflows (static workflow, constructor @inject DI,
|
|
6
|
+
dispatch/start statics, make:workflow), step handlers via @Step decorators,
|
|
7
|
+
defineStep, string names or transport.handle, ctx.localStep vs always-
|
|
8
|
+
dispatched ctx.step, durable sleeps (ctx.sleep/sleepUntil), signals
|
|
9
|
+
(waitForSignal/signal/signalWithStart), named events (waitForEvent/
|
|
10
|
+
publishEvent/eventBatch/onEvent/idempotent opts.id), webhooks (ctx.webhook),
|
|
11
|
+
external tasks (ctx.task/completeTask/failTask), transactional steps
|
|
12
|
+
(ctx.transaction), breakpoints, child workflows (child/startChild/all/
|
|
13
|
+
continueAsNew/GatherError), singleton workflows, entities (registerEntity/
|
|
14
|
+
callEntity), scheduling (ScheduledWorkflow/static schedule/@Scheduled),
|
|
15
|
+
tags and search attributes. Use when writing or composing workflow logic.
|
|
16
|
+
license: MIT
|
|
17
|
+
metadata:
|
|
18
|
+
type: core
|
|
19
|
+
library: "@adonis-agora/durable"
|
|
20
|
+
library_version: "0.25.0"
|
|
21
|
+
framework: adonisjs
|
|
22
|
+
sources:
|
|
23
|
+
- 'DavideCarvalho/adonis-durable:docs/authoring/app-workflows.mdx'
|
|
24
|
+
- 'DavideCarvalho/adonis-durable:docs/concepts/workflows-and-steps.mdx'
|
|
25
|
+
- 'DavideCarvalho/adonis-durable:docs/concepts/sleep-and-signals.mdx'
|
|
26
|
+
- 'DavideCarvalho/adonis-durable:docs/authoring/child-workflows.mdx'
|
|
27
|
+
- 'DavideCarvalho/adonis-durable:docs/authoring/webhooks.mdx'
|
|
28
|
+
- 'DavideCarvalho/adonis-durable:docs/authoring/entities.mdx'
|
|
29
|
+
- 'DavideCarvalho/adonis-durable:docs/authoring/scheduling.mdx'
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
# Authoring workflows & steps
|
|
33
|
+
|
|
34
|
+
A workflow is a class under `app/workflows/` extending `BaseWorkflow` with a
|
|
35
|
+
`static workflow = { name, version }`. The provider scans the directory at boot
|
|
36
|
+
and registers every exported class — you never call `engine.register` by hand
|
|
37
|
+
(it remains the low-level escape hatch). The convention mirrors `@adonisjs/queue`
|
|
38
|
+
jobs under `app/jobs`; scaffold with `node ace make:workflow order`.
|
|
39
|
+
|
|
40
|
+
## Setup
|
|
41
|
+
|
|
42
|
+
```ts title="app/workflows/checkout_workflow.ts"
|
|
43
|
+
import { inject } from '@adonisjs/core'
|
|
44
|
+
import { BaseWorkflow } from '@adonis-agora/durable'
|
|
45
|
+
import type { WorkflowCtx } from '@adonis-agora/durable'
|
|
46
|
+
|
|
47
|
+
export default class CheckoutWorkflow extends BaseWorkflow {
|
|
48
|
+
static workflow = { name: 'checkout', version: '1' }
|
|
49
|
+
|
|
50
|
+
async run(ctx: WorkflowCtx, order: { id: number; total: number }) {
|
|
51
|
+
await ctx.localStep('reserveStock', async () => ({ reserved: true }))
|
|
52
|
+
const charge = await ctx.step('payments:charge-card', {
|
|
53
|
+
orderId: order.id,
|
|
54
|
+
amountCents: order.total,
|
|
55
|
+
})
|
|
56
|
+
const approval = await ctx.waitForSignal<{ approved: boolean }>(`approve:${order.id}`)
|
|
57
|
+
if (!approval.approved) return { status: 'rejected', chargeId: charge.chargeId }
|
|
58
|
+
await ctx.localStep('ship', async () => ({ shipped: true }))
|
|
59
|
+
return { status: 'shipped', chargeId: charge.chargeId }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Run it from anywhere: `CheckoutWorkflow.dispatch(input, { runId })` enqueues;
|
|
65
|
+
`CheckoutWorkflow.start(input)` awaits the settled result. Both statics are
|
|
66
|
+
context-aware — inside a running body they become a linked child /
|
|
67
|
+
fire-and-forget child instead of a top-level run.
|
|
68
|
+
|
|
69
|
+
## Core patterns
|
|
70
|
+
|
|
71
|
+
### Pattern 1 — typed steps with `@Step`, `defineStep`, and DI
|
|
72
|
+
|
|
73
|
+
Steps are discovered from `app/steps` and served by name automatically. A
|
|
74
|
+
`@Step` method ref passed to `ctx.step` gives full input/output typing:
|
|
75
|
+
|
|
76
|
+
```ts title="app/steps/payment_steps.ts"
|
|
77
|
+
import { Step } from '@adonis-agora/durable'
|
|
78
|
+
import { z } from 'zod'
|
|
79
|
+
|
|
80
|
+
export default class PaymentSteps {
|
|
81
|
+
@Step({
|
|
82
|
+
name: 'payments:charge-card',
|
|
83
|
+
input: z.object({ orderId: z.number().int(), amountCents: z.number().int() }),
|
|
84
|
+
output: z.object({ chargeId: z.string() }),
|
|
85
|
+
retries: 3,
|
|
86
|
+
})
|
|
87
|
+
async chargeCard(input: { orderId: number; amountCents: number }) {
|
|
88
|
+
return { chargeId: await stripe.charge(input) }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```ts title="app/workflows/order_workflow.ts"
|
|
94
|
+
import { inject } from '@adonisjs/core'
|
|
95
|
+
import { BaseWorkflow } from '@adonis-agora/durable'
|
|
96
|
+
import type { WorkflowCtx } from '@adonis-agora/durable'
|
|
97
|
+
import PaymentSteps from '#steps/payment_steps'
|
|
98
|
+
|
|
99
|
+
@inject()
|
|
100
|
+
export default class OrderWorkflow extends BaseWorkflow {
|
|
101
|
+
static workflow = { name: 'order', version: '1' }
|
|
102
|
+
|
|
103
|
+
constructor(private payments: PaymentSteps) {
|
|
104
|
+
super()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async run(ctx: WorkflowCtx, order: Order) {
|
|
108
|
+
// Typed both ways — renaming chargeCard is a compile error here:
|
|
109
|
+
const charge = await ctx.step(this.payments.chargeCard, {
|
|
110
|
+
orderId: order.id,
|
|
111
|
+
amountCents: order.total,
|
|
112
|
+
})
|
|
113
|
+
return { chargeId: charge.chargeId }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The instance is built once at registration and reused for every run — treat the
|
|
119
|
+
constructor as wiring; keep per-run state in input and step outputs, never on
|
|
120
|
+
`this`. Injected services are for **composing**, not doing work: reach them
|
|
121
|
+
through step refs so the work is checkpointed.
|
|
122
|
+
|
|
123
|
+
### Pattern 2 — pausing durably: sleeps, signals, events
|
|
124
|
+
|
|
125
|
+
All three suspend the run with zero compute and survive restarts.
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
// Time-based wait — duration string or ms:
|
|
129
|
+
await ctx.sleep('7 days')
|
|
130
|
+
await ctx.sleepUntil(new Date('2026-12-31T00:00:00Z'))
|
|
131
|
+
|
|
132
|
+
// Human-in-the-loop / webhook callback — point-to-point by token:
|
|
133
|
+
const decision = await ctx.waitForSignal<{ approved: boolean }>(`approve:${order.id}`, {
|
|
134
|
+
timeoutMs: 24 * 60 * 60 * 1000, // throws SignalTimeoutError when the deadline passes
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// Named pub/sub — one publish fans out to every matching waiter:
|
|
138
|
+
const settled = await ctx.waitForEvent<{ orderId: string }>('payment.settled', {
|
|
139
|
+
match: { orderId: order.id },
|
|
140
|
+
})
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Deliver from controllers: `engine.signal(token, payload)` resumes one waiter;
|
|
144
|
+
signals sent before the waiter registers are buffered, not lost;
|
|
145
|
+
`engine.signalWithStart(workflow, input, runId, { token, payload })` starts the
|
|
146
|
+
run if missing and delivers in one race-free call. Publish events with
|
|
147
|
+
`engine.publishEvent(name, payload, { id: event.id })` — the upstream event id
|
|
148
|
+
makes redelivery a no-op for `onEvent` starts. Register a workflow to start on
|
|
149
|
+
events with `{ onEvent: ['payment.settled'] }`; coalesce chatty sources with
|
|
150
|
+
`eventBatch: { mode: 'debounce', windowMs: 5_000 }` (last payload wins) or
|
|
151
|
+
`{ mode: 'batch', maxSize: 50, windowMs: 60_000 }` (receives `{ events: [...] }`).
|
|
152
|
+
|
|
153
|
+
```ts title="app/controllers/approvals_controller.ts"
|
|
154
|
+
import { inject } from '@adonisjs/core'
|
|
155
|
+
import { WorkflowEngine } from '@adonis-agora/durable'
|
|
156
|
+
|
|
157
|
+
@inject()
|
|
158
|
+
export default class ApprovalsController {
|
|
159
|
+
constructor(private engine: WorkflowEngine) {}
|
|
160
|
+
|
|
161
|
+
async approve({ params, request, response }: HttpContext) {
|
|
162
|
+
await this.engine.signal(`approve:${params.id}`, request.body())
|
|
163
|
+
return response.noContent()
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Pattern 3 — children, fan-out, and singletons
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
const shipment = await ctx.child(ShippingWorkflow, { orderId: order.id }) // parent suspends
|
|
172
|
+
await ctx.startChild('reindex-search', { postId: post.id }) // fire-and-forget
|
|
173
|
+
const results = await ctx.all(ProcessItemWorkflow, batch.items) // outputs in input order
|
|
174
|
+
if (input.n < 3) await ctx.continueAsNew({ n: input.n + 1 }) // clean history per generation
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`ctx.all` default mode is `waitAll`: every sibling finishes, then the call throws
|
|
178
|
+
a `GatherError` carrying `.failures`; `{ mode: 'failFast' }` cancels remaining
|
|
179
|
+
siblings (plain cancel — no saga). Child ids derive from the call position and
|
|
180
|
+
are replay-stable; never pass a random one.
|
|
181
|
+
|
|
182
|
+
Serialize runs per subject with a singleton — runs sharing a key queue FIFO and
|
|
183
|
+
are race-free across instances:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
export default class SyncShopWorkflow extends BaseWorkflow {
|
|
187
|
+
static workflow = {
|
|
188
|
+
name: 'sync-shop',
|
|
189
|
+
version: '1',
|
|
190
|
+
singleton: { key: (input) => (input as { shopId: string }).shopId, limit: 1, maxQueueDepth: 20 },
|
|
191
|
+
}
|
|
192
|
+
// …
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Overflow beyond `limit + maxQueueDepth` rejects `dispatch` with
|
|
197
|
+
`SingletonQueueFullError` before anything is created.
|
|
198
|
+
|
|
199
|
+
### Pattern 4 — long-lived state, callbacks, cadences
|
|
200
|
+
|
|
201
|
+
Entities serialize operations per key without DB locks (register from a
|
|
202
|
+
preload):
|
|
203
|
+
|
|
204
|
+
```ts title="start/entities.ts"
|
|
205
|
+
import engine from '@adonis-agora/durable/services/main'
|
|
206
|
+
|
|
207
|
+
engine.registerEntity<{ balanceCents: number }>('account', {
|
|
208
|
+
initialState: () => ({ balanceCents: 0 }),
|
|
209
|
+
handlers: {
|
|
210
|
+
deposit: (state, amountCents: number) => {
|
|
211
|
+
state.balanceCents += amountCents
|
|
212
|
+
return state.balanceCents
|
|
213
|
+
},
|
|
214
|
+
balance: (state) => state.balanceCents,
|
|
215
|
+
},
|
|
216
|
+
})
|
|
217
|
+
// drive: engine.signalEntity('account', key, 'deposit', 500)
|
|
218
|
+
// read: engine.getEntityState('account', key)
|
|
219
|
+
// from a workflow: await ctx.callEntity('account', key, 'deposit', 500)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Wait on a foreign system's own callback by id (`engine.completeTask(runId, name,
|
|
223
|
+
result)` / `failTask` resume the run):
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
const result = await ctx.task<TranscodeResult>(job.id, async () => {
|
|
227
|
+
await sqs.send({ queueUrl, body: JSON.stringify({ jobId: job.id, src: job.src }) })
|
|
228
|
+
})
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Recurring cadences are colocated on the class (or set in `config/durable.ts`
|
|
232
|
+
`schedules`, which overrides by `key`); cron needs `cron-parser` installed where
|
|
233
|
+
the worker runs, and only the `durable:work` loop fires schedules:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
export default class DailyReportWorkflow extends BaseWorkflow {
|
|
237
|
+
static workflow = { name: 'daily-report', version: '1' }
|
|
238
|
+
static schedule = { cron: '0 2 * * *', timezone: 'America/Sao_Paulo' }
|
|
239
|
+
// or: @Scheduled({ everyMs: 60_000 }) above the class
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Each schedule window starts exactly once across a fleet via an idempotent
|
|
244
|
+
time-bucket run id.
|
|
245
|
+
|
|
246
|
+
Source: `docs/authoring/entities.mdx`, `docs/authoring/external-tasks.mdx`,
|
|
247
|
+
`docs/authoring/scheduling.mdx`.
|
|
248
|
+
|
|
249
|
+
## Common mistakes
|
|
250
|
+
|
|
251
|
+
### HIGH calling an injected service directly inside `run`
|
|
252
|
+
|
|
253
|
+
Wrong:
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
async run(ctx, input) {
|
|
257
|
+
const receipt = await this.billing.charge(input) // I/O outside a step
|
|
258
|
+
return { receipt }
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Correct:
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
async run(ctx, input) {
|
|
266
|
+
const receipt = await ctx.step(this.billing.charge, input)
|
|
267
|
+
return { receipt }
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Mechanism: work done outside `ctx.step`/`ctx.localStep` is never checkpointed,
|
|
272
|
+
so it re-executes on every replay and its result is not durable. The docs warn
|
|
273
|
+
explicitly that injected services exist "for composing the workflow, not for
|
|
274
|
+
doing its work". Source: `docs/authoring/app-workflows.mdx`.
|
|
275
|
+
|
|
276
|
+
### HIGH swallowing control-flow signals in a hand-rolled try/catch
|
|
277
|
+
|
|
278
|
+
Wrong:
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
try {
|
|
282
|
+
await ctx.step('chargeCard', input)
|
|
283
|
+
} catch (error) {
|
|
284
|
+
await ctx.step('refund', input) // also catches suspend/continue-as-new signals!
|
|
285
|
+
throw error
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Correct:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
import { isWorkflowControlFlowSignal } from '@adonis-agora/durable'
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
await ctx.step('chargeCard', input)
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (isWorkflowControlFlowSignal(error)) throw error
|
|
298
|
+
await ctx.step('refund', input)
|
|
299
|
+
throw error
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Mechanism: `ctx.sleep`, `ctx.waitForSignal` and `ctx.continueAsNew` unwind the
|
|
304
|
+
turn by throwing; running your failure path on one records extra history a later
|
|
305
|
+
replay never produces → `NonDeterminismError` on resume. The predicate checks a
|
|
306
|
+
stamped marker, deliberately `false` for cancellations and real failures.
|
|
307
|
+
Source: `docs/concepts/workflows-and-steps.mdx` ("Catching errors").
|
|
308
|
+
|
|
309
|
+
### HIGH passing a random child id inside a workflow
|
|
310
|
+
|
|
311
|
+
Wrong:
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
await ctx.startChild('reindex-search', item, crypto.randomUUID())
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Correct:
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
await ctx.startChild('reindex-search', item) // deterministic positional id
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Mechanism: inside a body, omitted child ids are derived deterministically from
|
|
324
|
+
the call position so replay re-attaches to the same child; a fresh random id on
|
|
325
|
+
each replay breaks that dedup and re-fires the child. Source:
|
|
326
|
+
`docs/authoring/child-workflows.mdx` ("opts.runId — outside vs inside").
|
|
327
|
+
|
|
328
|
+
### MEDIUM blocking a hot HTTP path on `.start`
|
|
329
|
+
|
|
330
|
+
Wrong:
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
const result = await CheckoutWorkflow.start(order) // request held open until settle
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Correct:
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
const { runId } = await CheckoutWorkflow.dispatch(order, { runId: `checkout:${order.id}` })
|
|
340
|
+
return response.accepted({ runId })
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Mechanism: `.start` resolves only when the run reaches a terminal state or
|
|
344
|
+
`suspends` — hours later for a long workflow. Source: `docs/getting-started.mdx`
|
|
345
|
+
(warning callout).
|
|
346
|
+
|
|
347
|
+
### MEDIUM awaiting a webhook with no timeout
|
|
348
|
+
|
|
349
|
+
Wrong:
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
const result = await hook.wait() // suspends indefinitely if the provider never calls back
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Correct:
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
import { SignalTimeoutError } from '@adonis-agora/durable'
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
const result = await hook.wait({ timeoutMs: 30 * 60 * 1000 })
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (!(error instanceof SignalTimeoutError)) throw error
|
|
364
|
+
// reconcile: poll the provider instead of hanging forever
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Mechanism: without `timeoutMs` there is no wake timer — a dropped callback
|
|
369
|
+
leaves the run parked invisibly to the timer poller and crash recovery.
|
|
370
|
+
Source: `docs/authoring/webhooks.mdx` ("Not waiting forever").
|
|
371
|
+
|
|
372
|
+
### MEDIUM throwing business rejections out of entity handlers
|
|
373
|
+
|
|
374
|
+
Wrong:
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
withdraw: (state, amountCents) => {
|
|
378
|
+
if (amountCents > state.balanceCents) throw new Error('insufficient funds')
|
|
379
|
+
state.balanceCents -= amountCents
|
|
380
|
+
return state.balanceCents
|
|
381
|
+
},
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Correct:
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
withdraw: (state, amountCents) =>
|
|
388
|
+
amountCents > state.balanceCents
|
|
389
|
+
? { ok: false as const, reason: 'insufficient_funds' as const }
|
|
390
|
+
: ((state.balanceCents -= amountCents), { ok: true as const }),
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Mechanism: a throwing handler fails the entity's immortal run, stranding every
|
|
394
|
+
caller suspended on `ctx.callEntity`; model expected rejections as returned
|
|
395
|
+
discriminated results and reserve throws for genuine programmer errors.
|
|
396
|
+
Source: `docs/authoring/entities.mdx` (warning callout).
|
|
397
|
+
|
|
398
|
+
See also: `durable-determinism` — why the body must stay deterministic and how
|
|
399
|
+
to change it safely once live.
|