@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.
@@ -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.