@dudousxd/nestjs-durable-core 0.79.0 → 0.80.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.
package/dist/index.js CHANGED
@@ -3859,6 +3859,16 @@ function isNewerVersion(a, b) {
3859
3859
  }) > 0;
3860
3860
  }
3861
3861
  __name(isNewerVersion, "isNewerVersion");
3862
+ var SETTLE_ELIGIBLE_STATUSES = [
3863
+ "pending",
3864
+ "running",
3865
+ "suspended",
3866
+ "blocked",
3867
+ "failed",
3868
+ "cancelling"
3869
+ ];
3870
+ var SUSPEND_ELIGIBLE_STATUSES = SETTLE_ELIGIBLE_STATUSES.filter((status) => status !== "cancelling");
3871
+ var RECOVERY_BATCH = 500;
3862
3872
  var NamespaceMismatch = class NamespaceMismatch2 extends Error {
3863
3873
  static {
3864
3874
  __name(this, "NamespaceMismatch");
@@ -3958,6 +3968,18 @@ var WorkflowEngine = class {
3958
3968
  awaitedStepFans = /* @__PURE__ */ new Map();
3959
3969
  /** Step fans whose join barrier this instance opened, by `runId:firstSeq` — see {@link settledHere}. */
3960
3970
  stepFansFiredHere = /* @__PURE__ */ new Map();
3971
+ /**
3972
+ * The in-process execution gate, by run id: the execution of the run currently in flight in THIS
3973
+ * process (`tail`), and at most one resume queued behind it (`queued`). See {@link resumeRun}.
3974
+ *
3975
+ * The run lease is keyed by engine instance, so it fences OTHER processes only: to the store, a
3976
+ * second execution in this same process holds the very same lease (and {@link execute} skips
3977
+ * `tryLockRun` when its snapshot says this instance holds it). Without this gate, a resume landing
3978
+ * while this process was still executing the run (a signal, a late step result, a child wake, an
3979
+ * explicit `resume()`) went straight past the lease and ran the body concurrently: a
3980
+ * not-yet-checkpointed step executed twice.
3981
+ */
3982
+ runGates = /* @__PURE__ */ new Map();
3961
3983
  /** The tail of each run's queue of results being handled on this instance — see {@link serializedPerRun}. */
3962
3984
  resultChains = /* @__PURE__ */ new Map();
3963
3985
  /** Each run's results waiting to be settled on this instance — see {@link completeRemoteResult}. */
@@ -4426,11 +4448,16 @@ var WorkflowEngine = class {
4426
4448
  if (registered.singleton) {
4427
4449
  await this.singletons.assertCapacity(name, registered.singleton, input);
4428
4450
  }
4451
+ const startAtMs = opts?.startAt instanceof Date ? opts.startAt.getTime() : opts?.startAt;
4452
+ const delayed = startAtMs !== void 0 && Number.isFinite(startAtMs) && startAtMs > this.clock();
4429
4453
  const run = {
4430
4454
  id: runId,
4431
4455
  workflow: name,
4432
4456
  workflowVersion: registered.version,
4433
- status: "pending",
4457
+ status: delayed ? "suspended" : "pending",
4458
+ ...delayed ? {
4459
+ wakeAt: startAtMs
4460
+ } : {},
4434
4461
  namespace: opts?.namespace ?? this.namespace,
4435
4462
  // DERIVED from the registration, exactly like `namespace` is derived from this engine — there is
4436
4463
  // deliberately no `opts.origin`, so a caller cannot label its run as another package's work.
@@ -4456,6 +4483,18 @@ var WorkflowEngine = class {
4456
4483
  error: winner.error
4457
4484
  };
4458
4485
  }
4486
+ if (delayed) {
4487
+ this.emit({
4488
+ type: "run.suspended",
4489
+ runId,
4490
+ workflow: name,
4491
+ namespace: run.namespace
4492
+ });
4493
+ return {
4494
+ runId,
4495
+ status: "suspended"
4496
+ };
4497
+ }
4459
4498
  await this.runDispatcher.dispatch(runId);
4460
4499
  if (this.controlPlane) {
4461
4500
  void this.controlPlane.publishControl({
@@ -4493,7 +4532,104 @@ var WorkflowEngine = class {
4493
4532
  resumeImplicit(runId) {
4494
4533
  return this.resumeRun(runId, false);
4495
4534
  }
4496
- async resumeRun(runId, allowFailed) {
4535
+ /**
4536
+ * One execution of a run at a time in this process. A resume that lands while the run executes
4537
+ * here neither joins that execution (the double-run) nor no-ops against its held lease (a lost
4538
+ * wake: its checkpoint may postdate the running turn's snapshot): it is QUEUED, and re-drives the
4539
+ * run once the running execution has settled and released its lease, re-reading the run then
4540
+ * (terminal: echoed, never re-executed). Resumes landing while one is already queued coalesce
4541
+ * into it: it has not started yet, so it will observe every write they made. Their callers get
4542
+ * the queued execution's result, i.e. the run's state after their own wake was applied.
4543
+ *
4544
+ * Across processes, exclusion stays the lease's job: {@link execute} still `tryLockRun`s when it
4545
+ * does not hold the lease.
4546
+ */
4547
+ resumeRun(runId, allowFailed) {
4548
+ const gate = this.runGates.get(runId);
4549
+ if (!gate) {
4550
+ const tail = this.resumeRunNow(runId, allowFailed);
4551
+ const fresh = {
4552
+ tail
4553
+ };
4554
+ this.runGates.set(runId, fresh);
4555
+ this.closeGateAfter(runId, fresh, tail);
4556
+ return tail;
4557
+ }
4558
+ let queued = gate.queued;
4559
+ if (queued) {
4560
+ queued.allowFailed ||= allowFailed;
4561
+ } else {
4562
+ const entry = {
4563
+ allowFailed
4564
+ };
4565
+ const result = gate.tail.catch(() => void 0).then(() => {
4566
+ if (gate.queued === entry) gate.queued = void 0;
4567
+ return this.resumeRunNow(runId, entry.allowFailed);
4568
+ });
4569
+ entry.result = result;
4570
+ queued = entry;
4571
+ gate.queued = queued;
4572
+ gate.tail = result;
4573
+ this.closeGateAfter(runId, gate, result);
4574
+ void this.track(result).catch(() => void 0);
4575
+ }
4576
+ if (currentWorkflowCtx()?.runId === runId) return this.echoCurrentStatus(runId);
4577
+ return queued.result;
4578
+ }
4579
+ /** Drop `gate` once `tail` settles, unless another execution queued behind it meanwhile. */
4580
+ closeGateAfter(runId, gate, tail) {
4581
+ const close = /* @__PURE__ */ __name(() => {
4582
+ if (this.runGates.get(runId) === gate && gate.tail === tail) this.runGates.delete(runId);
4583
+ }, "close");
4584
+ void tail.then(close, close);
4585
+ }
4586
+ /**
4587
+ * Release a run's lease AFTER its outcome is decided (persisted, or about to be returned). Best
4588
+ * effort, and that matters most on the failure path: the thing that failed the run is frequently
4589
+ * the very store this release talks to (a step that violated a DB constraint leaves Postgres
4590
+ * refusing every further statement on that connection), so the release throws too. A throwing
4591
+ * release in a `finally` REPLACES the run's real outcome with a lease write nobody asked about,
4592
+ * skips the post-turn re-drives after it, and, for a turn driven as a background resume, surfaces
4593
+ * as an unhandled rejection instead of a failed run.
4594
+ *
4595
+ * Letting it go is safe: the lease expires on its own and `recoverIncomplete` re-drives whatever
4596
+ * is still held. A lost release costs one lease window of latency; a masked error costs the
4597
+ * diagnosis.
4598
+ */
4599
+ async releaseLeaseBestEffort(runId) {
4600
+ try {
4601
+ await this.store.releaseRunLock(runId);
4602
+ } catch (err) {
4603
+ 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)}`);
4604
+ }
4605
+ }
4606
+ /**
4607
+ * The run's persisted state as a {@link RunResult}, without executing anything: the answer to a
4608
+ * resume issued from inside the run's own execution, and to a settle whose compare-and-set lost
4609
+ * (the path that won already emitted and notified). A run that is gone reads as `cancelled`, the
4610
+ * safest terminal reading, never a fabricated success.
4611
+ */
4612
+ async echoCurrentStatus(runId) {
4613
+ const run = await this.store.getRun(runId);
4614
+ return {
4615
+ runId,
4616
+ status: run?.status ?? "cancelled",
4617
+ output: run?.output,
4618
+ error: run?.error
4619
+ };
4620
+ }
4621
+ /**
4622
+ * The engine's stand-in for {@link StateStore.updateRunIf} on a store without it, called with the
4623
+ * store as `this`: read-then-write, which narrows the race to the gap between the read and the
4624
+ * write (the pre-CAS behavior, now applied to every settle) but cannot close it.
4625
+ */
4626
+ async updateRunIfFallback(runId, expected, patch) {
4627
+ const current = await this.getRun(runId);
4628
+ if (!current || !expected.includes(current.status)) return false;
4629
+ await this.updateRun(runId, patch);
4630
+ return true;
4631
+ }
4632
+ async resumeRunNow(runId, allowFailed) {
4497
4633
  const run = await this.store.getRun(runId);
4498
4634
  if (!run) throw new Error(`run ${runId} not found`);
4499
4635
  if (run.status === "cancelled" || run.status === "completed" || run.status === "dead" || run.status === "failed" && !allowFailed) {
@@ -4799,7 +4935,7 @@ var WorkflowEngine = class {
4799
4935
  async recoverIncomplete(nowMs = this.clock()) {
4800
4936
  if (this.draining) return [];
4801
4937
  const results = [];
4802
- for (const listed of await this.store.listIncompleteRuns(this.namespace)) {
4938
+ for (const listed of await this.orphanedRuns(nowMs)) {
4803
4939
  if (this.draining) break;
4804
4940
  const acquired = await this.store.tryLockRun(listed.id, this.instanceId, nowMs + this.leaseMs, nowMs);
4805
4941
  if (!acquired) continue;
@@ -4845,6 +4981,21 @@ var WorkflowEngine = class {
4845
4981
  return results;
4846
4982
  }
4847
4983
  /**
4984
+ * The runs {@link recoverIncomplete} should try to reclaim: incomplete runs whose lease is free or
4985
+ * expired, never the healthy ones a live turn is renewing. Every worker runs recovery on every
4986
+ * timer tick, so listing every running run and lock-probing each (one doomed UPDATE per healthy
4987
+ * run per worker per tick) was pure database load. The store answers it directly when it can
4988
+ * ({@link StateStore.listOrphanedRuns}, bounded by {@link RECOVERY_BATCH}); else the full list is
4989
+ * filtered here, which still spares the probes. Shuffled, like the due-timer pass, so instances
4990
+ * recovering at once spread over the batch instead of contending for the same leases.
4991
+ */
4992
+ async orphanedRuns(nowMs) {
4993
+ if (this.store.listOrphanedRuns) {
4994
+ return shuffle(await this.store.listOrphanedRuns(nowMs, RECOVERY_BATCH, this.namespace));
4995
+ }
4996
+ return shuffle((await this.store.listIncompleteRuns(this.namespace)).filter((run) => run.lockedUntil == null || run.lockedUntil <= nowMs));
4997
+ }
4998
+ /**
4848
4999
  * The remote step this run dispatched and is still awaiting a result for, if any — the signature
4849
5000
  * that tells {@link recoverIncomplete} "this run's work is ON THE TRANSPORT, not in a crashed
4850
5001
  * process". A `pending` remote checkpoint is written BEFORE the dispatch and settled by the
@@ -6361,12 +6512,13 @@ ${run.workflowVersion}`;
6361
6512
  async settleRun(run, outcome) {
6362
6513
  const updatedAt = /* @__PURE__ */ new Date();
6363
6514
  if (outcome.kind === "completed") {
6364
- await this.store.updateRun(run.id, {
6515
+ const applied2 = await (this.store.updateRunIf ?? this.updateRunIfFallback).call(this.store, run.id, SETTLE_ELIGIBLE_STATUSES, {
6365
6516
  status: "completed",
6366
6517
  output: outcome.output,
6367
6518
  error: void 0,
6368
6519
  updatedAt
6369
6520
  });
6521
+ if (!applied2) return this.echoCurrentStatus(run.id);
6370
6522
  this.emit({
6371
6523
  type: "run.completed",
6372
6524
  runId: run.id,
@@ -6388,11 +6540,12 @@ ${run.workflowVersion}`;
6388
6540
  const handed = await this.routeFailure(run, outcome.error);
6389
6541
  if (handed && handed.kind !== "failed") return this.settleRun(run, handed);
6390
6542
  const error2 = handed?.error ?? outcome.error;
6391
- await this.store.updateRun(run.id, {
6543
+ const applied2 = await (this.store.updateRunIf ?? this.updateRunIfFallback).call(this.store, run.id, SETTLE_ELIGIBLE_STATUSES, {
6392
6544
  status: "failed",
6393
6545
  error: error2,
6394
6546
  updatedAt
6395
6547
  });
6548
+ if (!applied2) return this.echoCurrentStatus(run.id);
6396
6549
  this.emit({
6397
6550
  type: "run.failed",
6398
6551
  runId: run.id,
@@ -6408,19 +6561,12 @@ ${run.workflowVersion}`;
6408
6561
  };
6409
6562
  }
6410
6563
  if (outcome.kind === "blocked") return this.parkBlocked(run, outcome.blocked);
6411
- const latest = await this.store.getRun(run.id);
6412
- if (latest?.status === "cancelled") {
6413
- return {
6414
- runId: run.id,
6415
- status: "cancelled",
6416
- error: latest.error
6417
- };
6418
- }
6419
- await this.store.updateRun(run.id, {
6564
+ const applied = await (this.store.updateRunIf ?? this.updateRunIfFallback).call(this.store, run.id, SUSPEND_ELIGIBLE_STATUSES, {
6420
6565
  status: "suspended",
6421
6566
  wakeAt: this.reconcileWakeAt(outcome.wakeAt),
6422
6567
  updatedAt
6423
6568
  });
6569
+ if (!applied) return this.echoCurrentStatus(run.id);
6424
6570
  this.emit({
6425
6571
  type: "run.suspended",
6426
6572
  runId: run.id,
@@ -6467,7 +6613,7 @@ ${run.workflowVersion}`;
6467
6613
  from: this.instanceId
6468
6614
  }).catch(() => void 0);
6469
6615
  }
6470
- await this.store.releaseRunLock(run.id);
6616
+ await this.releaseLeaseBestEffort(run.id);
6471
6617
  return {
6472
6618
  runId: run.id,
6473
6619
  status: "cancelled",
@@ -6497,7 +6643,7 @@ ${run.workflowVersion}`;
6497
6643
  const handoff = liveHandoff(run.id, checkpoints);
6498
6644
  if (handoff) {
6499
6645
  const result = await this.settleRun(run, await this.followHandoff(run, handoff));
6500
- await this.store.releaseRunLock(run.id);
6646
+ await this.releaseLeaseBestEffort(run.id);
6501
6647
  return result;
6502
6648
  }
6503
6649
  const history = await this.remoteHistory(run.id, checkpoints);
@@ -6535,7 +6681,7 @@ ${run.workflowVersion}`;
6535
6681
  decision = silenceMs != null ? await this.awaitWithLivenessDeadline(run.id, advance, silenceMs, () => new RemoteWorkflowTimeout(run.id, silenceMs)) : await advance;
6536
6682
  } catch (err) {
6537
6683
  if (err instanceof RemoteWorkflowTimeout) {
6538
- await this.store.releaseRunLock(run.id);
6684
+ await this.releaseLeaseBestEffort(run.id);
6539
6685
  return {
6540
6686
  runId: run.id,
6541
6687
  status: "running"
@@ -7206,7 +7352,7 @@ ${run.workflowVersion}`;
7206
7352
  this.awaitedRemoteSteps.delete(run.id);
7207
7353
  }
7208
7354
  if (this.awaitedStepFans.get(run.id) === awaitedFans) this.awaitedStepFans.delete(run.id);
7209
- await this.store.releaseRunLock(run.id);
7355
+ await this.releaseLeaseBestEffort(run.id);
7210
7356
  if (suspendedOnFan) {
7211
7357
  void this.redriveSettledChildren(run.id, suspendedOnFan).catch(() => void 0);
7212
7358
  }
@@ -7577,27 +7723,21 @@ ${run.workflowVersion}`;
7577
7723
  * own `finally` also releases (the run-execution path) is safe.
7578
7724
  */
7579
7725
  async parkBlocked(run, blocked) {
7580
- const current = await this.store.getRun(run.id);
7581
- if (current && (current.status === "cancelled" || current.status === "completed")) {
7582
- await this.store.releaseRunLock(run.id);
7583
- return {
7584
- runId: run.id,
7585
- status: current.status,
7586
- output: current.output,
7587
- error: current.error
7588
- };
7589
- }
7590
7726
  const error2 = {
7591
7727
  message: blocked.reason,
7592
7728
  code: blocked.code,
7593
7729
  retryable: true
7594
7730
  };
7595
- await this.store.updateRun(run.id, {
7731
+ const applied = await (this.store.updateRunIf ?? this.updateRunIfFallback).call(this.store, run.id, SUSPEND_ELIGIBLE_STATUSES, {
7596
7732
  status: "blocked",
7597
7733
  error: error2,
7598
7734
  wakeAt: this.clock() + this.blockedPollMs,
7599
7735
  updatedAt: /* @__PURE__ */ new Date()
7600
7736
  });
7737
+ if (!applied) {
7738
+ await this.store.releaseRunLock(run.id);
7739
+ return this.echoCurrentStatus(run.id);
7740
+ }
7601
7741
  this.emit({
7602
7742
  type: "run.blocked",
7603
7743
  runId: run.id,
@@ -8955,6 +9095,12 @@ var CodecStateStore = class {
8955
9095
  /** Forwarded verbatim (deletes touch no payload). Bound only when the inner store has it, so the
8956
9096
  * engine's per-run fallback still kicks in through the wrapper. */
8957
9097
  deleteRuns;
9098
+ /** Decodes the runs like {@link listIncompleteRuns}. Bound only when the inner store has it, so
9099
+ * the engine's list-and-filter fallback still kicks in through the wrapper. */
9100
+ listOrphanedRuns;
9101
+ /** Encodes the patch like {@link updateRun}. Bound only when the inner store has it, so the
9102
+ * engine's read-then-write fallback still kicks in through the wrapper. */
9103
+ updateRunIf;
8958
9104
  /** Encodes like {@link saveCheckpoint}. Bound only when the inner store has it, so the engine's
8959
9105
  * plain-save fallback still kicks in through the wrapper. */
8960
9106
  saveCheckpointIfPending;
@@ -8995,6 +9141,10 @@ var CodecStateStore = class {
8995
9141
  if (inner.tryAdmitSingleton) this.tryAdmitSingleton = inner.tryAdmitSingleton.bind(inner);
8996
9142
  const deleteRuns = inner.deleteRuns;
8997
9143
  if (deleteRuns) this.deleteRuns = (ids) => deleteRuns.call(inner, ids);
9144
+ const orphans = inner.listOrphanedRuns;
9145
+ if (orphans) this.listOrphanedRuns = async (nowMs, limit, namespace) => (await orphans.call(inner, nowMs, limit, namespace)).map((r) => this.decRun(r));
9146
+ const updateIf = inner.updateRunIf;
9147
+ if (updateIf) this.updateRunIf = (runId, expected, patch) => updateIf.call(inner, runId, expected, this.encRunPatch(patch));
8998
9148
  const saveIfPending = inner.saveCheckpointIfPending;
8999
9149
  if (saveIfPending) this.saveCheckpointIfPending = (cp) => saveIfPending.call(inner, this.encCp(cp));
9000
9150
  const saveManyIfPending = inner.saveCheckpointsIfPending;
@@ -9101,12 +9251,15 @@ var CodecStateStore = class {
9101
9251
  return this.inner.createRun(this.encRun(run));
9102
9252
  }
9103
9253
  updateRun(runId, patch) {
9254
+ return this.inner.updateRun(runId, this.encRunPatch(patch));
9255
+ }
9256
+ encRunPatch(patch) {
9104
9257
  const next = {
9105
9258
  ...patch
9106
9259
  };
9107
9260
  if ("input" in patch) next.input = this.enc(patch.input);
9108
9261
  if ("output" in patch) next.output = this.enc(patch.output);
9109
- return this.inner.updateRun(runId, next);
9262
+ return next;
9110
9263
  }
9111
9264
  async getRun(runId) {
9112
9265
  return this.decRun(await this.inner.getRun(runId));
@@ -9515,6 +9668,14 @@ var InMemoryStateStore = class {
9515
9668
  this.reindexAttributes(run.id, run.searchAttributes);
9516
9669
  }
9517
9670
  async updateRun(runId, inputPatch) {
9671
+ this.applyRunPatch(runId, inputPatch);
9672
+ }
9673
+ updateRunIf(runId, expectedStatuses, patch) {
9674
+ const existing = this.runs.get(runId);
9675
+ if (!existing || !expectedStatuses.includes(existing.status)) return Promise.resolve(false);
9676
+ return this.updateRun(runId, patch).then(() => true);
9677
+ }
9678
+ applyRunPatch(runId, inputPatch) {
9518
9679
  const patch = inputPatch.status && TERMINAL_RUN_STATUSES.includes(inputPatch.status) ? {
9519
9680
  ...inputPatch,
9520
9681
  tags: ("tags" in inputPatch ? inputPatch.tags : this.runs.get(runId)?.tags)?.filter((tag) => tag !== SINGLETON_ADMITTED_TAG)
@@ -9605,6 +9766,13 @@ var InMemoryStateStore = class {
9605
9766
  return true;
9606
9767
  });
9607
9768
  }
9769
+ async listOrphanedRuns(nowMs, limit, namespace) {
9770
+ return [
9771
+ ...this.runs.values()
9772
+ ].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) => ({
9773
+ ...r
9774
+ }));
9775
+ }
9608
9776
  async listIncompleteRuns(namespace) {
9609
9777
  return [
9610
9778
  ...this.runs.values()