@dudousxd/nestjs-durable-core 0.79.0 → 0.80.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/dist/index.cjs CHANGED
@@ -4052,6 +4052,16 @@ function isNewerVersion(a, b) {
4052
4052
  }) > 0;
4053
4053
  }
4054
4054
  __name(isNewerVersion, "isNewerVersion");
4055
+ var SETTLE_ELIGIBLE_STATUSES = [
4056
+ "pending",
4057
+ "running",
4058
+ "suspended",
4059
+ "blocked",
4060
+ "failed",
4061
+ "cancelling"
4062
+ ];
4063
+ var SUSPEND_ELIGIBLE_STATUSES = SETTLE_ELIGIBLE_STATUSES.filter((status) => status !== "cancelling");
4064
+ var RECOVERY_BATCH = 500;
4055
4065
  var NamespaceMismatch = class NamespaceMismatch2 extends Error {
4056
4066
  static {
4057
4067
  __name(this, "NamespaceMismatch");
@@ -4151,6 +4161,18 @@ var WorkflowEngine = class {
4151
4161
  awaitedStepFans = /* @__PURE__ */ new Map();
4152
4162
  /** Step fans whose join barrier this instance opened, by `runId:firstSeq` — see {@link settledHere}. */
4153
4163
  stepFansFiredHere = /* @__PURE__ */ new Map();
4164
+ /**
4165
+ * The in-process execution gate, by run id: the execution of the run currently in flight in THIS
4166
+ * process (`tail`), and at most one resume queued behind it (`queued`). See {@link resumeRun}.
4167
+ *
4168
+ * The run lease is keyed by engine instance, so it fences OTHER processes only: to the store, a
4169
+ * second execution in this same process holds the very same lease (and {@link execute} skips
4170
+ * `tryLockRun` when its snapshot says this instance holds it). Without this gate, a resume landing
4171
+ * while this process was still executing the run (a signal, a late step result, a child wake, an
4172
+ * explicit `resume()`) went straight past the lease and ran the body concurrently: a
4173
+ * not-yet-checkpointed step executed twice.
4174
+ */
4175
+ runGates = /* @__PURE__ */ new Map();
4154
4176
  /** The tail of each run's queue of results being handled on this instance — see {@link serializedPerRun}. */
4155
4177
  resultChains = /* @__PURE__ */ new Map();
4156
4178
  /** Each run's results waiting to be settled on this instance — see {@link completeRemoteResult}. */
@@ -4619,11 +4641,16 @@ var WorkflowEngine = class {
4619
4641
  if (registered.singleton) {
4620
4642
  await this.singletons.assertCapacity(name, registered.singleton, input);
4621
4643
  }
4644
+ const startAtMs = opts?.startAt instanceof Date ? opts.startAt.getTime() : opts?.startAt;
4645
+ const delayed = startAtMs !== void 0 && Number.isFinite(startAtMs) && startAtMs > this.clock();
4622
4646
  const run = {
4623
4647
  id: runId,
4624
4648
  workflow: name,
4625
4649
  workflowVersion: registered.version,
4626
- status: "pending",
4650
+ status: delayed ? "suspended" : "pending",
4651
+ ...delayed ? {
4652
+ wakeAt: startAtMs
4653
+ } : {},
4627
4654
  namespace: opts?.namespace ?? this.namespace,
4628
4655
  // DERIVED from the registration, exactly like `namespace` is derived from this engine — there is
4629
4656
  // deliberately no `opts.origin`, so a caller cannot label its run as another package's work.
@@ -4649,6 +4676,18 @@ var WorkflowEngine = class {
4649
4676
  error: winner.error
4650
4677
  };
4651
4678
  }
4679
+ if (delayed) {
4680
+ this.emit({
4681
+ type: "run.suspended",
4682
+ runId,
4683
+ workflow: name,
4684
+ namespace: run.namespace
4685
+ });
4686
+ return {
4687
+ runId,
4688
+ status: "suspended"
4689
+ };
4690
+ }
4652
4691
  await this.runDispatcher.dispatch(runId);
4653
4692
  if (this.controlPlane) {
4654
4693
  void this.controlPlane.publishControl({
@@ -4686,7 +4725,104 @@ var WorkflowEngine = class {
4686
4725
  resumeImplicit(runId) {
4687
4726
  return this.resumeRun(runId, false);
4688
4727
  }
4689
- async resumeRun(runId, allowFailed) {
4728
+ /**
4729
+ * One execution of a run at a time in this process. A resume that lands while the run executes
4730
+ * here neither joins that execution (the double-run) nor no-ops against its held lease (a lost
4731
+ * wake: its checkpoint may postdate the running turn's snapshot): it is QUEUED, and re-drives the
4732
+ * run once the running execution has settled and released its lease, re-reading the run then
4733
+ * (terminal: echoed, never re-executed). Resumes landing while one is already queued coalesce
4734
+ * into it: it has not started yet, so it will observe every write they made. Their callers get
4735
+ * the queued execution's result, i.e. the run's state after their own wake was applied.
4736
+ *
4737
+ * Across processes, exclusion stays the lease's job: {@link execute} still `tryLockRun`s when it
4738
+ * does not hold the lease.
4739
+ */
4740
+ resumeRun(runId, allowFailed) {
4741
+ const gate = this.runGates.get(runId);
4742
+ if (!gate) {
4743
+ const tail = this.resumeRunNow(runId, allowFailed);
4744
+ const fresh = {
4745
+ tail
4746
+ };
4747
+ this.runGates.set(runId, fresh);
4748
+ this.closeGateAfter(runId, fresh, tail);
4749
+ return tail;
4750
+ }
4751
+ let queued = gate.queued;
4752
+ if (queued) {
4753
+ queued.allowFailed ||= allowFailed;
4754
+ } else {
4755
+ const entry = {
4756
+ allowFailed
4757
+ };
4758
+ const result = gate.tail.catch(() => void 0).then(() => {
4759
+ if (gate.queued === entry) gate.queued = void 0;
4760
+ return this.resumeRunNow(runId, entry.allowFailed);
4761
+ });
4762
+ entry.result = result;
4763
+ queued = entry;
4764
+ gate.queued = queued;
4765
+ gate.tail = result;
4766
+ this.closeGateAfter(runId, gate, result);
4767
+ void this.track(result).catch(() => void 0);
4768
+ }
4769
+ if (currentWorkflowCtx()?.runId === runId) return this.echoCurrentStatus(runId);
4770
+ return queued.result;
4771
+ }
4772
+ /** Drop `gate` once `tail` settles, unless another execution queued behind it meanwhile. */
4773
+ closeGateAfter(runId, gate, tail) {
4774
+ const close = /* @__PURE__ */ __name(() => {
4775
+ if (this.runGates.get(runId) === gate && gate.tail === tail) this.runGates.delete(runId);
4776
+ }, "close");
4777
+ void tail.then(close, close);
4778
+ }
4779
+ /**
4780
+ * Release a run's lease AFTER its outcome is decided (persisted, or about to be returned). Best
4781
+ * effort, and that matters most on the failure path: the thing that failed the run is frequently
4782
+ * the very store this release talks to (a step that violated a DB constraint leaves Postgres
4783
+ * refusing every further statement on that connection), so the release throws too. A throwing
4784
+ * release in a `finally` REPLACES the run's real outcome with a lease write nobody asked about,
4785
+ * skips the post-turn re-drives after it, and, for a turn driven as a background resume, surfaces
4786
+ * as an unhandled rejection instead of a failed run.
4787
+ *
4788
+ * Letting it go is safe: the lease expires on its own and `recoverIncomplete` re-drives whatever
4789
+ * is still held. A lost release costs one lease window of latency; a masked error costs the
4790
+ * diagnosis.
4791
+ */
4792
+ async releaseLeaseBestEffort(runId) {
4793
+ try {
4794
+ await this.store.releaseRunLock(runId);
4795
+ } catch (err) {
4796
+ console.warn(`[nestjs-durable] releasing the lease of run ${runId} failed (it expires on its own and recovery re-drives): ${err instanceof Error ? err.message : String(err)}`);
4797
+ }
4798
+ }
4799
+ /**
4800
+ * The run's persisted state as a {@link RunResult}, without executing anything: the answer to a
4801
+ * resume issued from inside the run's own execution, and to a settle whose compare-and-set lost
4802
+ * (the path that won already emitted and notified). A run that is gone reads as `cancelled`, the
4803
+ * safest terminal reading, never a fabricated success.
4804
+ */
4805
+ async echoCurrentStatus(runId) {
4806
+ const run = await this.store.getRun(runId);
4807
+ return {
4808
+ runId,
4809
+ status: run?.status ?? "cancelled",
4810
+ output: run?.output,
4811
+ error: run?.error
4812
+ };
4813
+ }
4814
+ /**
4815
+ * The engine's stand-in for {@link StateStore.updateRunIf} on a store without it, called with the
4816
+ * store as `this`: read-then-write, which narrows the race to the gap between the read and the
4817
+ * write (the pre-CAS behavior, now applied to every settle) but cannot close it.
4818
+ */
4819
+ async updateRunIfFallback(runId, expected, patch) {
4820
+ const current = await this.getRun(runId);
4821
+ if (!current || !expected.includes(current.status)) return false;
4822
+ await this.updateRun(runId, patch);
4823
+ return true;
4824
+ }
4825
+ async resumeRunNow(runId, allowFailed) {
4690
4826
  const run = await this.store.getRun(runId);
4691
4827
  if (!run) throw new Error(`run ${runId} not found`);
4692
4828
  if (run.status === "cancelled" || run.status === "completed" || run.status === "dead" || run.status === "failed" && !allowFailed) {
@@ -4992,7 +5128,7 @@ var WorkflowEngine = class {
4992
5128
  async recoverIncomplete(nowMs = this.clock()) {
4993
5129
  if (this.draining) return [];
4994
5130
  const results = [];
4995
- for (const listed of await this.store.listIncompleteRuns(this.namespace)) {
5131
+ for (const listed of await this.orphanedRuns(nowMs)) {
4996
5132
  if (this.draining) break;
4997
5133
  const acquired = await this.store.tryLockRun(listed.id, this.instanceId, nowMs + this.leaseMs, nowMs);
4998
5134
  if (!acquired) continue;
@@ -5038,6 +5174,21 @@ var WorkflowEngine = class {
5038
5174
  return results;
5039
5175
  }
5040
5176
  /**
5177
+ * The runs {@link recoverIncomplete} should try to reclaim: incomplete runs whose lease is free or
5178
+ * expired, never the healthy ones a live turn is renewing. Every worker runs recovery on every
5179
+ * timer tick, so listing every running run and lock-probing each (one doomed UPDATE per healthy
5180
+ * run per worker per tick) was pure database load. The store answers it directly when it can
5181
+ * ({@link StateStore.listOrphanedRuns}, bounded by {@link RECOVERY_BATCH}); else the full list is
5182
+ * filtered here, which still spares the probes. Shuffled, like the due-timer pass, so instances
5183
+ * recovering at once spread over the batch instead of contending for the same leases.
5184
+ */
5185
+ async orphanedRuns(nowMs) {
5186
+ if (this.store.listOrphanedRuns) {
5187
+ return shuffle(await this.store.listOrphanedRuns(nowMs, RECOVERY_BATCH, this.namespace));
5188
+ }
5189
+ return shuffle((await this.store.listIncompleteRuns(this.namespace)).filter((run) => run.lockedUntil == null || run.lockedUntil <= nowMs).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()).slice(0, RECOVERY_BATCH));
5190
+ }
5191
+ /**
5041
5192
  * The remote step this run dispatched and is still awaiting a result for, if any — the signature
5042
5193
  * that tells {@link recoverIncomplete} "this run's work is ON THE TRANSPORT, not in a crashed
5043
5194
  * process". A `pending` remote checkpoint is written BEFORE the dispatch and settled by the
@@ -6554,12 +6705,13 @@ ${run.workflowVersion}`;
6554
6705
  async settleRun(run, outcome) {
6555
6706
  const updatedAt = /* @__PURE__ */ new Date();
6556
6707
  if (outcome.kind === "completed") {
6557
- await this.store.updateRun(run.id, {
6708
+ const applied2 = await (this.store.updateRunIf ?? this.updateRunIfFallback).call(this.store, run.id, SETTLE_ELIGIBLE_STATUSES, {
6558
6709
  status: "completed",
6559
6710
  output: outcome.output,
6560
6711
  error: void 0,
6561
6712
  updatedAt
6562
6713
  });
6714
+ if (!applied2) return this.echoCurrentStatus(run.id);
6563
6715
  this.emit({
6564
6716
  type: "run.completed",
6565
6717
  runId: run.id,
@@ -6581,11 +6733,12 @@ ${run.workflowVersion}`;
6581
6733
  const handed = await this.routeFailure(run, outcome.error);
6582
6734
  if (handed && handed.kind !== "failed") return this.settleRun(run, handed);
6583
6735
  const error2 = handed?.error ?? outcome.error;
6584
- await this.store.updateRun(run.id, {
6736
+ const applied2 = await (this.store.updateRunIf ?? this.updateRunIfFallback).call(this.store, run.id, SETTLE_ELIGIBLE_STATUSES, {
6585
6737
  status: "failed",
6586
6738
  error: error2,
6587
6739
  updatedAt
6588
6740
  });
6741
+ if (!applied2) return this.echoCurrentStatus(run.id);
6589
6742
  this.emit({
6590
6743
  type: "run.failed",
6591
6744
  runId: run.id,
@@ -6601,19 +6754,12 @@ ${run.workflowVersion}`;
6601
6754
  };
6602
6755
  }
6603
6756
  if (outcome.kind === "blocked") return this.parkBlocked(run, outcome.blocked);
6604
- const latest = await this.store.getRun(run.id);
6605
- if (latest?.status === "cancelled") {
6606
- return {
6607
- runId: run.id,
6608
- status: "cancelled",
6609
- error: latest.error
6610
- };
6611
- }
6612
- await this.store.updateRun(run.id, {
6757
+ const applied = await (this.store.updateRunIf ?? this.updateRunIfFallback).call(this.store, run.id, SUSPEND_ELIGIBLE_STATUSES, {
6613
6758
  status: "suspended",
6614
6759
  wakeAt: this.reconcileWakeAt(outcome.wakeAt),
6615
6760
  updatedAt
6616
6761
  });
6762
+ if (!applied) return this.echoCurrentStatus(run.id);
6617
6763
  this.emit({
6618
6764
  type: "run.suspended",
6619
6765
  runId: run.id,
@@ -6660,7 +6806,7 @@ ${run.workflowVersion}`;
6660
6806
  from: this.instanceId
6661
6807
  }).catch(() => void 0);
6662
6808
  }
6663
- await this.store.releaseRunLock(run.id);
6809
+ await this.releaseLeaseBestEffort(run.id);
6664
6810
  return {
6665
6811
  runId: run.id,
6666
6812
  status: "cancelled",
@@ -6690,7 +6836,7 @@ ${run.workflowVersion}`;
6690
6836
  const handoff = liveHandoff(run.id, checkpoints);
6691
6837
  if (handoff) {
6692
6838
  const result = await this.settleRun(run, await this.followHandoff(run, handoff));
6693
- await this.store.releaseRunLock(run.id);
6839
+ await this.releaseLeaseBestEffort(run.id);
6694
6840
  return result;
6695
6841
  }
6696
6842
  const history = await this.remoteHistory(run.id, checkpoints);
@@ -6728,7 +6874,7 @@ ${run.workflowVersion}`;
6728
6874
  decision = silenceMs != null ? await this.awaitWithLivenessDeadline(run.id, advance, silenceMs, () => new RemoteWorkflowTimeout(run.id, silenceMs)) : await advance;
6729
6875
  } catch (err) {
6730
6876
  if (err instanceof RemoteWorkflowTimeout) {
6731
- await this.store.releaseRunLock(run.id);
6877
+ await this.releaseLeaseBestEffort(run.id);
6732
6878
  return {
6733
6879
  runId: run.id,
6734
6880
  status: "running"
@@ -7399,7 +7545,7 @@ ${run.workflowVersion}`;
7399
7545
  this.awaitedRemoteSteps.delete(run.id);
7400
7546
  }
7401
7547
  if (this.awaitedStepFans.get(run.id) === awaitedFans) this.awaitedStepFans.delete(run.id);
7402
- await this.store.releaseRunLock(run.id);
7548
+ await this.releaseLeaseBestEffort(run.id);
7403
7549
  if (suspendedOnFan) {
7404
7550
  void this.redriveSettledChildren(run.id, suspendedOnFan).catch(() => void 0);
7405
7551
  }
@@ -7770,27 +7916,21 @@ ${run.workflowVersion}`;
7770
7916
  * own `finally` also releases (the run-execution path) is safe.
7771
7917
  */
7772
7918
  async parkBlocked(run, blocked) {
7773
- const current = await this.store.getRun(run.id);
7774
- if (current && (current.status === "cancelled" || current.status === "completed")) {
7775
- await this.store.releaseRunLock(run.id);
7776
- return {
7777
- runId: run.id,
7778
- status: current.status,
7779
- output: current.output,
7780
- error: current.error
7781
- };
7782
- }
7783
7919
  const error2 = {
7784
7920
  message: blocked.reason,
7785
7921
  code: blocked.code,
7786
7922
  retryable: true
7787
7923
  };
7788
- await this.store.updateRun(run.id, {
7924
+ const applied = await (this.store.updateRunIf ?? this.updateRunIfFallback).call(this.store, run.id, SUSPEND_ELIGIBLE_STATUSES, {
7789
7925
  status: "blocked",
7790
7926
  error: error2,
7791
7927
  wakeAt: this.clock() + this.blockedPollMs,
7792
7928
  updatedAt: /* @__PURE__ */ new Date()
7793
7929
  });
7930
+ if (!applied) {
7931
+ await this.releaseLeaseBestEffort(run.id);
7932
+ return this.echoCurrentStatus(run.id);
7933
+ }
7794
7934
  this.emit({
7795
7935
  type: "run.blocked",
7796
7936
  runId: run.id,
@@ -7800,7 +7940,7 @@ ${run.workflowVersion}`;
7800
7940
  requires: blocked.requires,
7801
7941
  diagnostics: blocked.diagnostics
7802
7942
  });
7803
- await this.store.releaseRunLock(run.id);
7943
+ await this.releaseLeaseBestEffort(run.id);
7804
7944
  return {
7805
7945
  runId: run.id,
7806
7946
  status: "blocked",
@@ -9148,6 +9288,12 @@ var CodecStateStore = class {
9148
9288
  /** Forwarded verbatim (deletes touch no payload). Bound only when the inner store has it, so the
9149
9289
  * engine's per-run fallback still kicks in through the wrapper. */
9150
9290
  deleteRuns;
9291
+ /** Decodes the runs like {@link listIncompleteRuns}. Bound only when the inner store has it, so
9292
+ * the engine's list-and-filter fallback still kicks in through the wrapper. */
9293
+ listOrphanedRuns;
9294
+ /** Encodes the patch like {@link updateRun}. Bound only when the inner store has it, so the
9295
+ * engine's read-then-write fallback still kicks in through the wrapper. */
9296
+ updateRunIf;
9151
9297
  /** Encodes like {@link saveCheckpoint}. Bound only when the inner store has it, so the engine's
9152
9298
  * plain-save fallback still kicks in through the wrapper. */
9153
9299
  saveCheckpointIfPending;
@@ -9188,6 +9334,10 @@ var CodecStateStore = class {
9188
9334
  if (inner.tryAdmitSingleton) this.tryAdmitSingleton = inner.tryAdmitSingleton.bind(inner);
9189
9335
  const deleteRuns = inner.deleteRuns;
9190
9336
  if (deleteRuns) this.deleteRuns = (ids) => deleteRuns.call(inner, ids);
9337
+ const orphans = inner.listOrphanedRuns;
9338
+ if (orphans) this.listOrphanedRuns = async (nowMs, limit, namespace) => (await orphans.call(inner, nowMs, limit, namespace)).map((r) => this.decRun(r));
9339
+ const updateIf = inner.updateRunIf;
9340
+ if (updateIf) this.updateRunIf = (runId, expected, patch) => updateIf.call(inner, runId, expected, this.encRunPatch(patch));
9191
9341
  const saveIfPending = inner.saveCheckpointIfPending;
9192
9342
  if (saveIfPending) this.saveCheckpointIfPending = (cp) => saveIfPending.call(inner, this.encCp(cp));
9193
9343
  const saveManyIfPending = inner.saveCheckpointsIfPending;
@@ -9294,12 +9444,15 @@ var CodecStateStore = class {
9294
9444
  return this.inner.createRun(this.encRun(run));
9295
9445
  }
9296
9446
  updateRun(runId, patch) {
9447
+ return this.inner.updateRun(runId, this.encRunPatch(patch));
9448
+ }
9449
+ encRunPatch(patch) {
9297
9450
  const next = {
9298
9451
  ...patch
9299
9452
  };
9300
9453
  if ("input" in patch) next.input = this.enc(patch.input);
9301
9454
  if ("output" in patch) next.output = this.enc(patch.output);
9302
- return this.inner.updateRun(runId, next);
9455
+ return next;
9303
9456
  }
9304
9457
  async getRun(runId) {
9305
9458
  return this.decRun(await this.inner.getRun(runId));
@@ -9708,6 +9861,14 @@ var InMemoryStateStore = class {
9708
9861
  this.reindexAttributes(run.id, run.searchAttributes);
9709
9862
  }
9710
9863
  async updateRun(runId, inputPatch) {
9864
+ this.applyRunPatch(runId, inputPatch);
9865
+ }
9866
+ updateRunIf(runId, expectedStatuses, patch) {
9867
+ const existing = this.runs.get(runId);
9868
+ if (!existing || !expectedStatuses.includes(existing.status)) return Promise.resolve(false);
9869
+ return this.updateRun(runId, patch).then(() => true);
9870
+ }
9871
+ applyRunPatch(runId, inputPatch) {
9711
9872
  const patch = inputPatch.status && TERMINAL_RUN_STATUSES.includes(inputPatch.status) ? {
9712
9873
  ...inputPatch,
9713
9874
  tags: ("tags" in inputPatch ? inputPatch.tags : this.runs.get(runId)?.tags)?.filter((tag) => tag !== SINGLETON_ADMITTED_TAG)
@@ -9798,6 +9959,13 @@ var InMemoryStateStore = class {
9798
9959
  return true;
9799
9960
  });
9800
9961
  }
9962
+ async listOrphanedRuns(nowMs, limit, namespace) {
9963
+ return [
9964
+ ...this.runs.values()
9965
+ ].filter((r) => (r.status === "running" || r.status === "cancelling") && (r.lockedUntil == null || r.lockedUntil <= nowMs) && (namespace === void 0 || r.namespace === namespace)).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()).slice(0, Math.max(0, limit)).map((r) => ({
9966
+ ...r
9967
+ }));
9968
+ }
9801
9969
  async listIncompleteRuns(namespace) {
9802
9970
  return [
9803
9971
  ...this.runs.values()