@adonis-agora/durable 0.56.4 → 0.56.6

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/src/codec-state-store.d.ts +3 -0
  3. package/dist/src/codec-state-store.d.ts.map +1 -1
  4. package/dist/src/codec-state-store.js +7 -0
  5. package/dist/src/codec-state-store.js.map +1 -1
  6. package/dist/src/dashboard/index.d.ts +1 -1
  7. package/dist/src/dashboard/index.js +1 -1
  8. package/dist/src/dispatch-key.d.ts +27 -0
  9. package/dist/src/dispatch-key.d.ts.map +1 -0
  10. package/dist/src/dispatch-key.js +27 -0
  11. package/dist/src/dispatch-key.js.map +1 -0
  12. package/dist/src/engine.d.ts +97 -6
  13. package/dist/src/engine.d.ts.map +1 -1
  14. package/dist/src/engine.js +322 -41
  15. package/dist/src/engine.js.map +1 -1
  16. package/dist/src/index.d.ts +1 -1
  17. package/dist/src/index.js +1 -1
  18. package/dist/src/interfaces.d.ts +21 -0
  19. package/dist/src/interfaces.d.ts.map +1 -1
  20. package/dist/src/interfaces.js.map +1 -1
  21. package/dist/src/protocol.d.ts +3 -0
  22. package/dist/src/protocol.d.ts.map +1 -1
  23. package/dist/src/protocol.js +21 -3
  24. package/dist/src/protocol.js.map +1 -1
  25. package/dist/src/stores/lucid.d.ts +1 -0
  26. package/dist/src/stores/lucid.d.ts.map +1 -1
  27. package/dist/src/stores/lucid.js +12 -0
  28. package/dist/src/stores/lucid.js.map +1 -1
  29. package/dist/src/testing/in-memory-state-store.d.ts +1 -0
  30. package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
  31. package/dist/src/testing/in-memory-state-store.js +10 -0
  32. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  33. package/dist/src/transport-pool.d.ts.map +1 -1
  34. package/dist/src/transport-pool.js +4 -13
  35. package/dist/src/transport-pool.js.map +1 -1
  36. package/dist/src/transports/bullmq/bullmq-transport.d.ts +4 -2
  37. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  38. package/dist/src/transports/bullmq/bullmq-transport.js +140 -58
  39. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  40. package/dist/src/transports/worker-keys.d.ts +3 -1
  41. package/dist/src/transports/worker-keys.d.ts.map +1 -1
  42. package/dist/src/transports/worker-keys.js +23 -11
  43. package/dist/src/transports/worker-keys.js.map +1 -1
  44. package/package.json +1 -1
@@ -7,6 +7,7 @@ import { instantCheckpoint, lostStepEvent, stepCheckpoint } from './checkpoints.
7
7
  import { CHILD_DELIVERED_TAG, markChildDelivered } from './child-delivery.js';
8
8
  import { completionError, failedCompletion, isFailedCompletion, } from './completion.js';
9
9
  import { assertConcurrency, concurrencyTag, countRuns, resolveConcurrency, } from './concurrency.js';
10
+ import { DISPATCH_KEY, dispatchKey } from './dispatch-key.js';
10
11
  import { controlPlaneDescriptor, planDispatch, WorkflowBlocked, } from './dispatch-routing.js';
11
12
  import { parseDuration } from './duration.js';
12
13
  import { Entities } from './entities.js';
@@ -140,6 +141,19 @@ const BUFFERED_SWEEP_BUDGET_MS = 250;
140
141
  const BUFFERED_SWEEP_REST_MS = 10 * 60_000;
141
142
  /** The most results of one run settled by one batched write (see `completeRemoteResult`). */
142
143
  const RESULT_BATCH_MAX = 256;
144
+ /** The most runs one owed-resume write marks (see `WorkflowEngine.markResumeOwed`). */
145
+ const OWED_RESUME_BATCH_MAX = 500;
146
+ /**
147
+ * How long a settled step result's acknowledgement waits for the run's resume before it marks the
148
+ * run as owed one instead (see `WorkflowEngine.resumeOwed`). A turn that ends within it needs no mark.
149
+ */
150
+ const OWED_RESUME_GRACE_MS = 3_000;
151
+ /**
152
+ * The results `execute` returns when another instance holds the run's lease — no turn ran here, so the
153
+ * wake that asked for it is not served yet — each with the durable request that wake made instead
154
+ * (see `WorkflowEngine.resumeOwed`). By identity, so the result keeps the shape every caller sees.
155
+ */
156
+ const leaseRefused = new WeakMap();
143
157
  /**
144
158
  * How many runs this instance executes in-process at once when it runs them itself (the default
145
159
  * dispatcher without a run queue, deferred child starts): past it, run ids wait in memory in arrival
@@ -320,6 +334,10 @@ export class WorkflowEngine {
320
334
  pending = new Map();
321
335
  /** Each run's step results waiting to be settled on this instance — see {@link completeRemoteResult}. */
322
336
  resultQueues = new Map();
337
+ /** Runs waiting to be marked as owed a resume by the next batched write — see {@link markResumeOwed}. */
338
+ owedResumes = [];
339
+ /** Whether an owed-resume write is in flight (the runs marked meanwhile go out in the next). */
340
+ markingOwedResumes = false;
323
341
  /** Per-step "reset the liveness timer" callbacks, called with the beat's worker timestamp when a
324
342
  * heartbeat arrives. */
325
343
  heartbeatResets = new Map();
@@ -2196,8 +2214,13 @@ export class WorkflowEngine {
2196
2214
  }
2197
2215
  // A resume issued from INSIDE this run's own execution (its ambient ctx is this run) can only be
2198
2216
  // satisfied once that execution ends — awaiting it there would deadlock. Queue it, answer now.
2199
- if (workflowAls.getStore()?.runId === runId)
2200
- return this.echoCurrentStatus(runId);
2217
+ if (workflowAls.getStore()?.runId === runId) {
2218
+ // Queued, not run: the wake is not served yet (see `resumeOwed`).
2219
+ return this.echoCurrentStatus(runId).then((echoed) => {
2220
+ leaseRefused.set(echoed, Promise.resolve('failed'));
2221
+ return echoed;
2222
+ });
2223
+ }
2201
2224
  return queued.result;
2202
2225
  }
2203
2226
  /** Drop `gate` once `tail` settles, unless another execution queued behind it meanwhile. */
@@ -2597,8 +2620,22 @@ export class WorkflowEngine {
2597
2620
  // the contract instead: park it `suspended` on the step's deadline or the reconcile timer. The
2598
2621
  // worker's result resumes it; `resumeDueTimers` is the safety net. A LOST dispatch stays the job
2599
2622
  // of `remoteRedispatchMs` / `redispatchPending`.
2600
- const dispatched = await this.inFlightRemoteStep(run.id);
2623
+ // The durable marker of work on the transport: a `pending` remote checkpoint is written before
2624
+ // the dispatch and settled by the result.
2625
+ const checkpoints = await this.store.listCheckpoints(run.id);
2626
+ const dispatched = checkpoints.find((cp) => cp.kind === 'remote' && cp.status === 'pending');
2601
2627
  if (dispatched) {
2628
+ // `running` with a lapsed lease: the turn that wrote these checkpoints died before settling
2629
+ // the run, possibly before its dispatch reached the broker. Send what it may have lost.
2630
+ try {
2631
+ await this.redispatchCrashedTurnSteps(run, checkpoints, true);
2632
+ }
2633
+ catch (error) {
2634
+ // Not sent: leave the run `running` for the next pass rather than park it on a lost step.
2635
+ await this.store.releaseRunLock(run.id, this.instanceId).catch(() => undefined);
2636
+ console.error(`[adonis-durable] could not re-dispatch the steps of crashed run ${run.id}; retrying on a later tick:`, error);
2637
+ continue;
2638
+ }
2602
2639
  // A REMOTE workflow reads `running` only while a turn is being computed or applied, so a free
2603
2640
  // lease here means that turn was lost — the call in flight is not what the run waits on next.
2604
2641
  // Park it on a near wake so the timer pass re-drives the turn promptly (never later than the
@@ -2693,13 +2730,87 @@ export class WorkflowEngine {
2693
2730
  return released;
2694
2731
  }
2695
2732
  /**
2696
- * The remote step this run dispatched and still awaits a result for, if any — the durable marker
2697
- * that tells {@link recoverIncomplete} "this run's work is on the transport, not in a crashed
2698
- * process". A `pending` remote checkpoint is written before the dispatch and settled by the result.
2733
+ * {@link redispatchCrashedTurnSteps} for a `suspended` run a timer pass just leased, when the run as
2734
+ * listed shows the previous lease holder died holding it: a lease is released (both columns
2735
+ * cleared) by every path that ends normally, so one still set but lapsed is a holder that never
2736
+ * reached its release — a turn SIGKILLed mid-way, most likely. A resumed turn keeps the run
2737
+ * `suspended` while it executes, so this is the only trace such a crash leaves. Called with the
2738
+ * lease held; a failure is reported and does not stop the resume.
2699
2739
  */
2700
- async inFlightRemoteStep(runId) {
2701
- const checkpoints = await this.store.listCheckpoints(runId);
2702
- return checkpoints.find((cp) => cp.kind === 'remote' && cp.status === 'pending');
2740
+ async redispatchIfHolderCrashed(listed, nowMs) {
2741
+ if (listed.status !== 'suspended')
2742
+ return;
2743
+ if (listed.lockedBy === undefined || listed.lockedUntil === undefined)
2744
+ return;
2745
+ if (listed.lockedUntil > nowMs)
2746
+ return;
2747
+ try {
2748
+ await this.redispatchCrashedTurnSteps(listed, await this.store.listCheckpoints(listed.id));
2749
+ }
2750
+ catch (error) {
2751
+ console.error(`[adonis-durable] could not re-dispatch the steps of run ${listed.id} after its lease holder died; its turn proceeds:`, error);
2752
+ }
2753
+ }
2754
+ /**
2755
+ * Re-dispatch the remote steps a CRASHED turn of `run` wrote `pending` and may never have sent.
2756
+ *
2757
+ * A turn persists a step's `pending` checkpoint BEFORE handing the job to the transport (so a fast
2758
+ * result always finds it), then settles the run. A process that dies between the two leaves a
2759
+ * `pending` step no worker ever received — and every later turn replays straight back to "pending,
2760
+ * keep waiting" (re-dispatching on replay is the opt-in `remoteRedispatchMs`, because a step may
2761
+ * legitimately run longer than any window). Observed under a SIGKILLed pod: runs stuck forever.
2762
+ *
2763
+ * The caller holds the run's lease and has evidence the turn died; this narrows it to the steps
2764
+ * THAT turn wrote: the ones enqueued at or after the run's last settle (`updatedAt` — a turn that
2765
+ * ends normally settles after its dispatches, so a step it sent is always older than its settle).
2766
+ * `sinceInclusive` is for a run still `running`: its `updatedAt` is that same turn's start.
2767
+ *
2768
+ * Each step goes out again under its own `stepId` and attempt, keyed like its original dispatch
2769
+ * (see `dispatch-key.ts`): a broker that deduplicates by key (BullMQ) adds nothing while the original
2770
+ * job still waits or runs, or has just finished. Elsewhere a step whose original dispatch did land
2771
+ * runs twice (at-least-once, as documented), and the second result finds the checkpoint settled and
2772
+ * is dropped. Only on a crash — a healthy run never gets here. Remote (polyglot) workflows are left to
2773
+ * their own turn re-drive. Returns how many steps were sent.
2774
+ */
2775
+ async redispatchCrashedTurnSteps(run, checkpoints, sinceInclusive = false) {
2776
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
2777
+ this.latest.get(run.workflow);
2778
+ // No transport: nothing could have been dispatched, nothing can be sent again.
2779
+ if (registered?.remote || this.pool.size === 0)
2780
+ return 0;
2781
+ const since = run.updatedAt.getTime();
2782
+ const lost = checkpoints.filter((cp) => {
2783
+ if (cp.kind !== 'remote' || cp.status !== 'pending' || !cp.stepId)
2784
+ return false;
2785
+ const at = cp.enqueuedAt.getTime();
2786
+ return sinceInclusive ? at >= since : at > since;
2787
+ });
2788
+ for (const cp of lost) {
2789
+ await this.dispatchRemoteTask({
2790
+ runId: run.id,
2791
+ seq: cp.seq,
2792
+ name: cp.name,
2793
+ stepId: cp.stepId,
2794
+ group: cp.workerGroup ?? sanitizeQueueToken(cp.name),
2795
+ input: cp.input,
2796
+ attempt: cp.attempts,
2797
+ priority: run.priority,
2798
+ // The original dispatch's key: a broker that still holds that job adds nothing.
2799
+ enqueuedAt: cp.enqueuedAt,
2800
+ });
2801
+ this.emit({
2802
+ type: 'step.started',
2803
+ runId: run.id,
2804
+ seq: cp.seq,
2805
+ name: cp.name,
2806
+ kind: 'remote',
2807
+ redispatched: true,
2808
+ });
2809
+ }
2810
+ if (lost.length > 0) {
2811
+ console.warn(`[adonis-durable] re-dispatched ${lost.length} step(s) of run ${run.id} written by a turn that died before settling (a step that did reach a worker may run twice)`);
2812
+ }
2813
+ return lost.length;
2703
2814
  }
2704
2815
  /**
2705
2816
  * Per-recovery bookkeeping (called once the lease is held): count the attempt, or — past
@@ -2789,6 +2900,7 @@ export class WorkflowEngine {
2789
2900
  });
2790
2901
  if (!claimed)
2791
2902
  continue;
2903
+ await this.redispatchIfHolderCrashed(run, nowMs);
2792
2904
  }
2793
2905
  finally {
2794
2906
  await this.store.releaseRunLock(run.id, this.instanceId);
@@ -3162,6 +3274,7 @@ export class WorkflowEngine {
3162
3274
  return;
3163
3275
  }
3164
3276
  try {
3277
+ await this.redispatchIfHolderCrashed(run, nowMs);
3165
3278
  // A per-run hook (recovery counting / dead-lettering) may settle the run terminally instead.
3166
3279
  const settled = onLocked ? await onLocked(run) : undefined;
3167
3280
  results.push(settled ?? (await this.resumeImplicit(run.id)));
@@ -3896,21 +4009,12 @@ export class WorkflowEngine {
3896
4009
  });
3897
4010
  }
3898
4011
  /**
3899
- * A wake that found the run leased by another instance (its turn is executing) asks for another
3900
- * turn, durably: it stamps {@link WorkflowRun.wakeRequestedAt} on the run. The holder does not let
3901
- * go of the lease while the stamp is there ({@link releaseSuspendedLease}) and runs another turn,
3902
- * which sees what the wake wrote; and a `suspended` run carrying it is due for the timer poll, so
3903
- * the request survives both this instance and the holder going down.
3904
- *
3905
- * The holder may have let go between the refused lease and the stamp: nobody but the timer poll
3906
- * would see it then, so the lease is tried once more here. The in-memory re-check
3907
- * ({@link recheckAfterLease}) runs too, as the fast path when the holder goes down mid-turn; it
3908
- * stops as soon as a turn served the request.
4012
+ * The durable half of {@link requestWake}: keep the wake's reason, then stamp
4013
+ * {@link WorkflowRun.wakeRequestedAt} on the run. `stamped`; `gone` when the run is no longer live
4014
+ * (nothing to wake); `failed` when the stamp could not be kept (a schema without the column, a store
4015
+ * error). Never rejects.
3909
4016
  */
3910
- async requestWake(runId, lockedUntil, reason = { generic: true, fans: new Map() }) {
3911
- if (this.draining)
3912
- return;
3913
- let marked;
4017
+ async stampWakeRequest(runId, reason) {
3914
4018
  try {
3915
4019
  // The reason first, the stamp second: a holder that finds the stamp finds the reason too.
3916
4020
  await this.store
@@ -3921,16 +4025,37 @@ export class WorkflowEngine {
3921
4025
  publishedAt: this.clock(),
3922
4026
  })
3923
4027
  .catch(() => undefined);
3924
- marked = await this.store.updateRunIf(runId, ['running', 'suspended', 'blocked'], {
4028
+ const marked = await this.store.updateRunIf(runId, ['running', 'suspended', 'blocked'], {
3925
4029
  wakeRequestedAt: this.clock(),
3926
4030
  });
4031
+ return marked ? 'stamped' : 'gone';
3927
4032
  }
3928
4033
  catch {
4034
+ return 'failed';
4035
+ }
4036
+ }
4037
+ /**
4038
+ * A wake that found the run leased by another instance (its turn is executing) asks for another
4039
+ * turn, durably: it stamps {@link WorkflowRun.wakeRequestedAt} on the run. The holder does not let
4040
+ * go of the lease while the stamp is there ({@link releaseSuspendedLease}) and runs another turn,
4041
+ * which sees what the wake wrote; and a `suspended` run carrying it is due for the timer poll, so
4042
+ * the request survives both this instance and the holder going down.
4043
+ *
4044
+ * The holder may have let go between the refused lease and the stamp: nobody but the timer poll
4045
+ * would see it then, so the lease is tried once more here. The in-memory re-check
4046
+ * ({@link recheckAfterLease}) runs too, as the fast path when the holder goes down mid-turn; it
4047
+ * stops as soon as a turn served the request.
4048
+ */
4049
+ async requestWake(runId, lockedUntil, reason = { generic: true, fans: new Map() }, stamping) {
4050
+ if (!stamping && this.draining)
4051
+ return;
4052
+ const stamp = await (stamping ?? this.stampWakeRequest(runId, reason));
4053
+ if (stamp === 'failed') {
3929
4054
  // Not kept (a schema without the column, a store error): the in-memory re-check is all there is.
3930
4055
  this.recheckAfterLease(runId, lockedUntil);
3931
4056
  return;
3932
4057
  }
3933
- if (!marked)
4058
+ if (stamp === 'gone')
3934
4059
  return;
3935
4060
  // The fast path for a holder that went down mid-turn: look again in memory too (it stops once a
3936
4061
  // turn served the request).
@@ -4074,8 +4199,11 @@ export class WorkflowEngine {
4074
4199
  this.wakeReasons.delete(run.id);
4075
4200
  if (run.lockedBy !== this.instanceId || (run.lockedUntil ?? 0) <= lockNow) {
4076
4201
  if (!(await this.store.tryLockRun(run.id, this.instanceId, lockNow + this.leaseMs, lockNow))) {
4077
- this.trackEffect(this.requestWake(run.id, run.lockedUntil, reason));
4078
- return { runId: run.id, status: run.status };
4202
+ const stamping = this.draining ? undefined : this.stampWakeRequest(run.id, reason);
4203
+ this.trackEffect(this.requestWake(run.id, run.lockedUntil, reason, stamping));
4204
+ const refused = { runId: run.id, status: run.status };
4205
+ leaseRefused.set(refused, stamping ?? Promise.resolve('failed'));
4206
+ return refused;
4079
4207
  }
4080
4208
  }
4081
4209
  // This turn reads the run's checkpoints after this point, so it sees whatever the wakes that
@@ -6404,6 +6532,7 @@ export class WorkflowEngine {
6404
6532
  priority: admission?.priority,
6405
6533
  attempt,
6406
6534
  transport,
6535
+ enqueuedAt,
6407
6536
  });
6408
6537
  this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
6409
6538
  throw new WorkflowSuspended(undefined, [seq]);
@@ -6607,7 +6736,9 @@ export class WorkflowEngine {
6607
6736
  traceparent,
6608
6737
  context,
6609
6738
  priority: opts.admission?.priority,
6739
+ lazyPickupBeat: true,
6610
6740
  attempt: 1,
6741
+ [DISPATCH_KEY]: dispatchKey(cp.stepId, 1, enqueuedAt),
6611
6742
  })), opts.transport);
6612
6743
  // Awaited below; a rejection is not left unhandled while the next round is written.
6613
6744
  dispatch.catch(() => { });
@@ -6632,7 +6763,7 @@ export class WorkflowEngine {
6632
6763
  * {@link redispatchPending}. Carries the current trace/context so a re-dispatch is traceable.
6633
6764
  */
6634
6765
  dispatchRemoteTask(task) {
6635
- return this.pool.dispatch({
6766
+ const remote = {
6636
6767
  runId: task.runId,
6637
6768
  seq: task.seq,
6638
6769
  name: task.name,
@@ -6643,7 +6774,10 @@ export class WorkflowEngine {
6643
6774
  context: this.context?.(),
6644
6775
  priority: task.priority,
6645
6776
  attempt: task.attempt,
6646
- }, task.transport);
6777
+ };
6778
+ if (task.enqueuedAt)
6779
+ remote[DISPATCH_KEY] = dispatchKey(task.stepId, task.attempt, task.enqueuedAt);
6780
+ return this.pool.dispatch(remote, task.transport);
6647
6781
  }
6648
6782
  /** A turn's read of its run's checkpoints: {@link StateStore.listCheckpointsForReplay}, or every
6649
6783
  * column through {@link StateStore.listCheckpoints} on a store without it. */
@@ -6729,14 +6863,15 @@ export class WorkflowEngine {
6729
6863
  }
6730
6864
  /** The single-result path: settle `result`, then resume its run unless its fan is still running. */
6731
6865
  async completeOneRemoteResult(result, queuedIn) {
6732
- const cp = await this.settleRemoteCheckpoint(result);
6733
- if (cp === undefined)
6866
+ const settled = await this.settleRemoteCheckpoint(result);
6867
+ if (settled === undefined)
6734
6868
  return;
6869
+ const { cp, run } = settled;
6735
6870
  if (await this.stepFanStillRunning(cp, result, queuedIn))
6736
6871
  return;
6737
6872
  if (!parseStepFanGroup(cp.parallelGroup))
6738
6873
  this.noteSettledHere(result.runId, [cp.seq]);
6739
- this.resumeInBackground(result.runId, fanWakeOf(cp, result));
6874
+ await this.resumeOwed(run, result.runId, fanWakeOf(cp, result));
6740
6875
  }
6741
6876
  /**
6742
6877
  * Record `seqs` of `runId` in {@link settledHere}, dropping the entries older than a lease. A key is
@@ -6756,10 +6891,156 @@ export class WorkflowEngine {
6756
6891
  this.settledHere.delete(key);
6757
6892
  }
6758
6893
  }
6759
- resumeInBackground(runId, fan) {
6760
- this.trackEffect(this.resumeImplicit(runId, fan).catch((err) => {
6894
+ /**
6895
+ * Resume the run a settled step result owes a turn, and make that turn DURABLE before the result
6896
+ * is acknowledged (the caller's return is the acknowledgement).
6897
+ *
6898
+ * Settling a result does two things — settle the checkpoint, then resume the run — and only the
6899
+ * first is durable on its own: the resume runs in the background, so the transport is never held
6900
+ * by a turn that awaits another result (the self-deadlock the background resume exists for). A pod
6901
+ * SIGKILLed after the acknowledgement and before (or mid-way through) the resumed turn, which keeps
6902
+ * the run `suspended` and so is invisible to the orphan sweep, left the run parked on whatever
6903
+ * `wakeAt` its last turn wrote — for a step that is the reconcile fallback, minutes out — with
6904
+ * nothing else to wake it: the result was already acknowledged, so no redelivery re-drives it.
6905
+ *
6906
+ * So the acknowledgement waits for one of two things, at most {@link OWED_RESUME_GRACE_MS}:
6907
+ * - the resumed turn ending here: it settled or re-parked the run itself, so nothing is owed any
6908
+ * more — the common case, and it costs no write;
6909
+ * - else ({@link OWED_RESUME_GRACE_MS} passed, or the resume failed) a durable mark: for a
6910
+ * `suspended` run {@link markResumeOwed} pulls its timer in to one lease from now, so every
6911
+ * instance's due-timer poll resumes it once the lease a dying turn may hold has lapsed; for a run
6912
+ * whose turn is still in flight, a {@link requestWake} stamp (see {@link owedDurably}). A resume
6913
+ * refused by another instance's lease already made that stamp, which is then enough.
6914
+ * A pod dying while the acknowledgement waits never acknowledged: the transport redelivers the
6915
+ * result, whose settled checkpoint re-drives the resume on a live instance.
6916
+ *
6917
+ * The wait is bounded, so a turn that awaits another result in memory holds this one for at most
6918
+ * the grace before it is marked and acknowledged — never a deadlock.
6919
+ */
6920
+ async resumeOwed(run, runId, fan) {
6921
+ let served = false;
6922
+ let refusal;
6923
+ const resumed = this.resumeImplicit(runId, fan).then((result) => {
6924
+ refusal = leaseRefused.get(result);
6925
+ served = refusal === undefined;
6926
+ }, (err) => {
6761
6927
  console.warn(`[adonis-durable] background resume of run ${runId} failed (the run recovers on its next reconcile wake): ${err instanceof Error ? err.message : String(err)}`);
6762
- }));
6928
+ });
6929
+ this.trackEffect(resumed);
6930
+ const owed = this.owedDurably(run);
6931
+ if (owed === undefined)
6932
+ return;
6933
+ let timer;
6934
+ await Promise.race([
6935
+ resumed,
6936
+ new Promise((resolve) => {
6937
+ timer = setTimeout(resolve, OWED_RESUME_GRACE_MS);
6938
+ }),
6939
+ ]);
6940
+ clearTimeout(timer);
6941
+ if (served)
6942
+ return;
6943
+ // Refused: the wake already asked the lease holder for a turn, durably, unless that failed.
6944
+ if (refusal !== undefined && (await refusal) !== 'failed')
6945
+ return;
6946
+ if (owed === 'mark') {
6947
+ await this.markResumeOwed(run, () => served);
6948
+ return;
6949
+ }
6950
+ if ((await this.stampWakeRequest(runId, { generic: true, fans: new Map() })) === 'failed') {
6951
+ console.warn(`[adonis-durable] could not mark run ${runId} as owed a turn (it recovers on its next reconcile wake)`);
6952
+ }
6953
+ }
6954
+ /**
6955
+ * What makes the turn a result settled for `run` (as read when it settled) owes durable, if the
6956
+ * resume does not end in time: `mark` ({@link markResumeOwed}) for a `suspended` run whose timer is
6957
+ * not already due within a lease; `stamp` (a {@link requestWake} stamp) for a run whose turn is in
6958
+ * flight (`running`, or `blocked`) — its turn may park it after this, overwriting any timer, and a
6959
+ * holder that dies after parking leaves a `suspended` run the orphan sweep never sees, so only the
6960
+ * stamp (kept across the park, and due for the timer poll at once) survives both. Else nothing.
6961
+ */
6962
+ owedDurably(run) {
6963
+ if (run?.status === 'suspended')
6964
+ return this.owesMark(run) ? 'mark' : undefined;
6965
+ if (run?.status === 'running' || run?.status === 'blocked')
6966
+ return 'stamp';
6967
+ return undefined;
6968
+ }
6969
+ /**
6970
+ * Whether a result settled for `run` (as read when it settled) would need {@link markResumeOwed}: only
6971
+ * a `suspended` run whose timer is not already due within a lease (a run whose turn is in flight
6972
+ * gets a wake request instead, see {@link owedDurably}).
6973
+ */
6974
+ owesMark(run) {
6975
+ if (run?.status !== 'suspended')
6976
+ return false;
6977
+ return run.wakeAt === undefined || run.wakeAt > this.clock() + this.leaseMs;
6978
+ }
6979
+ /**
6980
+ * Mark `run` as owed a resume: pull its timer in to one lease from now (only IN — a timer already
6981
+ * due sooner is kept, and only while the run is still `suspended`). Leased like any due run, so a
6982
+ * live turn is never doubled — at worst a resume that lands after the mark replays and re-parks.
6983
+ * `served` reports a resume that ended meanwhile, whose mark is then dropped if not yet written.
6984
+ *
6985
+ * Batched across runs when the store has {@link StateStore.pullRunWakesIn}. Best-effort: a store that
6986
+ * refuses the write leaves the run exactly where it was before.
6987
+ */
6988
+ async markResumeOwed(run, served = () => false) {
6989
+ if (!this.owesMark(run))
6990
+ return;
6991
+ if (!this.store.pullRunWakesIn) {
6992
+ await this.markRunResumeOwed(run.id, this.clock() + this.leaseMs);
6993
+ return;
6994
+ }
6995
+ // Batched across runs: the results of a busy instance arrive many runs at a time (a results batch
6996
+ // settles each of its runs concurrently), and each would otherwise cost its own UPDATE. The first
6997
+ // mark goes out at once; the ones arriving while it is in flight go out together in the next.
6998
+ await new Promise((done) => {
6999
+ this.owedResumes.push({ runId: run.id, served, done });
7000
+ if (!this.markingOwedResumes)
7001
+ void this.markOwedResumes();
7002
+ });
7003
+ }
7004
+ /** Write the queued owed-resume marks, a batch at a time, until none is left (see {@link markResumeOwed}). */
7005
+ async markOwedResumes() {
7006
+ this.markingOwedResumes = true;
7007
+ try {
7008
+ while (this.owedResumes.length > 0) {
7009
+ const batch = this.owedResumes.splice(0, OWED_RESUME_BATCH_MAX);
7010
+ // One lease from the write, not from each mark's arrival: never later than a single write.
7011
+ const owedBy = this.clock() + this.leaseMs;
7012
+ // A resume that ended while its mark queued owes nothing any more.
7013
+ const runIds = batch.filter((entry) => !entry.served()).map((entry) => entry.runId);
7014
+ if (runIds.length === 0) {
7015
+ for (const entry of batch)
7016
+ entry.done();
7017
+ continue;
7018
+ }
7019
+ try {
7020
+ await this.store.pullRunWakesIn?.(runIds, owedBy);
7021
+ }
7022
+ catch {
7023
+ // A batch the store refused (a deadlock victim, a dropped connection) is retried one run at
7024
+ // a time, so one bad row costs no other run its mark.
7025
+ for (const runId of new Set(runIds))
7026
+ await this.markRunResumeOwed(runId, owedBy);
7027
+ }
7028
+ for (const entry of batch)
7029
+ entry.done();
7030
+ }
7031
+ }
7032
+ finally {
7033
+ this.markingOwedResumes = false;
7034
+ }
7035
+ }
7036
+ /** {@link markResumeOwed} for one run, through {@link StateStore.updateRunIf}. Best-effort. */
7037
+ async markRunResumeOwed(runId, owedBy) {
7038
+ try {
7039
+ await this.store.updateRunIf(runId, ['suspended'], { wakeAt: owedBy });
7040
+ }
7041
+ catch (err) {
7042
+ console.warn(`[adonis-durable] could not mark run ${runId} as owed a resume (it recovers on its next reconcile wake): ${err instanceof Error ? err.message : String(err)}`);
7043
+ }
6763
7044
  }
6764
7045
  /**
6765
7046
  * Settle a batch of one run's step results; per result, `true` (settled or dropped) or the error
@@ -6877,7 +7158,7 @@ export class WorkflowEngine {
6877
7158
  const fan = parseStepFanGroup(cp.parallelGroup);
6878
7159
  if (!fan || result.status === 'failed') {
6879
7160
  this.noteSettledHere(runId, singles);
6880
- this.resumeInBackground(runId);
7161
+ await this.resumeOwed(run, runId);
6881
7162
  return outcomes;
6882
7163
  }
6883
7164
  let closed = barriers.get(fan.firstSeq);
@@ -6890,7 +7171,7 @@ export class WorkflowEngine {
6890
7171
  }
6891
7172
  if (!(await closed)) {
6892
7173
  this.noteSettledHere(runId, singles);
6893
- this.resumeInBackground(runId, {
7174
+ await this.resumeOwed(run, runId, {
6894
7175
  first: fan.firstSeq,
6895
7176
  seq: highest.get(fan.firstSeq) ?? cp.seq,
6896
7177
  });
@@ -7000,7 +7281,7 @@ export class WorkflowEngine {
7000
7281
  }
7001
7282
  /**
7002
7283
  * The durable half of {@link completeRemoteResult}: settle `result` onto its checkpoint (or re-drive
7003
- * the resume when a redelivered result finds it already settled). Returns the step's checkpoint as
7284
+ * the resume when a redelivered result finds it already settled). Returns the step's checkpoint (and its run) as
7004
7285
  * read when its run should be resumed, or `undefined` when no resume is wanted (no checkpoint, or a
7005
7286
  * late result for a terminal run — terminal is terminal).
7006
7287
  */
@@ -7033,7 +7314,7 @@ export class WorkflowEngine {
7033
7314
  // explicit `requeue`/`durable:retry`, never to a stray result.
7034
7315
  if (cp.status !== 'pending') {
7035
7316
  if (!terminal)
7036
- return cp;
7317
+ return { cp, run };
7037
7318
  return undefined;
7038
7319
  }
7039
7320
  // A result settling this step frees its flow-control slot (no-op if it wasn't queued). Released
@@ -7070,7 +7351,7 @@ export class WorkflowEngine {
7070
7351
  if (typeof claim === 'object')
7071
7352
  throw claim.error;
7072
7353
  if (!claim)
7073
- return terminal ? undefined : cp;
7354
+ return terminal ? undefined : { cp, run };
7074
7355
  await this.releaseQueueSlot(cp);
7075
7356
  this.emit({
7076
7357
  type: result.status === 'completed' ? 'step.completed' : 'step.failed',
@@ -7083,7 +7364,7 @@ export class WorkflowEngine {
7083
7364
  queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
7084
7365
  durationMs: finishedAt.getTime() - startedAt.getTime(),
7085
7366
  });
7086
- return cp;
7367
+ return { cp, run };
7087
7368
  }
7088
7369
  /**
7089
7370
  * A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they