@adonis-agora/durable 0.40.0 → 0.41.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 CHANGED
@@ -1,5 +1,69 @@
1
1
  # @adonis-agora/durable
2
2
 
3
+ ## 0.41.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#202](https://github.com/DavideCarvalho/adonis-agora-durable/pull/202) [`573a198`](https://github.com/DavideCarvalho/adonis-agora-durable/commit/573a198ae5e1e69503f3a37d3b9817da07b038c4) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - A run is no longer counted, dead-lettered or executed twice when its turn settles while the orphan sweep is looking at it.
8
+
9
+ Port of the same race fixed in the NestJS core (nestjs-durable#331), seen there as intermittently hung durable chat turns where an in-process worker answers a dispatched step in a couple of milliseconds:
10
+
11
+ - **A turn releases its lease only after its settled state is written.** `runExecution` returned `this.settleRun(...)` (and `this.parkBlocked(...)`) without awaiting it inside `try … finally { releaseRunLock }`, so the `finally` ran first: a first turn that completed, failed or parked `blocked` sat unlocked while its row still read `running`, which is what `listOrphanedRuns` returns for a crashed turn. Every settle in that block is now `return await`ed. (The suspend settle was already awaited.)
12
+ - **`recoverIncomplete` decides on the run it locked, not the one it listed.** It trusted the listed `running` status: a run that settled between the listing and `tryLockRun` got a recovery attempt counted and, past `maxRecoveryAttempts`, an unconditional `dead` written over a completed or suspended run. It now re-reads the run under the lease and releases it again unless it still reads `running`. The dead-letter write is also conditional on `running`, so a lease-free `cancel()` landing after that re-read is not overwritten with `dead`.
13
+ - **A replay re-reads a `pending` remote step before acting on it.** With `remoteRedispatchMs` set, a replay that found its step pending in its start-of-execution snapshot stamped the lease by writing `{ ...snapshot, wakeAt }` back, overwriting a result that had landed since with `pending` and parking the run on a lease an hour out. The step is now re-read from the store first; a result that has landed is replayed instead.
14
+
15
+ ## 0.41.0
16
+
17
+ ### Minor Changes
18
+
19
+ - [#198](https://github.com/DavideCarvalho/adonis-agora-durable/pull/198) [`2b74239`](https://github.com/DavideCarvalho/adonis-agora-durable/commit/2b74239efc6defb62d7f978d8df060384791447a) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Re-drive a `gather_calls` step whose worker was killed mid-step, instead of orphaning it forever.
20
+
21
+ A polyglot workflow's turn declares its fan-out as `call` commands; `applyCommands` writes a
22
+ `pending` checkpoint and dispatches each one. On every later turn the worker's replay RE-EMITS the
23
+ calls it is still waiting on, and the engine skips a command whose checkpoint already exists — the
24
+ guard that stops a partially-settled fan-out from double-dispatching its live siblings.
25
+
26
+ That guard had no notion of a LOST job. A worker killed mid-step (an OOM kill) takes its in-flight
27
+ job with it, and the transport cannot always put it back: the `bullmq` transport's terminal-failure
28
+ bridge (`worker.on('failed')`) only exists inside a JS consumer, so a first-class Python fleet (see
29
+ `docs/python.mdx`) has none; the `queue` transport's stalled-claim sweep gives up past
30
+ `maxStalledCount`, and never runs at all on an adapter without `recoverStalledJobs`. Nothing then
31
+ publishes a `StepResult`, so the checkpoint stayed `pending` — "out for delivery" — and every turn
32
+ skipped it: the run woke on the `reconcileMs` sweep, dispatched a turn, had the calls re-emitted,
33
+ skipped them, and slept. Forever, with `attempts` never leaving 1 and the checkpoint's `wakeAt` NULL.
34
+
35
+ Those two tells named the gap exactly. `callRemote` (the `ctx.step` path) stamps a re-dispatch
36
+ deadline on the pending checkpoint's `wakeAt` and honours `remoteRedispatchMs`; the `call` path did
37
+ neither — so the self-heal `docs/reliability/failure-modes.mdx` documents as acting "on the
38
+ checkpoint regardless of which transport lost the job" **did not exist for a fan-out**, no matter how
39
+ it was configured.
40
+
41
+ Now the two paths share one policy, and a pending remote checkpoint's `wakeAt` means the same thing
42
+ in both: the step's LEASE.
43
+
44
+ - A settled checkpoint always wins — a step that completed just before the crash is never re-run.
45
+ - `remoteRedispatchMs` unset (still the default) keeps the by-design "re-suspend, never
46
+ re-dispatch": a merely-slow worker is never double-run.
47
+ - Set it and the dispatched step carries a lease, and the run suspends ON it (never later than the
48
+ `reconcileMs` sweep would have woken it anyway). Only a LAPSED lease re-dispatches, bounded by
49
+ `remoteRedispatchMax` (default 10); past the bound the step is failed `remote_step_lost`, which
50
+ enters the run's history so the workflow's own error path surfaces it rather than the engine
51
+ looping.
52
+ - A step-scoped heartbeat now RENEWS that lease durably, so a worker still holding a long step keeps
53
+ it — the in-memory rearm only ever protected a `timeoutMs` step, and only on the instance that
54
+ dispatched it. The renewal is throttled by the lease itself (a write only once the window is half
55
+ spent), so a worker beating every few seconds costs one read + one write per half
56
+ `remoteRedispatchMs`, not one per beat — the same concern that throttles the liveness write in
57
+ `persistHeartbeat`. And it is best-effort end to end: the renewal runs on the transport's SERIAL
58
+ heartbeat handler, so no read or write of it can reject into that loop — a store hiccup that took the
59
+ beat loop down would lapse every lease at once.
60
+ - Observability: a re-drive emits `step.started` with `redispatched: true` and appends a `warn`
61
+ `step.redispatched` event to the checkpoint's own trail (`step.lost` at the bound), so "re-driven
62
+ after a lost worker" reads differently from a failure retry — in the dashboard and in the database.
63
+
64
+ No schema change and no new config knob: `remoteRedispatchMs` / `remoteRedispatchMax` (already
65
+ carried through from `config/durable.ts`) now simply mean what they say for a fan-out too.
66
+
3
67
  ## 0.40.0
4
68
 
5
69
  ### Minor Changes