@adonis-agora/durable 0.56.3 → 0.56.5

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 (43) hide show
  1. package/CHANGELOG.md +34 -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/engine.d.ts +49 -5
  9. package/dist/src/engine.d.ts.map +1 -1
  10. package/dist/src/engine.js +148 -14
  11. package/dist/src/engine.js.map +1 -1
  12. package/dist/src/index.d.ts +1 -1
  13. package/dist/src/index.js +1 -1
  14. package/dist/src/interfaces.d.ts +21 -0
  15. package/dist/src/interfaces.d.ts.map +1 -1
  16. package/dist/src/interfaces.js.map +1 -1
  17. package/dist/src/protocol.d.ts +3 -0
  18. package/dist/src/protocol.d.ts.map +1 -1
  19. package/dist/src/protocol.js +21 -3
  20. package/dist/src/protocol.js.map +1 -1
  21. package/dist/src/stores/lucid.d.ts +1 -0
  22. package/dist/src/stores/lucid.d.ts.map +1 -1
  23. package/dist/src/stores/lucid.js +14 -0
  24. package/dist/src/stores/lucid.js.map +1 -1
  25. package/dist/src/testing/in-memory-state-store.d.ts +1 -0
  26. package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
  27. package/dist/src/testing/in-memory-state-store.js +7 -0
  28. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  29. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  30. package/dist/src/testing-kit/state-store-conformance.js +35 -0
  31. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  32. package/dist/src/transport-pool.d.ts.map +1 -1
  33. package/dist/src/transport-pool.js +4 -13
  34. package/dist/src/transport-pool.js.map +1 -1
  35. package/dist/src/transports/bullmq/bullmq-transport.d.ts +4 -2
  36. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  37. package/dist/src/transports/bullmq/bullmq-transport.js +110 -56
  38. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  39. package/dist/src/transports/worker-keys.d.ts +3 -1
  40. package/dist/src/transports/worker-keys.d.ts.map +1 -1
  41. package/dist/src/transports/worker-keys.js +23 -11
  42. package/dist/src/transports/worker-keys.js.map +1 -1
  43. package/package.json +1 -1
@@ -2597,8 +2597,22 @@ export class WorkflowEngine {
2597
2597
  // the contract instead: park it `suspended` on the step's deadline or the reconcile timer. The
2598
2598
  // worker's result resumes it; `resumeDueTimers` is the safety net. A LOST dispatch stays the job
2599
2599
  // of `remoteRedispatchMs` / `redispatchPending`.
2600
- const dispatched = await this.inFlightRemoteStep(run.id);
2600
+ // The durable marker of work on the transport: a `pending` remote checkpoint is written before
2601
+ // the dispatch and settled by the result.
2602
+ const checkpoints = await this.store.listCheckpoints(run.id);
2603
+ const dispatched = checkpoints.find((cp) => cp.kind === 'remote' && cp.status === 'pending');
2601
2604
  if (dispatched) {
2605
+ // `running` with a lapsed lease: the turn that wrote these checkpoints died before settling
2606
+ // the run, possibly before its dispatch reached the broker. Send what it may have lost.
2607
+ try {
2608
+ await this.redispatchCrashedTurnSteps(run, checkpoints, true);
2609
+ }
2610
+ catch (error) {
2611
+ // Not sent: leave the run `running` for the next pass rather than park it on a lost step.
2612
+ await this.store.releaseRunLock(run.id, this.instanceId).catch(() => undefined);
2613
+ console.error(`[adonis-durable] could not re-dispatch the steps of crashed run ${run.id}; retrying on a later tick:`, error);
2614
+ continue;
2615
+ }
2602
2616
  // A REMOTE workflow reads `running` only while a turn is being computed or applied, so a free
2603
2617
  // lease here means that turn was lost — the call in flight is not what the run waits on next.
2604
2618
  // Park it on a near wake so the timer pass re-drives the turn promptly (never later than the
@@ -2693,13 +2707,83 @@ export class WorkflowEngine {
2693
2707
  return released;
2694
2708
  }
2695
2709
  /**
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.
2710
+ * {@link redispatchCrashedTurnSteps} for a `suspended` run a timer pass just leased, when the run as
2711
+ * listed shows the previous lease holder died holding it: a lease is released (both columns
2712
+ * cleared) by every path that ends normally, so one still set but lapsed is a holder that never
2713
+ * reached its release — a turn SIGKILLed mid-way, most likely. A resumed turn keeps the run
2714
+ * `suspended` while it executes, so this is the only trace such a crash leaves. Called with the
2715
+ * lease held; a failure is reported and does not stop the resume.
2699
2716
  */
2700
- async inFlightRemoteStep(runId) {
2701
- const checkpoints = await this.store.listCheckpoints(runId);
2702
- return checkpoints.find((cp) => cp.kind === 'remote' && cp.status === 'pending');
2717
+ async redispatchIfHolderCrashed(listed, nowMs) {
2718
+ if (listed.status !== 'suspended')
2719
+ return;
2720
+ if (listed.lockedBy === undefined || listed.lockedUntil === undefined)
2721
+ return;
2722
+ if (listed.lockedUntil > nowMs)
2723
+ return;
2724
+ try {
2725
+ await this.redispatchCrashedTurnSteps(listed, await this.store.listCheckpoints(listed.id));
2726
+ }
2727
+ catch (error) {
2728
+ console.error(`[adonis-durable] could not re-dispatch the steps of run ${listed.id} after its lease holder died; its turn proceeds:`, error);
2729
+ }
2730
+ }
2731
+ /**
2732
+ * Re-dispatch the remote steps a CRASHED turn of `run` wrote `pending` and may never have sent.
2733
+ *
2734
+ * A turn persists a step's `pending` checkpoint BEFORE handing the job to the transport (so a fast
2735
+ * result always finds it), then settles the run. A process that dies between the two leaves a
2736
+ * `pending` step no worker ever received — and every later turn replays straight back to "pending,
2737
+ * keep waiting" (re-dispatching on replay is the opt-in `remoteRedispatchMs`, because a step may
2738
+ * legitimately run longer than any window). Observed under a SIGKILLed pod: runs stuck forever.
2739
+ *
2740
+ * The caller holds the run's lease and has evidence the turn died; this narrows it to the steps
2741
+ * THAT turn wrote: the ones enqueued at or after the run's last settle (`updatedAt` — a turn that
2742
+ * ends normally settles after its dispatches, so a step it sent is always older than its settle).
2743
+ * `sinceInclusive` is for a run still `running`: its `updatedAt` is that same turn's start.
2744
+ *
2745
+ * Each step goes out again under its own `stepId` and attempt: a step whose original dispatch did
2746
+ * land runs twice (at-least-once, as documented), and the second result finds the checkpoint
2747
+ * settled and is dropped. Only on a crash — a healthy run never gets here. Remote (polyglot)
2748
+ * workflows are left to their own turn re-drive. Returns how many steps were sent.
2749
+ */
2750
+ async redispatchCrashedTurnSteps(run, checkpoints, sinceInclusive = false) {
2751
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
2752
+ this.latest.get(run.workflow);
2753
+ // No transport: nothing could have been dispatched, nothing can be sent again.
2754
+ if (registered?.remote || this.pool.size === 0)
2755
+ return 0;
2756
+ const since = run.updatedAt.getTime();
2757
+ const lost = checkpoints.filter((cp) => {
2758
+ if (cp.kind !== 'remote' || cp.status !== 'pending' || !cp.stepId)
2759
+ return false;
2760
+ const at = cp.enqueuedAt.getTime();
2761
+ return sinceInclusive ? at >= since : at > since;
2762
+ });
2763
+ for (const cp of lost) {
2764
+ await this.dispatchRemoteTask({
2765
+ runId: run.id,
2766
+ seq: cp.seq,
2767
+ name: cp.name,
2768
+ stepId: cp.stepId,
2769
+ group: cp.workerGroup ?? sanitizeQueueToken(cp.name),
2770
+ input: cp.input,
2771
+ attempt: cp.attempts,
2772
+ priority: run.priority,
2773
+ });
2774
+ this.emit({
2775
+ type: 'step.started',
2776
+ runId: run.id,
2777
+ seq: cp.seq,
2778
+ name: cp.name,
2779
+ kind: 'remote',
2780
+ redispatched: true,
2781
+ });
2782
+ }
2783
+ if (lost.length > 0) {
2784
+ 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)`);
2785
+ }
2786
+ return lost.length;
2703
2787
  }
2704
2788
  /**
2705
2789
  * Per-recovery bookkeeping (called once the lease is held): count the attempt, or — past
@@ -2789,6 +2873,7 @@ export class WorkflowEngine {
2789
2873
  });
2790
2874
  if (!claimed)
2791
2875
  continue;
2876
+ await this.redispatchIfHolderCrashed(run, nowMs);
2792
2877
  }
2793
2878
  finally {
2794
2879
  await this.store.releaseRunLock(run.id, this.instanceId);
@@ -3162,6 +3247,7 @@ export class WorkflowEngine {
3162
3247
  return;
3163
3248
  }
3164
3249
  try {
3250
+ await this.redispatchIfHolderCrashed(run, nowMs);
3165
3251
  // A per-run hook (recovery counting / dead-lettering) may settle the run terminally instead.
3166
3252
  const settled = onLocked ? await onLocked(run) : undefined;
3167
3253
  results.push(settled ?? (await this.resumeImplicit(run.id)));
@@ -5190,7 +5276,17 @@ export class WorkflowEngine {
5190
5276
  }
5191
5277
  const renewed = now + this.remoteRedispatchMs;
5192
5278
  const previous = cp.wakeAt;
5193
- await this.store.saveCheckpoint({ ...cp, wakeAt: renewed });
5279
+ // Conditional, never a blind write of the row read above: another instance may have settled the
5280
+ // step (its result) or re-dispatched it (new `attempts` and deadline) since, and writing `cp`
5281
+ // back would undo either — a `completed` step back to `pending`, the step run a second time.
5282
+ const wrote = this.store.renewCheckpointLease
5283
+ ? await this.store.renewCheckpointLease(runId, seq, previous, renewed)
5284
+ : await this.savePendingCheckpoint({ ...cp, wakeAt: renewed });
5285
+ if (!wrote) {
5286
+ // The step moved on under us: nothing of ours to defend, and the next beat re-reads it.
5287
+ this.stepLeaseRearms.delete(id);
5288
+ return;
5289
+ }
5194
5290
  setBounded(this.stepLeaseRearms, id, renewed - halfWindow);
5195
5291
  // If the run is parked EXACTLY on this step's old lease, carry its wake forward too — otherwise a
5196
5292
  // beating worker costs one wasted turn per renewal window. Only on an exact match: any other
@@ -6597,6 +6693,7 @@ export class WorkflowEngine {
6597
6693
  traceparent,
6598
6694
  context,
6599
6695
  priority: opts.admission?.priority,
6696
+ lazyPickupBeat: true,
6600
6697
  attempt: 1,
6601
6698
  })), opts.transport);
6602
6699
  // Awaited below; a rejection is not left unhandled while the next round is written.
@@ -6719,11 +6816,13 @@ export class WorkflowEngine {
6719
6816
  }
6720
6817
  /** The single-result path: settle `result`, then resume its run unless its fan is still running. */
6721
6818
  async completeOneRemoteResult(result, queuedIn) {
6722
- const cp = await this.settleRemoteCheckpoint(result);
6723
- if (cp === undefined)
6819
+ const settled = await this.settleRemoteCheckpoint(result);
6820
+ if (settled === undefined)
6724
6821
  return;
6822
+ const { cp, run } = settled;
6725
6823
  if (await this.stepFanStillRunning(cp, result, queuedIn))
6726
6824
  return;
6825
+ await this.markResumeOwed(run);
6727
6826
  if (!parseStepFanGroup(cp.parallelGroup))
6728
6827
  this.noteSettledHere(result.runId, [cp.seq]);
6729
6828
  this.resumeInBackground(result.runId, fanWakeOf(cp, result));
@@ -6746,6 +6845,39 @@ export class WorkflowEngine {
6746
6845
  this.settledHere.delete(key);
6747
6846
  }
6748
6847
  }
6848
+ /**
6849
+ * Make the turn a settled step result owes its run DURABLE before the result is acknowledged.
6850
+ *
6851
+ * Settling a result does two things — settle the checkpoint, then resume the run — and only the
6852
+ * first is durable: the resume runs in the background, after the transport has acknowledged the
6853
+ * result. A pod SIGKILLed in between (or mid-way through the resumed turn, which keeps the run
6854
+ * `suspended` and so is invisible to the orphan sweep) left the run parked on whatever `wakeAt` its
6855
+ * last turn wrote — for a step that is the reconcile fallback, minutes out — with nothing else to
6856
+ * wake it: the result was already acknowledged, so no redelivery re-drives it.
6857
+ *
6858
+ * So pull the suspended run's timer in to one lease from now first. The resume this instance is
6859
+ * about to run re-parks the run (or settles it) and so overwrites it; if this instance dies before
6860
+ * that, every instance's due-timer poll resumes the run once the lease a dying turn may hold has
6861
+ * lapsed. Leased like any due run, so a live turn is never doubled — at worst a resume landing after
6862
+ * the timer replays and re-parks. Only pulls a timer IN (one already due sooner is kept), and only
6863
+ * for a `suspended` run: a run still `running` has its first turn holding the lease, which re-checks
6864
+ * the step on settling (see `recheckWaitSeqs`) and is recovered by the orphan sweep if it dies.
6865
+ *
6866
+ * Best-effort: a store that refuses the write leaves the run exactly where it was before.
6867
+ */
6868
+ async markResumeOwed(run) {
6869
+ if (run?.status !== 'suspended')
6870
+ return;
6871
+ const owedBy = this.clock() + this.leaseMs;
6872
+ if (run.wakeAt !== undefined && run.wakeAt <= owedBy)
6873
+ return;
6874
+ try {
6875
+ await this.store.updateRunIf(run.id, ['suspended'], { wakeAt: owedBy });
6876
+ }
6877
+ catch (err) {
6878
+ console.warn(`[adonis-durable] could not mark run ${run.id} as owed a resume (it recovers on its next reconcile wake): ${err instanceof Error ? err.message : String(err)}`);
6879
+ }
6880
+ }
6749
6881
  resumeInBackground(runId, fan) {
6750
6882
  this.trackEffect(this.resumeImplicit(runId, fan).catch((err) => {
6751
6883
  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)}`);
@@ -6866,6 +6998,7 @@ export class WorkflowEngine {
6866
6998
  for (const { cp, result } of resumable) {
6867
6999
  const fan = parseStepFanGroup(cp.parallelGroup);
6868
7000
  if (!fan || result.status === 'failed') {
7001
+ await this.markResumeOwed(run);
6869
7002
  this.noteSettledHere(runId, singles);
6870
7003
  this.resumeInBackground(runId);
6871
7004
  return outcomes;
@@ -6879,6 +7012,7 @@ export class WorkflowEngine {
6879
7012
  barriers.set(fan.firstSeq, closed);
6880
7013
  }
6881
7014
  if (!(await closed)) {
7015
+ await this.markResumeOwed(run);
6882
7016
  this.noteSettledHere(runId, singles);
6883
7017
  this.resumeInBackground(runId, {
6884
7018
  first: fan.firstSeq,
@@ -6990,7 +7124,7 @@ export class WorkflowEngine {
6990
7124
  }
6991
7125
  /**
6992
7126
  * The durable half of {@link completeRemoteResult}: settle `result` onto its checkpoint (or re-drive
6993
- * the resume when a redelivered result finds it already settled). Returns the step's checkpoint as
7127
+ * the resume when a redelivered result finds it already settled). Returns the step's checkpoint (and its run) as
6994
7128
  * read when its run should be resumed, or `undefined` when no resume is wanted (no checkpoint, or a
6995
7129
  * late result for a terminal run — terminal is terminal).
6996
7130
  */
@@ -7023,7 +7157,7 @@ export class WorkflowEngine {
7023
7157
  // explicit `requeue`/`durable:retry`, never to a stray result.
7024
7158
  if (cp.status !== 'pending') {
7025
7159
  if (!terminal)
7026
- return cp;
7160
+ return { cp, run };
7027
7161
  return undefined;
7028
7162
  }
7029
7163
  // A result settling this step frees its flow-control slot (no-op if it wasn't queued). Released
@@ -7060,7 +7194,7 @@ export class WorkflowEngine {
7060
7194
  if (typeof claim === 'object')
7061
7195
  throw claim.error;
7062
7196
  if (!claim)
7063
- return terminal ? undefined : cp;
7197
+ return terminal ? undefined : { cp, run };
7064
7198
  await this.releaseQueueSlot(cp);
7065
7199
  this.emit({
7066
7200
  type: result.status === 'completed' ? 'step.completed' : 'step.failed',
@@ -7073,7 +7207,7 @@ export class WorkflowEngine {
7073
7207
  queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
7074
7208
  durationMs: finishedAt.getTime() - startedAt.getTime(),
7075
7209
  });
7076
- return cp;
7210
+ return { cp, run };
7077
7211
  }
7078
7212
  /**
7079
7213
  * A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they