@adonis-agora/durable 0.9.1 → 0.10.0

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.
@@ -67,6 +67,9 @@ export class WorkflowEngine {
67
67
  namespace;
68
68
  leaseMs;
69
69
  maxRecoveryAttempts;
70
+ reconcileMs;
71
+ remoteRedispatchMs;
72
+ remoteRedispatchMax;
70
73
  webhookUrl;
71
74
  traceparent;
72
75
  context;
@@ -134,6 +137,11 @@ export class WorkflowEngine {
134
137
  this.pool.useNamespace(this.namespace);
135
138
  this.leaseMs = deps.leaseMs ?? 30_000;
136
139
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
140
+ // Default 5min; an explicit 0 disables the fallback (opt back into wake-forever-on-lost-event).
141
+ this.reconcileMs = deps.reconcileMs === undefined ? 300_000 : deps.reconcileMs || undefined;
142
+ // Opt-in: unset (or 0) leaves the by-design "re-suspend a lost dispatch, never re-dispatch" behavior.
143
+ this.remoteRedispatchMs = deps.remoteRedispatchMs || undefined;
144
+ this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
137
145
  this.webhookUrl = deps.webhookUrl;
138
146
  this.traceparent = deps.traceparent;
139
147
  this.context = deps.context;
@@ -695,15 +703,124 @@ export class WorkflowEngine {
695
703
  * inline — or null if the run is unknown. The dashboard "retry" goes through here so it can't block
696
704
  * the HTTP request on workflow execution.
697
705
  */
698
- async requeue(runId) {
706
+ async requeue(runId, _seen) {
707
+ const seen = _seen ?? new Set();
708
+ if (seen.has(runId))
709
+ return null;
710
+ seen.add(runId);
699
711
  const run = await this.store.getRun(runId);
700
712
  if (!run)
701
713
  return null;
714
+ // A FAILED/DEAD run's replay deterministically re-throws its recorded failures — an exhausted
715
+ // failed checkpoint rethrows, and an awaited child's failure completion rethrows through
716
+ // unwrapCompletion — so "retry" without resetting that state is a no-op that re-fails in
717
+ // milliseconds. Reset the failure state first so replay RE-EXECUTES the failed parts:
718
+ // - a `failed` checkpoint becomes retryable-now (attempts 0, wake immediately, `retryable`
719
+ // forced back on): the durable-retry machinery re-dispatches it as a fresh first attempt;
720
+ // - an awaited-child `signal:child:` checkpoint holding a FAILURE completion returns to its
721
+ // live `running` placeholder, so replay re-registers the `child:<id>` waiter — a separately
722
+ // retried child's completion (already buffered, or still to come) then resumes this run.
723
+ // Retry child and parent in either order; signal buffering makes it converge.
724
+ if (run.status === 'failed' || run.status === 'dead') {
725
+ const isFailureCompletion = (v) => typeof v === 'object' && v !== null && v.ok === false;
726
+ for (const cp of await this.store.listCheckpoints(runId)) {
727
+ if (cp.status === 'failed') {
728
+ await this.store.saveCheckpoint({
729
+ ...cp,
730
+ attempts: 0,
731
+ wakeAt: this.clock(),
732
+ ...(cp.error ? { error: { ...cp.error, retryable: true } } : {}),
733
+ });
734
+ }
735
+ else if (cp.kind === 'signal' &&
736
+ cp.name.startsWith('signal:child:') &&
737
+ cp.status === 'completed' &&
738
+ isFailureCompletion(cp.output)) {
739
+ await this.store.saveCheckpoint({ ...cp, status: 'running', output: undefined });
740
+ // CASCADE: retrying only the parent is useless in a live engine — replay re-registers
741
+ // the `child:<id>` waiter and the reconciler re-delivers the child's still-FAILED
742
+ // terminal state within seconds, re-failing the parent with the same error. Requeue the
743
+ // failed child too so the pair converges: whichever finishes the handshake last (waiter
744
+ // registered vs completion buffered) resumes the parent.
745
+ //
746
+ // Exception: a SUCCESS already buffered on this token (a completed `~retry~` fix of the
747
+ // child — see notifyParent's origin delivery) is the outcome the replay will consume, so
748
+ // re-running the failed origin would be pure waste. Peek = take + re-buffer.
749
+ const childId = cp.name.slice('signal:child:'.length);
750
+ // Drain-and-restore keeps relative order (a partial take+re-buffer would rotate it).
751
+ const parked = [];
752
+ for (;;) {
753
+ const buffered = await this.store.takeBufferedSignal(`child:${childId}`);
754
+ if (!buffered)
755
+ break;
756
+ parked.push(buffered.payload);
757
+ }
758
+ for (const payload of parked)
759
+ await this.store.bufferSignal(`child:${childId}`, payload);
760
+ const bufferedSuccess = parked.some((payload) => typeof payload === 'object' &&
761
+ payload !== null &&
762
+ payload.ok === true);
763
+ if (!bufferedSuccess) {
764
+ const child = await this.store.getRun(childId);
765
+ if (child && (child.status === 'failed' || child.status === 'dead')) {
766
+ await this.requeue(childId, seen);
767
+ }
768
+ }
769
+ }
770
+ }
771
+ }
702
772
  await this.store.releaseRunLock(runId);
703
- await this.store.updateRun(runId, { status: 'pending', updatedAt: new Date() });
773
+ // Explicit `error: undefined` CLEARS the stale failure (the store spreads the patch over the
774
+ // run) — otherwise the dashboard keeps showing the old error while the run is re-executing.
775
+ await this.store.updateRun(runId, {
776
+ status: 'pending',
777
+ error: undefined,
778
+ updatedAt: new Date(),
779
+ });
704
780
  await this.runDispatcher.dispatch(runId);
705
781
  return { runId, status: 'pending' };
706
782
  }
783
+ /**
784
+ * Explicitly re-dispatch every remote step of `runId` stuck `pending` — the OPERATOR escape hatch for
785
+ * a run whose dispatched step job was LOST (worker crashed with no result, or a transport that dropped
786
+ * the job). No automatic recovery re-drives these: a reconcile re-drive re-suspends a still-pending
787
+ * step by design (unless `remoteRedispatchMs` is set), `recoverIncomplete` only reclaims LEASED runs,
788
+ * and `requeue` just replays back to the pending guard. This re-enqueues the same `stepId` so the
789
+ * (idempotent) step re-runs and its result resumes the run. Safe to call on a healthy run — it re-runs
790
+ * only in-flight `pending` remote steps, which are idempotent by the durable contract. Returns the
791
+ * run's current status (with the count re-dispatched), or null if the run is unknown.
792
+ */
793
+ async redispatchPending(runId) {
794
+ const run = await this.store.getRun(runId);
795
+ if (!run)
796
+ return null;
797
+ const pending = (await this.store.listCheckpoints(runId)).filter((cp) => cp.kind === 'remote' && cp.status === 'pending');
798
+ let redispatched = 0;
799
+ for (const cp of pending) {
800
+ if (!cp.stepId)
801
+ continue;
802
+ const reAttempt = cp.attempts + 1;
803
+ const reEnqueuedAt = new Date();
804
+ await this.store.saveCheckpoint({
805
+ ...cp,
806
+ attempts: reAttempt,
807
+ enqueuedAt: reEnqueuedAt,
808
+ startedAt: reEnqueuedAt,
809
+ finishedAt: reEnqueuedAt,
810
+ });
811
+ await this.dispatchRemoteTask({
812
+ runId,
813
+ seq: cp.seq,
814
+ name: cp.name,
815
+ stepId: cp.stepId,
816
+ group: cp.workerGroup ?? sanitizeQueueToken(cp.name),
817
+ input: cp.input,
818
+ attempt: reAttempt,
819
+ });
820
+ redispatched += 1;
821
+ }
822
+ return { runId, status: run.status, redispatched };
823
+ }
707
824
  /**
708
825
  * **Fix-and-replay**: re-run a run (typically a `dead`/`failed` one) with a corrected `input`, as a
709
826
  * fresh run with clean history. It's a NEW run — `newRunId` defaults to `<runId>~retry~<uuid>` — so
@@ -982,6 +1099,15 @@ export class WorkflowEngine {
982
1099
  */
983
1100
  notifyParent(runId, completion) {
984
1101
  void this.signal(`child:${runId}`, completion).catch(() => undefined);
1102
+ // A fix-and-replay run (`<origin>~retry~<hash>`) is standalone, but its SUCCESS is the
1103
+ // origin's outcome for all practical purposes: deliver it on the ORIGIN's token too, so a
1104
+ // parent that failed on that child and is retried later consumes this success (buffered or
1105
+ // live) instead of waiting on a child nobody re-runs. Failures stay retry-only — a failed
1106
+ // fix attempt must not poison the origin's token.
1107
+ const at = runId.lastIndexOf('~retry~');
1108
+ if (at !== -1 && completion.ok) {
1109
+ void this.signal(`child:${runId.slice(0, at)}`, completion).catch(() => undefined);
1110
+ }
985
1111
  }
986
1112
  /**
987
1113
  * Deferred child start shared by the in-process ctx host and the remote `startChild` command.
@@ -1393,7 +1519,11 @@ export class WorkflowEngine {
1393
1519
  if (latest?.status === 'cancelled') {
1394
1520
  return { runId: run.id, status: 'cancelled', error: latest.error };
1395
1521
  }
1396
- await this.store.updateRun(run.id, { status: 'suspended', wakeAt: outcome.wakeAt, updatedAt });
1522
+ await this.store.updateRun(run.id, {
1523
+ status: 'suspended',
1524
+ wakeAt: this.reconcileWakeAt(outcome.wakeAt),
1525
+ updatedAt,
1526
+ });
1397
1527
  this.emit({
1398
1528
  type: 'run.suspended',
1399
1529
  runId: run.id,
@@ -1402,6 +1532,17 @@ export class WorkflowEngine {
1402
1532
  });
1403
1533
  return { runId: run.id, status: 'suspended' };
1404
1534
  }
1535
+ /**
1536
+ * A wakeAt to persist on a suspend: the natural timer if the turn produced one (a `ctx.sleep`, a
1537
+ * remote-step deadline), else the {@link reconcileMs} fallback so an event-waiting run (child/signal/
1538
+ * timeout-less remote step) can't be orphaned forever if its wake is lost. `undefined` only when the
1539
+ * fallback is disabled (`reconcileMs: 0`). See {@link WorkflowEngineDeps.reconcileMs}.
1540
+ */
1541
+ reconcileWakeAt(wakeAt) {
1542
+ if (wakeAt != null)
1543
+ return wakeAt;
1544
+ return this.reconcileMs != null ? this.clock() + this.reconcileMs : undefined;
1545
+ }
1405
1546
  async runRemoteExecution(run, registered) {
1406
1547
  const remote = registered.remote;
1407
1548
  if (run.status === 'pending') {
@@ -1899,6 +2040,12 @@ export class WorkflowEngine {
1899
2040
  startChild: (workflow, input, id, priority) => {
1900
2041
  this.startChildDeferred(workflow, input, id, { priority });
1901
2042
  },
2043
+ // Deferred for the same reentrancy reason as `startChild` above. `cancel()` is already
2044
+ // idempotent on a terminal/cancelled run (returns its existing status without side effects), so
2045
+ // no extra guard is needed here for the failFast replay case (re-issuing the same cancel calls).
2046
+ cancelChild: (childId) => {
2047
+ queueMicrotask(() => void this.cancel(childId).catch(() => undefined));
2048
+ },
1902
2049
  // Shallow-merge into the run's searchAttributes (the ctx primitive makes this exactly-once).
1903
2050
  upsertSearchAttributes: async (runId, attrs) => {
1904
2051
  const run = await this.store.getRun(runId);
@@ -1995,8 +2142,57 @@ export class WorkflowEngine {
1995
2142
  // pod can scale down or crash mid-step without losing the run or re-running completed work.
1996
2143
  if (step.timeoutMs)
1997
2144
  return this.callRemoteInMemory(runId, seq, step, input, transport);
1998
- if (existing?.status === 'pending')
1999
- throw new WorkflowSuspended(); // dispatched; keep waiting
2145
+ if (existing?.status === 'pending') {
2146
+ // Dispatched; normally we just keep waiting for the result to resume the run. But a LOST dispatch
2147
+ // (worker crashed with no result, or the transport dropped the job) would hang here forever — a
2148
+ // reconcile re-drive replays straight back to this guard. Opt-in self-heal: once the step has been
2149
+ // pending longer than `remoteRedispatchMs`, re-dispatch the same stepId (the idempotent step
2150
+ // re-runs, its result resumes the run), bounded by `remoteRedispatchMax` so a never-settling step
2151
+ // fails instead of looping. Unset (default) keeps the by-design "re-suspend, never re-dispatch".
2152
+ if (this.remoteRedispatchMs == null)
2153
+ throw new WorkflowSuspended();
2154
+ // Stamp a redispatch deadline (clock-space, persisted) the first time we see this pending step,
2155
+ // stable across replays and crashes — mirrors the failed-retry backoff below. The run's wakeAt
2156
+ // becomes this deadline, so a reconcile re-drive lands exactly when it's due to re-dispatch.
2157
+ if (existing.wakeAt == null) {
2158
+ const wakeAt = this.clock() + this.remoteRedispatchMs;
2159
+ await this.store.saveCheckpoint({ ...existing, wakeAt });
2160
+ throw new WorkflowSuspended(wakeAt);
2161
+ }
2162
+ if (this.clock() < existing.wakeAt)
2163
+ throw new WorkflowSuspended(existing.wakeAt);
2164
+ // Past the deadline with no result — the dispatch is presumed lost. Re-dispatch (bounded by
2165
+ // `remoteRedispatchMax` so a step that never settles fails the run instead of looping forever).
2166
+ if (existing.attempts >= this.remoteRedispatchMax) {
2167
+ throw new RemoteStepError({
2168
+ message: `remote step "${step.name}" lost — no result after ${existing.attempts} re-dispatch(es)`,
2169
+ code: 'remote_step_lost',
2170
+ });
2171
+ }
2172
+ const reAttempt = existing.attempts + 1;
2173
+ const nextDeadline = this.clock() + this.remoteRedispatchMs;
2174
+ const reEnqueuedAt = new Date();
2175
+ await this.store.saveCheckpoint({
2176
+ ...existing,
2177
+ attempts: reAttempt,
2178
+ wakeAt: nextDeadline,
2179
+ enqueuedAt: reEnqueuedAt,
2180
+ startedAt: reEnqueuedAt,
2181
+ finishedAt: reEnqueuedAt,
2182
+ });
2183
+ await this.dispatchRemoteTask({
2184
+ runId,
2185
+ seq,
2186
+ name: step.name,
2187
+ stepId: existing.stepId ?? stepId(runId, seq),
2188
+ group: existing.workerGroup ?? tenantGroup(sanitizeQueueToken(step.name), step.partition),
2189
+ input: existing.input,
2190
+ priority: admission?.priority,
2191
+ attempt: reAttempt,
2192
+ transport,
2193
+ });
2194
+ throw new WorkflowSuspended(nextDeadline);
2195
+ }
2000
2196
  // Durable retry: a failed attempt re-dispatches up to `retries`, spacing attempts by `backoff` —
2001
2197
  // unless the worker marked the error non-retryable (a deterministic verdict like a declined card).
2002
2198
  // The retry deadline is stamped on the failed checkpoint as `wakeAt` (clock-space, persisted) the
@@ -2063,21 +2259,39 @@ export class WorkflowEngine {
2063
2259
  startedAt: enqueuedAt, // placeholders until the worker result lands
2064
2260
  finishedAt: enqueuedAt,
2065
2261
  });
2066
- await this.pool.dispatch({
2262
+ await this.dispatchRemoteTask({
2067
2263
  runId,
2068
2264
  seq,
2069
2265
  name: step.name,
2070
2266
  stepId: id,
2071
2267
  group: token,
2072
2268
  input: validInput,
2073
- traceparent: this.traceparent?.(),
2074
- context: this.context?.(),
2075
2269
  priority: admission?.priority,
2076
2270
  attempt,
2077
- }, transport);
2271
+ transport,
2272
+ });
2078
2273
  this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
2079
2274
  throw new WorkflowSuspended();
2080
2275
  }
2276
+ /**
2277
+ * Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
2278
+ * dispatch, the opt-in self-heal re-dispatch (see `remoteRedispatchMs`), and the explicit
2279
+ * {@link redispatchPending}. Carries the current trace/context so a re-dispatch is traceable.
2280
+ */
2281
+ dispatchRemoteTask(task) {
2282
+ return this.pool.dispatch({
2283
+ runId: task.runId,
2284
+ seq: task.seq,
2285
+ name: task.name,
2286
+ stepId: task.stepId,
2287
+ group: task.group,
2288
+ input: task.input,
2289
+ traceparent: this.traceparent?.(),
2290
+ context: this.context?.(),
2291
+ priority: task.priority,
2292
+ attempt: task.attempt,
2293
+ }, task.transport);
2294
+ }
2081
2295
  /**
2082
2296
  * Complete a durable remote step from its worker result and resume the run — runs on whichever
2083
2297
  * instance receives the result (the dispatching one may be gone), so the run is crash/scale-safe.