@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,352 @@
1
+ ---
2
+ name: durable-reliability
3
+ description: >-
4
+ Recover and harden @adonis-agora/durable runs: step retries with fixed/exp
5
+ backoff and jitter, FatalError and worker retryable:false, timeoutMs liveness
6
+ vs the durable suspend path, pickupTimeoutMs, log.heartbeat, saga
7
+ compensation (compensate callbacks/refs, UndoOf, compensationRetries,
8
+ cancel({compensate:true})), flow-control queues (registerQueue, concurrency,
9
+ rateLimit, fairnessKey) with Redis-backed global admission (admission-redis),
10
+ the dead-letter queue (maxRecoveryAttempts, onDead, retryWithInput), the
11
+ stuck-run playbook (stranded signature, redispatchPending,
12
+ remoteRedispatchMs), ace ops commands (durable:work/runs/retry --stale), and
13
+ the testing harness (createTestEngine, tick, failOnce, conformance kits).
14
+ license: MIT
15
+ metadata:
16
+ type: core
17
+ library: "@adonis-agora/durable"
18
+ library_version: "0.25.0"
19
+ framework: adonisjs
20
+ sources:
21
+ - 'DavideCarvalho/adonis-durable:docs/reliability/retries.mdx'
22
+ - 'DavideCarvalho/adonis-durable:docs/reliability/sagas.mdx'
23
+ - 'DavideCarvalho/adonis-durable:docs/reliability/dead-letter.mdx'
24
+ - 'DavideCarvalho/adonis-durable:docs/reliability/flow-control.mdx'
25
+ - 'DavideCarvalho/adonis-durable:docs/reliability/failure-modes.mdx'
26
+ - 'DavideCarvalho/adonis-durable:docs/cli.mdx'
27
+ - 'DavideCarvalho/adonis-durable:docs/testing.mdx'
28
+ ---
29
+
30
+ # Reliability & operations
31
+
32
+ A run's world is unreliable: APIs time out, workers crash, downstream systems
33
+ rate-limit. The engine treats retries, compensation, admission control and
34
+ dead-lettering as first-class primitives — and gives operators a symptom → fix
35
+ map for the runs that still get stuck.
36
+
37
+ ## Setup
38
+
39
+ Retries are declared per step; `FatalError` opts a deterministic business
40
+ failure out of the retry path:
41
+
42
+ ```ts title="app/steps/charge.ts"
43
+ import { defineStep } from '@adonis-agora/durable'
44
+
45
+ export const chargeCard = defineStep(
46
+ 'payments:charge-card',
47
+ async (input: { orderId: number; amountCents: number }) => {
48
+ const res = await stripe.charge(input)
49
+ if (res.declined) {
50
+ // deterministic verdict — don't make the engine retry it
51
+ throw Object.assign(new Error('card declined'), { code: 'declined', retryable: false })
52
+ }
53
+ return { chargeId: res.id }
54
+ },
55
+ { retries: 4, backoff: 'exp', backoffMs: 500, backoffMaxMs: 30_000, jitter: true },
56
+ )
57
+ ```
58
+
59
+ A dispatched step WITHOUT `timeoutMs` takes the durable path: failure → persist
60
+ `wakeAt = now + backoff(attempt)` on the checkpoint → suspend → the timer poller
61
+ re-dispatches. Crash-safe, never held in memory.
62
+
63
+ ## Core patterns
64
+
65
+ ### Pattern 1 — sagas: undo completed steps in reverse
66
+
67
+ Attach a `compensate` to each side-effecting step; when the run fails, undos run
68
+ last-completed-first:
69
+
70
+ ```ts
71
+ engine.register('checkout', '1', async (ctx, order: Order) => {
72
+ await ctx.localStep('reserve-inventory', () => inventory.reserve(order.items), {
73
+ compensate: () => inventory.release(order.items),
74
+ })
75
+
76
+ await ctx.localStep(
77
+ 'charge-card',
78
+ () => payments.charge(order.customerId, order.totalCents),
79
+ { compensate: () => payments.refundOnce(order.customerId, order.totalCents) },
80
+ )
81
+
82
+ await ctx.localStep('allocate-shipment', () => shipping.allocate(order))
83
+ return { ok: true }
84
+ })
85
+ ```
86
+
87
+ The saga is reconstructed from history on replay, so it works after a crash.
88
+ Dispatched steps compensate with a **step ref** receiving an `UndoOf<...>`
89
+ envelope (`{ input, output }`). Engine-level `compensationRetries` (default 1)
90
+ bounds transient undo failures; failing compensations are skipped so they can't
91
+ mask the original error. Trigger deliberately with
92
+ `await engine.cancel(runId, { compensate: true })` or
93
+ `engine.cancelWhere(filter, { compensate: true })`. Every undo surfaces as a
94
+ visible `compensate:<step>` event. Source: `docs/reliability/sagas.mdx`.
95
+
96
+ ### Pattern 2 — flow-control queues + fleet-wide admission
97
+
98
+ Cap concurrency/rate durably — a blocked call re-suspends with a persisted
99
+ `retryAt`, driven by the timer poller (nothing waits in memory):
100
+
101
+ ```ts title="start/durable.ts"
102
+ engine.registerQueue({ name: 'emails', concurrency: 10, rateLimit: { limit: 1000, periodMs: 3_600_000 } })
103
+ ```
104
+
105
+ ```ts
106
+ const sent = await ctx.step(sendEmail, input, { queue: 'emails', priority: 10, fairnessKey: order.tenantId })
107
+ ```
108
+
109
+ Accounting is per engine instance by default. For a cap that holds across every
110
+ pod, select the Redis admission backend (ships in the main package):
111
+
112
+ ```bash
113
+ npm i @adonisjs/redis
114
+ ```
115
+
116
+ ```ts title="config/durable.ts"
117
+ import { admissions, defineConfig } from '@adonis-agora/durable'
118
+
119
+ export default defineConfig({
120
+ // …
121
+ admission: admissions.redis({ connection: 'main' }),
122
+ })
123
+ ```
124
+
125
+ Each admit is one atomic Lua script (priority desc → fairness round-robin →
126
+ FIFO/LIFO); a crashed pod's slots free within `instanceTtlMs`.
127
+ Source: `docs/reliability/flow-control.mdx`.
128
+
129
+ ### Pattern 3 — dead-letter poison pills
130
+
131
+ Crash-recovery reclaims orphaned runs within ~`leaseMs`; a run that crashes the
132
+ process every time would loop forever. Cap it and route the terminal `dead`
133
+ status into a handler workflow (idempotent by the `dlq:<runId>` start id):
134
+
135
+ ```ts title="config/durable.ts"
136
+ export default defineConfig({
137
+ // …
138
+ maxRecoveryAttempts: 5, // then dead-letter instead of crash-looping
139
+ })
140
+ ```
141
+
142
+ ```ts title="start/durable.ts"
143
+ engine.onDead((run) => {
144
+ void engine.start(
145
+ 'dlq',
146
+ { deadRunId: run.id, workflow: run.workflow, input: run.input, error: run.error },
147
+ `dlq:${run.id}`,
148
+ )
149
+ })
150
+ ```
151
+
152
+ A dead run stays inspectable and retriable (`durable:retry <runId>`, dashboard
153
+ retry, or `engine.retryWithInput(runId, fixedInput)` for bad data).
154
+ Source: `docs/reliability/dead-letter.mdx`.
155
+
156
+ ### Pattern 4 — the stuck-run playbook
157
+
158
+ A lost remote dispatch does **not** self-heal by default: the run cycles
159
+ `suspended → reconcile → suspended` forever with no error. The stranded
160
+ signature: status `suspended`, stale `updatedAt`, newest checkpoint
161
+ `kind: 'remote' / status: 'pending'`, old `enqueuedAt`. Find it, then heal it:
162
+
163
+ ```bash
164
+ node ace durable:runs --stale=1h # threshold above your longest legitimate step
165
+ node ace durable:retry checkout:1234
166
+ ```
167
+
168
+ ```ts title="app/controllers/ops_controller.ts"
169
+ import { inject } from '@adonisjs/core'
170
+ import {
171
+ DEFAULT_STALE_MS,
172
+ WorkflowEngine,
173
+ attachLiveness,
174
+ filterStale,
175
+ listRuns,
176
+ } from '@adonis-agora/durable'
177
+
178
+ @inject()
179
+ export default class OpsController {
180
+ constructor(private engine: WorkflowEngine) {}
181
+
182
+ async strandedRuns({ response }: HttpContext) {
183
+ const runs = await listRuns(this.engine, { statuses: ['running', 'suspended'], limit: 200 })
184
+ const stale = filterStale(await attachLiveness(this.engine, runs), DEFAULT_STALE_MS)
185
+ return response.ok({ stranded: stale.map(({ run }) => run.id) })
186
+ }
187
+ }
188
+ ```
189
+
190
+ Three nets, in preference order:
191
+ 1. Per-step `timeoutMs` (+ heartbeat-aware liveness; split queue wait with
192
+ `pickupTimeoutMs`) — but its timer dies with the coordinating process.
193
+ 2. Engine-level `remoteRedispatchMs` / `remoteRedispatchMax` (default bound 10;
194
+ fails the step with `RemoteStepError` code `'remote_step_lost'` beyond it) —
195
+ must exceed the longest legitimate step; steps must be idempotent.
196
+ 3. `engine.redispatchPending(runId)` — the operator escape hatch, safe on a
197
+ healthy run (only touches already-pending checkpoints).
198
+
199
+ Source: `docs/reliability/failure-modes.mdx`, `docs/cli.mdx`.
200
+
201
+ ### Pattern 5 — test workflows without infrastructure
202
+
203
+ ```ts
204
+ import { assertRunStatus, createTestEngine, failOnce } from '@adonis-agora/durable/testing'
205
+
206
+ test('checkout completes', async () => {
207
+ const t = createTestEngine() // in-memory store + transport + controllable clock
208
+ t.engine.register('checkout', '1', async (ctx) => {
209
+ await ctx.localStep('charge', failOnce({ ok: true }), { retries: 3 })
210
+ await ctx.sleep('7 days')
211
+ return { done: true }
212
+ })
213
+
214
+ await t.run('checkout', {}, 'run1') // enqueues AND waits for settle
215
+ await t.tick(7 * 24 * 60 * 60 * 1000) // advance the clock past the sleep
216
+ await assertRunStatus(t.store, 'run1', 'completed')
217
+ })
218
+ ```
219
+
220
+ Custom adapters prove themselves against the shipped contracts:
221
+ `runStateStoreContract(name, factory)` and
222
+ `runAdmissionBackendContract(name, factory)` live on
223
+ `@adonis-agora/durable/testing/conformance` (they generate vitest suites);
224
+ `assertTransportConformance(transport)` stays on `/testing` as a plain async
225
+ call. Source: `docs/testing.mdx`.
226
+
227
+ ## Common mistakes
228
+
229
+ ### CRITICAL assuming a lost remote dispatch self-heals
230
+
231
+ Wrong:
232
+
233
+ ```ts
234
+ // Deploy and hope: the run stays suspended, its remote step pending forever.
235
+ ```
236
+
237
+ Correct:
238
+
239
+ ```ts
240
+ export default defineConfig({
241
+ remoteRedispatchMs: 30 * 60_000, // opt in explicitly
242
+ remoteRedispatchMax: 10,
243
+ })
244
+ // or per-step timeoutMs — or engine.redispatchPending(runId) right now
245
+ ```
246
+
247
+ Mechanism: the periodic reconcile replays straight back into the "still pending"
248
+ guard and re-suspends rather than re-dispatching, because the engine cannot tell
249
+ a dead worker from a slow one. The docs state this is deliberate ("by design").
250
+ Source: `docs/reliability/failure-modes.mdx`.
251
+
252
+ ### HIGH tuning detection windows below the slowest real step
253
+
254
+ Wrong:
255
+
256
+ ```ts
257
+ export default defineConfig({ remoteRedispatchMs: 60_000 }) // imports legitimately take ~20min
258
+ ```
259
+
260
+ Correct:
261
+
262
+ ```ts
263
+ export default defineConfig({ remoteRedispatchMs: 30 * 60_000, remoteRedispatchMax: 10 })
264
+ ```
265
+
266
+ Mechanism: once a pending checkpoint ages past the window the SAME `stepId` is
267
+ re-dispatched — a slow-but-live worker's step double-executes. Both requirements
268
+ are non-negotiable in the docs: window > longest legitimate step, and idempotent
269
+ steps. Source: `docs/reliability/failure-modes.mdx`.
270
+
271
+ ### HIGH inferring in-flight runs from `run.started − run.completed`
272
+
273
+ Wrong:
274
+
275
+ ```ts
276
+ const inFlight = counts['run.started'] - counts['run.completed'] // wildly overcounts
277
+ ```
278
+
279
+ Correct:
280
+
281
+ ```ts
282
+ const inFlight = counts['run.started'] - (counts['run.completed'] + counts['run.failed'])
283
+ // or read the store: listRuns({ status: ['running', 'suspended'] })
284
+ ```
285
+
286
+ Mechanism: `run.suspended` fires on EVERY park — each sleep, signal wait, child
287
+ wait — and suspended runs have not completed; counting starts minus completions
288
+ treats every parked run as finished-or-inflight incorrectly either way. The docs
289
+ warn: "Never count run.started against run.completed to infer 'runs in flight'".
290
+ Source: `docs/observability/events-and-interceptors.mdx`.
291
+
292
+ ### MEDIUM reading an empty `workerHealth()` as a healthy fleet
293
+
294
+ Wrong:
295
+
296
+ ```ts
297
+ const stalled = (await engine.workerHealth()).some((g) => g.stalled) // always false on queue/db
298
+ ```
299
+
300
+ Correct:
301
+
302
+ ```ts
303
+ const stale = filterStale(
304
+ await attachLiveness(engine, await listRuns(engine, { statuses: ['running', 'suspended'], limit: 200 })),
305
+ DEFAULT_STALE_MS,
306
+ )
307
+ ```
308
+
309
+ Mechanism: group health is BullMQ-only — on `queue`, `db` or `eventEmitter`,
310
+ `workerHealth()` returns `[]`, not an error, so "no stalled groups reported" says
311
+ nothing. Use the stranded-signature query instead. Source:
312
+ `docs/reliability/failure-modes.mdx`.
313
+
314
+ ### MEDIUM writing non-idempotent saga compensations
315
+
316
+ Wrong:
317
+
318
+ ```ts
319
+ { compensate: () => payments.refund(order.customerId, order.totalCents) } // refunds again on retry
320
+ ```
321
+
322
+ Correct:
323
+
324
+ ```ts
325
+ { compensate: () => payments.refundOnce(order.customerId, order.totalCents) } // keyed, idempotent
326
+ ```
327
+
328
+ Mechanism: compensations may run more than once (`compensationRetries`) and are
329
+ reconstructed from history across crashes; refund/release must no-op when
330
+ already applied or a retry double-refunds. Source: `docs/reliability/sagas.mdx`.
331
+
332
+ ### MEDIUM expecting `failFast` fan-out to clean up siblings
333
+
334
+ Wrong:
335
+
336
+ ```ts
337
+ await ctx.all(ProcessItemWorkflow, items, { mode: 'failFast' }) // assumes undone work
338
+ ```
339
+
340
+ Correct:
341
+
342
+ ```ts
343
+ // Give ProcessItemWorkflow its own compensations; cancel deliberately with:
344
+ await engine.cancel(childRunId, { compensate: true })
345
+ ```
346
+
347
+ Mechanism: failFast cancellation is best-effort and carries NO saga — a sibling
348
+ mid-step observes cancellation only at its next checkpoint. Source:
349
+ `docs/authoring/child-workflows.mdx` (warning callout).
350
+
351
+ See also: `durable-transports-stores` — claim/stall semantics behind these nets.
352
+ See also: `durable-cluster` — where bodies actually execute in a fleet.
@@ -0,0 +1,293 @@
1
+ ---
2
+ name: durable-setup
3
+ description: >-
4
+ Install and configure @adonis-agora/durable in an AdonisJS app: node ace add,
5
+ config/durable.ts with defineConfig and driver-by-name transports/stores maps
6
+ (memory, event-emitter, queue, db, bullmq; lucid store), the WorkflowEngine
7
+ container singleton from @adonis-agora/durable/services/main,
8
+ Workflow.dispatch vs .start, where runs execute (in-process dispatcher,
9
+ NOOP_RUN_DISPATCHER, durable:work), consumers, leaseMs. Use when setting up
10
+ durable workflows for the first time, moving from dev defaults to production
11
+ infrastructure, or diagnosing "runs vanish on restart" / "workflows compete
12
+ with HTTP traffic".
13
+ license: MIT
14
+ metadata:
15
+ type: core
16
+ library: "@adonis-agora/durable"
17
+ library_version: "0.25.0"
18
+ framework: adonisjs
19
+ sources:
20
+ - 'DavideCarvalho/adonis-durable:docs/getting-started.mdx'
21
+ - 'DavideCarvalho/adonis-durable:docs/concepts/durability.mdx'
22
+ - 'DavideCarvalho/adonis-durable:docs/transports/index.mdx'
23
+ - 'DavideCarvalho/adonis-durable:docs/reliability/failure-modes.mdx'
24
+ - 'DavideCarvalho/adonis-durable:README.md'
25
+ ---
26
+
27
+ # Setting up @adonis-agora/durable
28
+
29
+ `@adonis-agora/durable` is a durable workflow engine for AdonisJS: workflows are
30
+ plain async code whose steps are checkpointed, so a crash or deploy resumes from
31
+ the last checkpoint instead of starting over. The published `config/durable.ts`
32
+ defaults to zero infrastructure (in-process transport + in-memory store) — that
33
+ default is for development only.
34
+
35
+ ## Setup
36
+
37
+ Install via ace (registers `@adonis-agora/durable/durable_provider` in
38
+ `adonisrc.ts` and publishes `config/durable.ts`):
39
+
40
+ ```bash
41
+ node ace add @adonis-agora/durable
42
+ ```
43
+
44
+ The provider binds a singleton `WorkflowEngine` built from the config. Import it
45
+ anywhere with `import engine from '@adonis-agora/durable/services/main'`.
46
+
47
+ ```ts title="config/durable.ts"
48
+ import { defineConfig, stores, transports } from '@adonis-agora/durable'
49
+
50
+ export default defineConfig({
51
+ transport: 'event-emitter',
52
+ transports: {
53
+ memory: transports.memory(),
54
+ 'event-emitter': transports.eventEmitter(),
55
+ // queue: transports.queue({ connection: 'redis' }), // names a config/queue.ts adapter
56
+ // db: transports.db(),
57
+ },
58
+ store: 'lucid',
59
+ stores: { lucid: stores.lucid() },
60
+ })
61
+ ```
62
+
63
+ Define a workflow class under `app/workflows/` (auto-registered at boot):
64
+
65
+ ```ts title="app/workflows/checkout_workflow.ts"
66
+ import { BaseWorkflow } from '@adonis-agora/durable'
67
+ import type { WorkflowCtx } from '@adonis-agora/durable'
68
+
69
+ export default class CheckoutWorkflow extends BaseWorkflow {
70
+ static workflow = { name: 'checkout', version: '1' }
71
+
72
+ async run(ctx: WorkflowCtx, order: { id: number; total: number }) {
73
+ await ctx.localStep('reserveStock', async () => ({ reserved: true }))
74
+ const approval = await ctx.waitForSignal<{ approved: boolean }>(`approve:${order.id}`)
75
+ if (!approval.approved) return { status: 'rejected' }
76
+ return { status: 'shipped' }
77
+ }
78
+ }
79
+ ```
80
+
81
+ Start a run from a controller — `dispatch` enqueues and returns immediately:
82
+
83
+ ```ts title="app/controllers/checkout_controller.ts"
84
+ import type { HttpContext } from '@adonisjs/core/http'
85
+ import CheckoutWorkflow from '#workflows/checkout_workflow'
86
+ import Order from '#models/order'
87
+
88
+ export default class CheckoutController {
89
+ async store({ request, response }: HttpContext) {
90
+ const total = request.input('total') as number
91
+ const order = await Order.create({ total })
92
+ const { runId } = await CheckoutWorkflow.dispatch(
93
+ { id: order.id, total: order.total },
94
+ { runId: `checkout:${order.id}` }, // stable runId = idempotency key
95
+ )
96
+ return response.accepted({ runId })
97
+ }
98
+ }
99
+ ```
100
+
101
+ Source: `docs/getting-started.mdx`, `docs/index.mdx`.
102
+
103
+ ## Core patterns
104
+
105
+ ### Pattern 1 — production single-process config
106
+
107
+ For a deployed app that stays one process, pair the Lucid store with the
108
+ `eventEmitter` transport. Checkpoints survive restarts in Postgres while steps
109
+ never leave the process:
110
+
111
+ ```ts title="config/durable.ts"
112
+ import { defineConfig, stores, transports } from '@adonis-agora/durable'
113
+
114
+ export default defineConfig({
115
+ transport: 'event-emitter',
116
+ transports: { 'event-emitter': transports.eventEmitter() },
117
+ store: 'lucid',
118
+ stores: { lucid: stores.lucid() },
119
+ leaseMs: 30_000,
120
+ })
121
+ ```
122
+
123
+ Run `node ace durable:work` alongside (or rely on the embedded worker) so
124
+ recovery, timers and schedules are driven even if you later split processes.
125
+ Source: `docs/transports/index.mdx`.
126
+
127
+ ### Pattern 2 — split web from workers
128
+
129
+ `engine.start()` never blocks the caller, but by default the body executes on a
130
+ microtask **on the calling instance**. On an API/dashboard pod that means heavy
131
+ runs compete with HTTP traffic and the DB pool. Pass `NOOP_RUN_DISPATCHER` so
132
+ the API pod only creates runs, and let `durable:work` execute them:
133
+
134
+ ```ts title="config/durable.ts (API/dashboard pod)"
135
+ import { NOOP_RUN_DISPATCHER, defineConfig, stores, transports } from '@adonis-agora/durable'
136
+
137
+ export default defineConfig({
138
+ transport: 'queue',
139
+ transports: { queue: transports.queue({ connection: 'redis' }) },
140
+ store: 'lucid',
141
+ stores: { lucid: stores.lucid() },
142
+ runDispatcher: NOOP_RUN_DISPATCHER, // create runs; never execute bodies here
143
+ })
144
+ ```
145
+
146
+ ```bash title="a separate worker process"
147
+ node ace durable:work --interval=500 --drainTimeout=15000
148
+ ```
149
+
150
+ Each `durable:work` tick runs `engine.runPending()` → `recoverIncomplete()` →
151
+ `resumeDueTimers()` → `sweepTimeouts()` → fires due schedules, then drains on
152
+ `SIGINT`/`SIGTERM`. Source: `docs/concepts/durability.mdx`,
153
+ `docs/reliability/failure-modes.mdx`, `docs/cli.mdx`.
154
+
155
+ ### Pattern 3 — dispatch vs start vs waitForRun
156
+
157
+ One rule: `.dispatch(input)` = fire-and-forget (`{ runId }`); `.start(input)` =
158
+ block until the run settles (terminal state or suspended) and get the result;
159
+ `engine.waitForRun(runId)` resolves a previously started run.
160
+
161
+ ```ts
162
+ const { runId } = await OrderWorkflow.dispatch(order) // hot path — respond now
163
+ const result = await OrderWorkflow.start(order) // scripts / short runs only
164
+ await engine.start('order', order, runId)
165
+ const settled = await engine.waitForRun(runId) // resolves on settle
166
+ ```
167
+
168
+ `.start` blocks the calling context — avoid it on a hot HTTP path for
169
+ long-running workflows. Source: `docs/getting-started.mdx`,
170
+ `docs/authoring/app-workflows.mdx`.
171
+
172
+ ### Pattern 4 — optional peers install only when selected
173
+
174
+ Every driver lazily imports its optional peer (`@adonisjs/lucid`,
175
+ `@adonisjs/queue`, `@adonisjs/redis`, `bullmq`, `@opentelemetry/api`) only when
176
+ that driver is named in config. Add the peer to the app that uses it:
177
+
178
+ ```bash
179
+ npm i @adonisjs/lucid # lucid store / db transport
180
+ node ace configure @adonisjs/lucid && node ace migration:run
181
+ npm i bullmq # bullmq transport (cross-ecosystem fleets)
182
+ npm i cron-parser # cron-based schedules
183
+ ```
184
+
185
+ `cron-parser` is needed wherever schedules with a `cron` field fire (the worker
186
+ host). Source: `docs/stores/lucid.mdx`, `docs/authoring/scheduling.mdx`,
187
+ `packages/adonis/package.json` (peerDependencies).
188
+
189
+ ## Common mistakes
190
+
191
+ ### CRITICAL shipping the in-memory default store
192
+
193
+ Wrong:
194
+
195
+ ```ts
196
+ // Works locally, then every run and checkpoint vanishes on restart/deploy.
197
+ export default defineConfig({
198
+ transport: 'memory',
199
+ transports: { memory: transports.memory() },
200
+ })
201
+ ```
202
+
203
+ Correct:
204
+
205
+ ```ts
206
+ export default defineConfig({
207
+ transport: 'event-emitter',
208
+ transports: { 'event-emitter': transports.eventEmitter() },
209
+ store: 'lucid',
210
+ stores: { lucid: stores.lucid() },
211
+ })
212
+ ```
213
+
214
+ Mechanism: with no `store` key the engine uses the in-memory store — no error,
215
+ no warning, just state loss across restarts. The in-memory store is documented
216
+ as "for tests and local dev. Not durable."
217
+ Source: `docs/stores/index.mdx`, `docs/getting-started.mdx`.
218
+
219
+ ### HIGH using `transports.memory()` as the single-process production driver
220
+
221
+ Wrong:
222
+
223
+ ```ts
224
+ transport: 'memory' // "it's all one process anyway"
225
+ ```
226
+
227
+ Correct:
228
+
229
+ ```ts
230
+ transport: 'event-emitter',
231
+ transports: { 'event-emitter': transports.eventEmitter() },
232
+ ```
233
+
234
+ Mechanism: `memory` executes the handler **inline inside the dispatch call**;
235
+ `eventEmitter` delivers on a later tick like a real broker, so code correct on
236
+ `eventEmitter` is correct on a broker too. The docs warn explicitly:
237
+ "memory is for tests; eventEmitter is the single-process production driver".
238
+ Source: `docs/transports/index.mdx`.
239
+
240
+ ### HIGH letting workflows share the API pod's event loop
241
+
242
+ Wrong:
243
+
244
+ ```ts
245
+ // API/dashboard pod, default config — a burst of CPU-heavy runs starves requests.
246
+ export default defineConfig({ transport: 'queue', transports: { /* … */ } })
247
+ ```
248
+
249
+ Correct:
250
+
251
+ ```ts
252
+ import { NOOP_RUN_DISPATCHER, defineConfig } from '@adonis-agora/durable'
253
+
254
+ export default defineConfig({
255
+ transport: 'queue',
256
+ transports: { /* … */ },
257
+ runDispatcher: NOOP_RUN_DISPATCHER,
258
+ })
259
+ ```
260
+
261
+ plus `node ace durable:work` as its own process. Mechanism: the default
262
+ in-process dispatcher executes bodies on the same instance, event loop and DB
263
+ pool as the HTTP handler that called `start()`; a saturated pool starves both.
264
+ Source: `docs/reliability/failure-modes.mdx` ("The in-process default
265
+ surprise"), `docs/concepts/durability.mdx`.
266
+
267
+ ### MEDIUM expecting a `node ace` script to await a remote step result
268
+
269
+ Wrong:
270
+
271
+ ```ts
272
+ // inside `node ace run-report` — hangs forever on a queue/db/bullmq transport
273
+ const { runId } = await OrderWorkflow.dispatch(input)
274
+ const result = await engine.waitForRun(runId)
275
+ ```
276
+
277
+ Correct:
278
+
279
+ ```ts
280
+ export default defineConfig({
281
+ // …
282
+ consumers: 'always', // opt this process into broker consumption
283
+ })
284
+ ```
285
+
286
+ —or don't round-trip inline: start the run, exit, let the worker fleet finish
287
+ it. Mechanism: console/repl processes deliberately do not start broker consumer
288
+ loops (they would claim jobs and die holding them), so nothing receives results
289
+ in that process unless `consumers: 'always'`.
290
+ Source: `docs/transports/queue.mdx` ("consumers").
291
+
292
+ See also: `durable-transports-stores` — picking drivers, namespaces, migrations.
293
+ See also: `durable-workflows` — authoring the workflow classes you just wired.