@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,308 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: durable-cluster
|
|
3
|
+
description: >-
|
|
4
|
+
Scale @adonis-agora/durable across processes and runtimes: the
|
|
5
|
+
role-discriminated config (standalone, control-plane, store-less tenant with
|
|
6
|
+
compile-time store exclusion), durable:work vs durable:worker entrypoints,
|
|
7
|
+
WorkerRuntime + RedisWorkerRegistry descriptors, handshake negotiation
|
|
8
|
+
(compatible/degraded/incompatible) and capability routing (requires → blocked
|
|
9
|
+
runs), layered tenant auth (signTenantToken/hmacTenantVerifier/verifyTenant),
|
|
10
|
+
runGateway as the role-portable read/control surface, namespace vs partition,
|
|
11
|
+
and Python (durable-worker) / NestJS interop over the bullmq wire. Use when
|
|
12
|
+
splitting web from workers, isolating tenants, or adding polyglot workers.
|
|
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/cluster/index.mdx'
|
|
21
|
+
- 'DavideCarvalho/adonis-durable:docs/cluster/roles-and-config.mdx'
|
|
22
|
+
- 'DavideCarvalho/adonis-durable:docs/cluster/thin-workers.mdx'
|
|
23
|
+
- 'DavideCarvalho/adonis-durable:docs/cluster/handshake.mdx'
|
|
24
|
+
- 'DavideCarvalho/adonis-durable:docs/cluster/interop.mdx'
|
|
25
|
+
- 'DavideCarvalho/adonis-durable:docs/python.mdx'
|
|
26
|
+
- 'DavideCarvalho/adonis-durable:docs/concepts/tenancy.mdx'
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
# Cluster topologies & interop
|
|
30
|
+
|
|
31
|
+
`config/durable.ts` is a **role-discriminated union**: one `role` field picks
|
|
32
|
+
the topology and TypeScript narrows the rest. Workflows (`app/workflows`) and
|
|
33
|
+
steps (`app/steps`) are byte-identical across every role — only where bodies
|
|
34
|
+
execute changes.
|
|
35
|
+
|
|
36
|
+
| Role | Owns the store | Executes bodies | Serves HTTP |
|
|
37
|
+
|------|:---:|:---:|:---:|
|
|
38
|
+
| `standalone` (default — omit `role`) | ✅ | ✅ embedded | optional |
|
|
39
|
+
| `control-plane` | ✅ required | ❌ | optional |
|
|
40
|
+
| `tenant` | ❌ typed `never` | worker pods ✅ | api/dashboard pod |
|
|
41
|
+
|
|
42
|
+
## Setup
|
|
43
|
+
|
|
44
|
+
Split fleet: a pure coordinator plus store-less tenant workers.
|
|
45
|
+
|
|
46
|
+
```ts title="config/durable.ts (control plane)"
|
|
47
|
+
import { defineConfig, stores, transports } from '@adonis-agora/durable'
|
|
48
|
+
|
|
49
|
+
export default defineConfig({
|
|
50
|
+
role: 'control-plane',
|
|
51
|
+
transport: 'bullmq', // cross-ecosystem wire; queue/db work for Adonis-only splits
|
|
52
|
+
transports: { bullmq: transports.bullmq({ connection: { host: '127.0.0.1', port: 6379 } }) },
|
|
53
|
+
store: 'lucid',
|
|
54
|
+
stores: { lucid: stores.lucid({ connection: 'pg' }) },
|
|
55
|
+
verifyTenant: hmacTenantVerifier(process.env.DURABLE_TENANT_SECRET!),
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```ts title="config/durable.ts (tenant worker pod)"
|
|
60
|
+
import { defineConfig, transports } from '@adonis-agora/durable'
|
|
61
|
+
|
|
62
|
+
export default defineConfig({
|
|
63
|
+
role: 'tenant',
|
|
64
|
+
transport: 'bullmq',
|
|
65
|
+
transports: { bullmq: transports.bullmq({ connection: process.env.REDIS_URL }) },
|
|
66
|
+
partition: 'acme-corp', // which pool this pod serves (required)
|
|
67
|
+
tenant: { token: process.env.DURABLE_TENANT_TOKEN }, // its signed claim
|
|
68
|
+
requestTimeoutMs: 10_000,
|
|
69
|
+
// store: 'lucid' // ❌ COMPILE ERROR — a tenant config cannot name a store
|
|
70
|
+
})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```bash title="entrypoints"
|
|
74
|
+
node ace durable:work # store-backed loop: pending, recovery, timers, sweeps, schedules
|
|
75
|
+
node ace durable:worker # store-less tenant loop: serves app/steps, heartbeats, drains
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Isolation is three-layered: type (`store` is `never` on tenant), container (no
|
|
79
|
+
store binding registered), object (`WorkerRuntime`/`ProxyRunGateway` have no
|
|
80
|
+
store field).
|
|
81
|
+
|
|
82
|
+
## Core patterns
|
|
83
|
+
|
|
84
|
+
### Pattern 1 — role-portable code via `runGateway`
|
|
85
|
+
|
|
86
|
+
Every role exposes the same read/control surface; identical controller code runs
|
|
87
|
+
on a store pod (direct reads) or a store-less pod (wire requests answered by the
|
|
88
|
+
control plane's `RunRequestResponder`):
|
|
89
|
+
|
|
90
|
+
```ts title="app/controllers/runs_controller.ts"
|
|
91
|
+
import { runGateway } from '@adonis-agora/durable/services/main'
|
|
92
|
+
|
|
93
|
+
export default class RunsController {
|
|
94
|
+
async show({ params, response }: HttpContext) {
|
|
95
|
+
const run = await runGateway.getRun(params.id)
|
|
96
|
+
if (!run) return response.notFound()
|
|
97
|
+
return response.ok({ run, timeline: await runGateway.getCheckpoints(params.id) })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async approve({ params, response }: HttpContext) {
|
|
101
|
+
await runGateway.signal(params.id, `approve:${params.id}`, { by: 'ops' })
|
|
102
|
+
return response.accepted({})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Verbs: `getRun`, `listRuns`, `getCheckpoints`, `getRunChildren`,
|
|
108
|
+
`getSearchAttributes`, `start`, `signal`, `cancel`, `redispatchPending`,
|
|
109
|
+
`workerHealth`, `subscribe`, `topology()`. Never branch on `role`.
|
|
110
|
+
Source: `docs/cluster/roles-and-config.mdx`.
|
|
111
|
+
|
|
112
|
+
### Pattern 2 — capability-aware routing and blocked runs
|
|
113
|
+
|
|
114
|
+
A step can require capabilities; dispatch goes only to workers advertising them.
|
|
115
|
+
No capable live worker → the run parks **blocked** (first-class status, visible
|
|
116
|
+
in the dashboard, auto-resumes when a capable worker registers):
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
export const settlePayment = defineStep(
|
|
120
|
+
'settle-payment',
|
|
121
|
+
async (input: { orderId: number }) => ({ /* … */ }),
|
|
122
|
+
{ requires: ['saga', 'search-attributes'] },
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Workers advertise a `WorkerDescriptor` (runtime, SDK version, protocol range,
|
|
127
|
+
capabilities, steps/workflows served, partition); the control plane negotiates
|
|
128
|
+
protocol-major overlap + capability intersection into compatible / degraded /
|
|
129
|
+
incompatible. An incompatible worker is never handed a task — structured
|
|
130
|
+
`protocol.incompatible` / `capability.unavailable` diagnostics carry both
|
|
131
|
+
descriptors and the delta. Source: `docs/cluster/handshake.mdx`.
|
|
132
|
+
|
|
133
|
+
### Pattern 3 — building a thin worker by hand
|
|
134
|
+
|
|
135
|
+
Outside an Adonis app (standalone Node service), use the `/worker` subpath — it
|
|
136
|
+
imports zero store code:
|
|
137
|
+
|
|
138
|
+
```ts title="worker.ts"
|
|
139
|
+
import { RedisWorkerRegistry, WorkerRuntime } from '@adonis-agora/durable/worker'
|
|
140
|
+
import { transports } from '@adonis-agora/durable'
|
|
141
|
+
import { Redis } from 'ioredis'
|
|
142
|
+
|
|
143
|
+
const transport = await transports.bullmq({ connection: process.env.REDIS_URL })({ app })
|
|
144
|
+
const runtime = new WorkerRuntime({
|
|
145
|
+
transport,
|
|
146
|
+
partition: 'acme-corp',
|
|
147
|
+
registry: new RedisWorkerRegistry(new Redis(process.env.REDIS_URL!), { ownsConnection: true }),
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
runtime.registerStep('billing.charge', async (input, log) => {
|
|
151
|
+
log.info('charging', input)
|
|
152
|
+
return { chargeId: await stripe.charge(input as ChargeInput) }
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
await runtime.start()
|
|
156
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
157
|
+
process.once(signal, () => void runtime.stop())
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
A worker that must execute workflow turns registers their BODIES explicitly —
|
|
162
|
+
synchronous replay functions returning commands. Source:
|
|
163
|
+
`docs/cluster/thin-workers.mdx`.
|
|
164
|
+
|
|
165
|
+
### Pattern 4 — Python workers on the same control plane
|
|
166
|
+
|
|
167
|
+
The BullMQ transport speaks the aviary wire byte-for-byte, so a Python worker
|
|
168
|
+
can serve steps (or whole workflows) for an AdoniJS control plane over the same
|
|
169
|
+
Redis:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
pip install "durable-worker[redis]"
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
```python title="worker.py"
|
|
176
|
+
import asyncio
|
|
177
|
+
from durable_worker import Worker
|
|
178
|
+
from durable_worker.redis_runner import run_redis_worker
|
|
179
|
+
|
|
180
|
+
worker = Worker()
|
|
181
|
+
|
|
182
|
+
@worker.step("payments.charge-card")
|
|
183
|
+
async def charge(data):
|
|
184
|
+
res = await stripe.charge(data["orderId"], data["amountCents"])
|
|
185
|
+
return {"chargeId": res.id}
|
|
186
|
+
|
|
187
|
+
async def main():
|
|
188
|
+
await run_redis_worker(worker, connection="redis://localhost:6379")
|
|
189
|
+
await asyncio.Event().wait()
|
|
190
|
+
|
|
191
|
+
asyncio.run(main())
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Connection and prefix must match on both sides; sharing a broker across
|
|
195
|
+
deployments means setting the same `partition` on both sides (queues become
|
|
196
|
+
`<name>@<partition>`). Start Python workflows from Adonis by name:
|
|
197
|
+
`engine.start('pipeline', input, id)` — if no worker serves the name, the start
|
|
198
|
+
fails fast rather than hanging. Source: `docs/python.mdx`,
|
|
199
|
+
`docs/cluster/interop.mdx`.
|
|
200
|
+
|
|
201
|
+
## Common mistakes
|
|
202
|
+
|
|
203
|
+
### HIGH launching `durable:worker` on a store-backed role
|
|
204
|
+
|
|
205
|
+
Wrong:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
node ace durable:worker # on a standalone/control-plane pod
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Correct:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
node ace durable:work # the store-backed dispatch/recovery/timers/schedules loop
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Mechanism: `durable:worker` boots the store-less `WorkerRuntime`; on a role that
|
|
218
|
+
owns a store it logs a warning and refuses — the two commands are different
|
|
219
|
+
topologies, not aliases. Source: `docs/cli.mdx`.
|
|
220
|
+
|
|
221
|
+
### HIGH assuming `durable:worker` pods carry workflow bodies
|
|
222
|
+
|
|
223
|
+
Wrong:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
# expecting a tenant pod to execute workflow turns because app/workflows exists
|
|
227
|
+
node ace durable:worker
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Correct:
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
runtime.registerWorkflow('checkout', (ctx, input) => {
|
|
234
|
+
const paid = ctx.step('billing.charge', input)
|
|
235
|
+
return { paid }
|
|
236
|
+
})
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Mechanism: the command registers workflow NAMES from `app/workflows` (routing +
|
|
240
|
+
descriptor) but not their bodies; turn execution needs explicit registration,
|
|
241
|
+
and turns are synchronous replay functions by design. Source:
|
|
242
|
+
`docs/cluster/thin-workers.mdx` (warning callout).
|
|
243
|
+
|
|
244
|
+
### MEDIUM leaving a hand-built WorkerRuntime on the default NoopWorkerRegistry
|
|
245
|
+
|
|
246
|
+
Wrong:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
const runtime = new WorkerRuntime({ transport, partition: 'acme' }) // registry defaults to noop
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Correct:
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
const runtime = new WorkerRuntime({
|
|
256
|
+
transport,
|
|
257
|
+
partition: 'acme',
|
|
258
|
+
registry: new RedisWorkerRegistry(redis, { ownsConnection: true }),
|
|
259
|
+
})
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Mechanism: without a registry the descriptor/heartbeat go nowhere — the worker
|
|
263
|
+
executes fine but is invisible to capability-aware routing, so any step with
|
|
264
|
+
`requires` will never be dispatched to it. Source:
|
|
265
|
+
`docs/cluster/thin-workers.mdx` options table.
|
|
266
|
+
|
|
267
|
+
### MEDIUM letting a partition-less local process join a shared broker
|
|
268
|
+
|
|
269
|
+
Wrong:
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
// laptop dev against the production Redis:
|
|
273
|
+
transports.bullmq({ connection: REDIS_URL })
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Correct:
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
transports.bullmq({ connection: REDIS_URL, partition: 'david-dev' })
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Mechanism: a worker on no partition serves the bare `<name>` tokens — exactly
|
|
283
|
+
the queues the deployed fleet consumes — so the local process steals production
|
|
284
|
+
tasks; docs call this out under Tenancy pitfalls ("competes for, and steals, the
|
|
285
|
+
deployed fleet's tasks"). Source: `docs/concepts/tenancy.mdx`.
|
|
286
|
+
|
|
287
|
+
### MEDIUM stamping runs into a namespace nothing polls
|
|
288
|
+
|
|
289
|
+
Wrong:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
await engine.start('checkout', order, id, { namespace: 'eu-west' }) // no engine/operator polls eu-west
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Correct:
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
// Run at least one engine on 'eu-west' (or an operator above it) first:
|
|
299
|
+
await engine.start('checkout', order, id, { namespace: 'eu-west' })
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Mechanism: between namespaced engines there is no fallback owner; a namespace
|
|
303
|
+
with neither an engine of its own nor an operator above it is a dead end where
|
|
304
|
+
runs sit `pending` forever. Source: `docs/concepts/tenancy.mdx` (pitfalls).
|
|
305
|
+
|
|
306
|
+
See also: `durable-setup` — the standalone execution model this scales out of.
|
|
307
|
+
See also: `durable-transports-stores` — bullmq/queue/db driver details,
|
|
308
|
+
namespace mechanics.
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: durable-determinism
|
|
3
|
+
description: >-
|
|
4
|
+
Keep @adonis-agora/durable runs replay-safe: the determinism rule (no
|
|
5
|
+
Date.now, Math.random, new Date, crypto.randomUUID in a workflow body), the
|
|
6
|
+
checkpointed ctx.now() and ctx.sideEffect() capture sources,
|
|
7
|
+
NonDeterminismError at replay time, workflow versions for breaking shape
|
|
8
|
+
changes (skew protection), ctx.patched for surgical in-place guards, and the
|
|
9
|
+
@adonis-agora/durable-eslint-plugin no-nondeterminism rule with its
|
|
10
|
+
recommended preset. Use when authoring or changing workflow code that has
|
|
11
|
+
runs in flight, fixing NonDeterminismError, or wiring determinism linting.
|
|
12
|
+
license: MIT
|
|
13
|
+
metadata:
|
|
14
|
+
type: core
|
|
15
|
+
library: "@adonis-agora/durable"
|
|
16
|
+
library_version: "0.25.0"
|
|
17
|
+
framework: adonisjs
|
|
18
|
+
sources:
|
|
19
|
+
- 'DavideCarvalho/adonis-durable:docs/concepts/durability.mdx'
|
|
20
|
+
- 'DavideCarvalho/adonis-durable:docs/authoring/versioning.mdx'
|
|
21
|
+
- 'DavideCarvalho/adonis-durable:docs/tooling/linting.mdx'
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
# Determinism, replay & versioning
|
|
25
|
+
|
|
26
|
+
Durability is **checkpoint + deterministic replay**: each step records its
|
|
27
|
+
result at a deterministic position (`seq`); recovery re-runs the body from the
|
|
28
|
+
top and completed steps return their saved output instead of executing. The one
|
|
29
|
+
rule: the workflow body must take the same path every time it runs — all
|
|
30
|
+
non-determinism (network, queries, clocks, randomness) lives inside steps or
|
|
31
|
+
the context's capture primitives.
|
|
32
|
+
|
|
33
|
+
## Setup
|
|
34
|
+
|
|
35
|
+
Use the deterministic sources instead of raw clocks/entropy — each captures its
|
|
36
|
+
value once and replays it verbatim:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
engine.register('invoice', '1', async (ctx, order: Order) => {
|
|
40
|
+
const issuedAt = await ctx.now() // epoch ms — captured once, replayed verbatim
|
|
41
|
+
const nonce = await ctx.sideEffect(() => crypto.randomUUID()) // any generated value
|
|
42
|
+
const sampled = (await ctx.sideEffect(() => Math.random())) < 0.1
|
|
43
|
+
|
|
44
|
+
await ctx.localStep('issue', () => issue(order, { issuedAt, nonce, sampled }))
|
|
45
|
+
})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`ctx.now()` is the deterministic wall clock; `ctx.sideEffect(fn)` runs `fn` once
|
|
49
|
+
and replays the recorded result without re-running it — wrap UUIDs, randomness,
|
|
50
|
+
env reads, anything non-deterministic you control. Raw `Date.now()` /
|
|
51
|
+
`crypto.randomUUID()` belong only inside step bodies, whose whole result is
|
|
52
|
+
checkpointed.
|
|
53
|
+
|
|
54
|
+
## Core patterns
|
|
55
|
+
|
|
56
|
+
### Pattern 1 — catch drift at author time with the ESLint plugin
|
|
57
|
+
|
|
58
|
+
`@adonis-agora/durable-eslint-plugin` flags `Date.now()`, `performance.now()`,
|
|
59
|
+
`new Date()` (no args), `Math.random()` and `crypto.randomUUID()` inside a
|
|
60
|
+
workflow body — AST-scoped to the function form (`engine.register(...)`) and the
|
|
61
|
+
class form (`BaseWorkflow.run` / any class with `static workflow`). It stops at
|
|
62
|
+
`ctx.localStep(...)` / `ctx.task(...)` / `ctx.sideEffect(...)` callback
|
|
63
|
+
boundaries, where such calls are fine.
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npm i -D @adonis-agora/durable-eslint-plugin
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```js title="eslint.config.js"
|
|
70
|
+
import durable from '@adonis-agora/durable-eslint-plugin'
|
|
71
|
+
|
|
72
|
+
export default [
|
|
73
|
+
{
|
|
74
|
+
files: ['**/*.ts'],
|
|
75
|
+
plugins: { '@adonis-agora/durable': durable },
|
|
76
|
+
rules: {
|
|
77
|
+
'@adonis-agora/durable/no-nondeterminism': 'error',
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
// or simply:
|
|
83
|
+
// export default [durable.configs.recommended]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Each report names its replacement ("use `ctx.now()`"). The plugin is the cheap
|
|
87
|
+
early layer; `NonDeterminismError` is the replay-time backstop for calls it
|
|
88
|
+
cannot see (transitive helpers). Source: `docs/tooling/linting.mdx`.
|
|
89
|
+
|
|
90
|
+
### Pattern 2 — breaking changes: register versions side by side
|
|
91
|
+
|
|
92
|
+
A run records the code version it started on (`workflowVersion`) and replays on
|
|
93
|
+
that version. For a structural change (new/removed/reordered steps, different
|
|
94
|
+
control flow), bump the version and drain the old:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
engine.register('checkout', '1', async (ctx, order: Order) => {
|
|
98
|
+
/* original body — keep registered until its in-flight runs drain */
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
engine.register('checkout', '2', async (ctx, order: Order) => {
|
|
102
|
+
/* new body */
|
|
103
|
+
})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
In-flight v1 runs finish on v1; new starts pick v2. A run whose version is no
|
|
107
|
+
longer registered fails loudly rather than corrupting. Remove v1 only after
|
|
108
|
+
every run that started under it reached a terminal state.
|
|
109
|
+
Source: `docs/authoring/versioning.mdx`, `docs/concepts/durability.mdx`.
|
|
110
|
+
|
|
111
|
+
### Pattern 3 — surgical changes: `ctx.patched(id)`
|
|
112
|
+
|
|
113
|
+
For a small in-place change on a live workflow, guard it instead of shipping a
|
|
114
|
+
whole second body:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
engine.register('checkout', '1', async (ctx, order: Order) => {
|
|
118
|
+
const quote = await ctx.localStep('quote', () => price(order))
|
|
119
|
+
|
|
120
|
+
if (await ctx.patched('add-fraud-check')) {
|
|
121
|
+
// Runs started AFTER this code shipped take this branch...
|
|
122
|
+
const risk = await ctx.step(scoreFraud, { orderId: order.id })
|
|
123
|
+
if (risk.score > 0.9) throw new FatalError('high fraud risk', 'fraud')
|
|
124
|
+
}
|
|
125
|
+
// ...runs already recorded under the old code rewind past the marker here.
|
|
126
|
+
|
|
127
|
+
await ctx.step(chargeCard, { orderId: order.id, amountCents: quote.total })
|
|
128
|
+
})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The marker is position-transparent: old runs replay straight into the real step
|
|
132
|
+
they recorded; fresh runs record `patch:add-fraud-check` and take the new branch.
|
|
133
|
+
Once everything old drained, delete the guard and keep the new branch.
|
|
134
|
+
Source: `docs/authoring/versioning.mdx`.
|
|
135
|
+
|
|
136
|
+
### Pattern 4 — what triggers the runtime guard
|
|
137
|
+
|
|
138
|
+
Replay pairs each position with its recorded checkpoint by name. If the name at
|
|
139
|
+
a position changed — an insert, removal, reorder — the engine throws rather than
|
|
140
|
+
silently mis-pairing:
|
|
141
|
+
|
|
142
|
+
```text
|
|
143
|
+
NonDeterminismError(runId, seq)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
It tells you a code change is incompatible with runs that started under the old
|
|
147
|
+
code — fix with versions (Pattern 2) or patches (Pattern 3).
|
|
148
|
+
Source: `docs/authoring/versioning.mdx`.
|
|
149
|
+
|
|
150
|
+
## Common mistakes
|
|
151
|
+
|
|
152
|
+
### CRITICAL reading clocks or randomness in the orchestration prefix
|
|
153
|
+
|
|
154
|
+
Wrong:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
const startedAt = Date.now()
|
|
158
|
+
const idempotencyKey = crypto.randomUUID()
|
|
159
|
+
const jitter = Math.random()
|
|
160
|
+
const at = new Date()
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Correct:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
const startedAt = await ctx.now()
|
|
167
|
+
const idempotencyKey = await ctx.sideEffect(() => crypto.randomUUID())
|
|
168
|
+
const jitter = await ctx.sideEffect(() => Math.random())
|
|
169
|
+
const at = new Date(await ctx.now())
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Mechanism: each banned call returns a different value on every replay, so the
|
|
173
|
+
replayed path diverges from the recorded one and silently corrupts the run until
|
|
174
|
+
`NonDeterminismError` fires far from the offending line. Source:
|
|
175
|
+
`docs/tooling/linting.mdx`, `docs/concepts/durability.mdx`.
|
|
176
|
+
|
|
177
|
+
### HIGH reshaping a live workflow without a version bump
|
|
178
|
+
|
|
179
|
+
Wrong:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
// v1 was [quote, charge]; edited in place to [quote, fraudCheck, charge]
|
|
183
|
+
static workflow = { name: 'checkout', version: '1' }
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Correct:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
static workflow = { name: 'checkout', version: '2' } // alongside v1 until drained
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Mechanism: replay is positional — inserting/reordering steps while runs are in
|
|
193
|
+
flight pairs old checkpoints with new positions and throws
|
|
194
|
+
`NonDeterminismError`; the version split is exactly the skew protection for it.
|
|
195
|
+
Source: `docs/authoring/versioning.mdx`.
|
|
196
|
+
|
|
197
|
+
### HIGH calling injected services directly from `run`
|
|
198
|
+
|
|
199
|
+
Wrong:
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
async run(ctx, input) {
|
|
203
|
+
const receipt = await this.billing.charge(input) // unchecked I/O, re-runs on replay
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Correct:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
async run(ctx, input) {
|
|
211
|
+
const receipt = await ctx.step(this.billing.charge, input)
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Mechanism: constructor DI resolves services outside the checkpoint system;
|
|
216
|
+
calling them from the body performs I/O that re-executes on every replay and is
|
|
217
|
+
never checkpointed. Source: `docs/authoring/app-workflows.mdx` (warning).
|
|
218
|
+
|
|
219
|
+
### MEDIUM adding `timeoutMs` to a webhook wait as an unversioned edit
|
|
220
|
+
|
|
221
|
+
Wrong:
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
const r = await hook.wait({ timeoutMs: 1_800_000 }) // edited onto a live workflow
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Correct:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
// Ship under version+1, or gate with ctx.patched('add-wait-deadline').
|
|
231
|
+
const r = await hook.wait({ timeoutMs: 1_800_000 })
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Mechanism: a deadline claims an extra logical position, so toggling it shifts
|
|
235
|
+
positions for in-flight runs — the docs call it "a versioned change" explicitly.
|
|
236
|
+
Source: `docs/authoring/webhooks.mdx`.
|
|
237
|
+
|
|
238
|
+
### MEDIUM reading env/config directly in the body
|
|
239
|
+
|
|
240
|
+
Wrong:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
const region = env.get('REGION') // may differ between the original run and a replay
|
|
244
|
+
if (region === 'eu') await ctx.step(gdprStep, input)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Correct:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
const region = await ctx.sideEffect(() => env.get('REGION'))
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Mechanism: an env value changed between the first execution and a replay makes
|
|
254
|
+
the body branch differently, diverging from recorded history; captured via
|
|
255
|
+
`sideEffect`, the value is fixed once. Source: `docs/authoring/versioning.mdx`
|
|
256
|
+
("a config/env read" belongs behind `ctx.sideEffect`).
|
|
257
|
+
|
|
258
|
+
See also: `durable-workflows` — the authoring surface these rules constrain.
|
|
259
|
+
See also: `durable-reliability` — poison-pill runs and the DLQ path when
|
|
260
|
+
NonDeterminismError keeps crashing recovery.
|