@dudousxd/nestjs-durable-core 0.52.0 → 0.53.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.cjs CHANGED
@@ -953,6 +953,47 @@ var RemoteWorkflowExecutor = class {
953
953
  }
954
954
  };
955
955
 
956
+ // src/run-waiting.ts
957
+ var WEBHOOK_PREFIX = "wh:";
958
+ var CHILD_PREFIX = "child:";
959
+ var BREAKPOINT_PREFIX = "bp:";
960
+ var EVENT_PREFIX = "event:";
961
+ function classifyWaiterToken(token) {
962
+ if (token.startsWith(WEBHOOK_PREFIX)) return {
963
+ on: "webhook",
964
+ name: token
965
+ };
966
+ if (token.startsWith(BREAKPOINT_PREFIX)) return {
967
+ on: "breakpoint",
968
+ name: "breakpoint"
969
+ };
970
+ if (token.startsWith(CHILD_PREFIX)) return {
971
+ on: "child",
972
+ name: token.slice(CHILD_PREFIX.length)
973
+ };
974
+ if (token.startsWith(EVENT_PREFIX)) return {
975
+ on: "signal",
976
+ name: eventNameOf(token)
977
+ };
978
+ return {
979
+ on: "signal",
980
+ name: token
981
+ };
982
+ }
983
+ __name(classifyWaiterToken, "classifyWaiterToken");
984
+ function resolveRunWaiting(run, waiterByRun) {
985
+ if (run.status !== "suspended") return void 0;
986
+ const waiter = waiterByRun.get(run.id);
987
+ return waiter ? classifyWaiterToken(waiter.token) : void 0;
988
+ }
989
+ __name(resolveRunWaiting, "resolveRunWaiting");
990
+ function indexWaitersByRun(waiters) {
991
+ const byRun = /* @__PURE__ */ new Map();
992
+ for (const w of waiters) if (!byRun.has(w.runId)) byRun.set(w.runId, w);
993
+ return byRun;
994
+ }
995
+ __name(indexWaitersByRun, "indexWaitersByRun");
996
+
956
997
  // src/singleton-gate.ts
957
998
  var SINGLETON_RETRY_MS = 1e3;
958
999
  var SINGLETON_RETRY_JITTER_MS = 250;
@@ -1408,6 +1449,15 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1408
1449
  runId,
1409
1450
  seq: current
1410
1451
  });
1452
+ const lateBuffered2 = await consumeBuffered(token, current);
1453
+ if (lateBuffered2) {
1454
+ await store.removeSignalWaiter({
1455
+ token,
1456
+ runId,
1457
+ seq: current
1458
+ });
1459
+ return lateBuffered2.value;
1460
+ }
1411
1461
  throw new WorkflowSuspended();
1412
1462
  }
1413
1463
  const timeoutMs = opts.timeoutMs;
@@ -1419,7 +1469,11 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1419
1469
  const buffered = await consumeBuffered(token, waitSeq);
1420
1470
  if (buffered) return buffered.value;
1421
1471
  if (host.clock() >= deadline) {
1422
- await store.takeSignalWaiter(token).catch(() => void 0);
1472
+ await store.removeSignalWaiter({
1473
+ token,
1474
+ runId,
1475
+ seq: waitSeq
1476
+ }).catch(() => void 0);
1423
1477
  throw new SignalTimeoutError(token, timeoutMs);
1424
1478
  }
1425
1479
  await store.putSignalWaiter({
@@ -1427,6 +1481,15 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1427
1481
  runId,
1428
1482
  seq: waitSeq
1429
1483
  });
1484
+ const lateBuffered = await consumeBuffered(token, waitSeq);
1485
+ if (lateBuffered) {
1486
+ await store.removeSignalWaiter({
1487
+ token,
1488
+ runId,
1489
+ seq: waitSeq
1490
+ });
1491
+ return lateBuffered.value;
1492
+ }
1430
1493
  throw new WorkflowSuspended(deadline);
1431
1494
  }, "waitForSignal");
1432
1495
  const waitForEvent = /* @__PURE__ */ __name(async (name, opts) => {
@@ -1450,7 +1513,11 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1450
1513
  const waited = await readCheckpoint(waitSeq);
1451
1514
  if (waited && waited.status === "completed") return waited.output;
1452
1515
  if (host.clock() >= deadline) {
1453
- await store.takeSignalWaiter(token).catch(() => void 0);
1516
+ await store.removeSignalWaiter({
1517
+ token,
1518
+ runId,
1519
+ seq: waitSeq
1520
+ }).catch(() => void 0);
1454
1521
  throw new SignalTimeoutError(`event:${name}`, timeoutMs);
1455
1522
  }
1456
1523
  await store.putSignalWaiter({
@@ -2596,9 +2663,46 @@ var WorkflowEngine = class {
2596
2663
  /**
2597
2664
  * Resume every suspended run whose durable timer is due. Call periodically (a poller) and on
2598
2665
  * boot. A run still not due re-suspends cheaply without running new work.
2666
+ *
2667
+ * Piggy-backs the signal-waiter reconcile safety net (see {@link reconcileSignalWaiter}): a crash
2668
+ * between the waiter/buffer pairing ops on EITHER side (this run's own `putSignalWaiter` →
2669
+ * `takeBufferedSignal`, or a signaler's `takeSignalWaiter` → `bufferSignal` → re-check — see
2670
+ * `signal`'s interleaving proof) can leave both rows stranded with nothing left to pair them: the
2671
+ * waiter's turn already suspended, the signaler that buffered it already returned. Event-wait
2672
+ * suspends already carry a `reconcileMs` fallback `wakeAt` (see {@link reconcileWakeAt}) precisely so
2673
+ * they show up HERE — that's what makes this due-timer pass the natural place for the sweep. One
2674
+ * bulk waiter scan, filtered to just this tick's batch, keeps it cheap regardless of store size.
2599
2675
  */
2600
2676
  async resumeDueTimers(nowMs = this.clock()) {
2601
- return this.resumeLeased(await this.store.listDueTimers(nowMs, this.namespace), nowMs);
2677
+ const due = await this.store.listDueTimers(nowMs, this.namespace);
2678
+ if (due.length === 0) return [];
2679
+ const waiterByRun = indexWaitersByRun(await this.store.listSignalWaiters(""));
2680
+ return this.resumeLeased(due, nowMs, (run) => this.reconcileSignalWaiter(run, waiterByRun.get(run.id)));
2681
+ }
2682
+ /**
2683
+ * The reconcile safety net's per-run check (see {@link resumeDueTimers}): if this suspended run has
2684
+ * a registered signal waiter AND its token already has a buffered payload, pair and deliver them
2685
+ * directly instead of falling through to a full replay. Event/child/breakpoint waiter tokens are
2686
+ * never buffered (`publishEvent` doesn't buffer, and a child/breakpoint notifies via `signal` only
2687
+ * once its counterpart is already registered) — so `takeBufferedSignal` on those is a guaranteed
2688
+ * miss, and this is naturally signal-only without needing to classify the token.
2689
+ *
2690
+ * Returns `undefined` (not settled — `resumeLeased` proceeds with its normal `resume`) when there's
2691
+ * no waiter or no buffered hit. Deliberately never returns `null`: a delivered signal always resumes
2692
+ * via {@link deliverSignal}, and even a `NamespaceMismatch` there is reported as a real (non-nullish)
2693
+ * result — returning `null` would read as "not settled" to `resumeLeased`'s `??` fallback and
2694
+ * trigger a second, redundant `resume` call on the same run.
2695
+ */
2696
+ async reconcileSignalWaiter(run, waiter) {
2697
+ if (!waiter) return void 0;
2698
+ const buffered = await this.store.takeBufferedSignal(waiter.token);
2699
+ if (!buffered) return void 0;
2700
+ await this.store.removeSignalWaiter(waiter);
2701
+ const settled = await this.deliverSignal(waiter, buffered.payload);
2702
+ return settled ?? {
2703
+ runId: run.id,
2704
+ status: run.status
2705
+ };
2602
2706
  }
2603
2707
  /**
2604
2708
  * Lease and execute one run by id — the worker side of dispatch. Acquires the recovery lease (so
@@ -2816,16 +2920,17 @@ var WorkflowEngine = class {
2816
2920
  }
2817
2921
  return touched;
2818
2922
  }
2819
- async signal(token, payload) {
2820
- const waiter = await this.store.takeSignalWaiter(token);
2821
- if (!waiter) {
2822
- await this.store.bufferSignal(token, payload);
2823
- return null;
2824
- }
2923
+ /**
2924
+ * Deliver a signal to an already-known waiter: write its resolving `signal:<token>` checkpoint and
2925
+ * resume the run. Shared by {@link signal}'s direct hit, its post-buffer re-check reclaim, and the
2926
+ * due-timer reconcile sweep ({@link reconcileSignalWaiter}) — all three resolve a waiter the exact
2927
+ * same way.
2928
+ */
2929
+ async deliverSignal(waiter, payload) {
2825
2930
  await this.store.saveCheckpoint(instantCheckpoint({
2826
2931
  runId: waiter.runId,
2827
2932
  seq: waiter.seq,
2828
- name: `signal:${token}`,
2933
+ name: `signal:${waiter.token}`,
2829
2934
  kind: "signal",
2830
2935
  output: payload,
2831
2936
  // Carry the awaiting command's fan group (set when a `ctx.gather_children`/`ctx.all` fan-out
@@ -2840,6 +2945,42 @@ var WorkflowEngine = class {
2840
2945
  });
2841
2946
  }
2842
2947
  /**
2948
+ * Deliver an external signal to the run waiting on `token`, resume it with `payload`, and return
2949
+ * the run result — or null if no run is (or ends up) waiting for it.
2950
+ *
2951
+ * Race with the waiter side (`waitForSignal`'s check → `putSignalWaiter` → re-check, same shape in
2952
+ * the remote `waitSignal` command): a waiter can register in the sliver between this method's
2953
+ * initial `takeSignalWaiter` miss and its `bufferSignal` write, and the naive old flow (buffer and
2954
+ * return) would then leave BOTH a buffered payload and a registered waiter sitting in the store with
2955
+ * nothing left to pair them — a lost wake, forever suspended.
2956
+ *
2957
+ * The interleaving proof: the waiter side does check → put → re-check, this side does take → buffer
2958
+ * → re-check. Whichever side's SECOND look runs last is guaranteed to observe the other side's
2959
+ * write, because it always follows that write in real time:
2960
+ * - If the waiter's `put` lands before this method's first `take`, the take just finds it directly
2961
+ * (the pre-existing, non-racy path).
2962
+ * - If the waiter's `put` lands after this method's `bufferSignal`, our re-check below either sees
2963
+ * it (if `put` also lands before the re-check) or the waiter's OWN post-put re-check sees our
2964
+ * buffer (if `put` lands after our re-check but the waiter hasn't re-checked yet either — its
2965
+ * re-check strictly follows its own put).
2966
+ * - If the waiter's `put` lands between this method's `take` and its re-`take`, the re-check below
2967
+ * catches it.
2968
+ * Either way, the destructive `take*` ops (on the waiter row, on the buffer) arbitrate: whichever
2969
+ * side actually removes the row is the one that delivers, so a signal is never delivered twice.
2970
+ */
2971
+ async signal(token, payload) {
2972
+ const waiter = await this.store.takeSignalWaiter(token);
2973
+ if (waiter) return this.deliverSignal(waiter, payload);
2974
+ await this.store.bufferSignal(token, payload);
2975
+ const lateWaiter = await this.store.takeSignalWaiter(token);
2976
+ if (!lateWaiter) return null;
2977
+ const reclaimed = await this.store.takeBufferedSignal(token);
2978
+ if (!reclaimed) {
2979
+ return null;
2980
+ }
2981
+ return this.deliverSignal(lateWaiter, reclaimed.payload);
2982
+ }
2983
+ /**
2843
2984
  * Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
2844
2985
  * signal buffering: if the run is new (or busy / not yet waiting), the signal is buffered and
2845
2986
  * consumed when it reaches `waitForSignal(token)`. The canonical **durable-entity / accumulator**
@@ -3312,6 +3453,14 @@ var WorkflowEngine = class {
3312
3453
  error: outcome.error
3313
3454
  };
3314
3455
  }
3456
+ const latest = await this.store.getRun(run.id);
3457
+ if (latest?.status === "cancelled") {
3458
+ return {
3459
+ runId: run.id,
3460
+ status: "cancelled",
3461
+ error: latest.error
3462
+ };
3463
+ }
3315
3464
  await this.store.updateRun(run.id, {
3316
3465
  status: "suspended",
3317
3466
  wakeAt: this.reconcileWakeAt(outcome.wakeAt),
@@ -3654,22 +3803,34 @@ var WorkflowEngine = class {
3654
3803
  }));
3655
3804
  wakeAt = wakeAt == null ? deadline : Math.min(wakeAt, deadline);
3656
3805
  } else if (cmd.kind === "waitSignal") {
3657
- const buffered = await this.store.takeBufferedSignal(cmd.signal);
3658
- if (buffered) {
3806
+ const deliverBuffered = /* @__PURE__ */ __name(async (payload) => {
3659
3807
  await this.store.saveCheckpoint(instantCheckpoint({
3660
3808
  runId: run.id,
3661
3809
  seq: cmd.seq,
3662
3810
  name: `signal:${cmd.signal}`,
3663
3811
  kind: "signal",
3664
- output: buffered.payload
3812
+ output: payload
3665
3813
  }));
3666
3814
  setTimeout(() => void this.resume(run.id).catch(() => void 0), 0);
3815
+ }, "deliverBuffered");
3816
+ const buffered = await this.store.takeBufferedSignal(cmd.signal);
3817
+ if (buffered) {
3818
+ await deliverBuffered(buffered.payload);
3667
3819
  } else {
3668
3820
  await this.store.putSignalWaiter({
3669
3821
  token: cmd.signal,
3670
3822
  runId: run.id,
3671
3823
  seq: cmd.seq
3672
3824
  });
3825
+ const lateBuffered = await this.store.takeBufferedSignal(cmd.signal);
3826
+ if (lateBuffered) {
3827
+ await this.store.removeSignalWaiter({
3828
+ token: cmd.signal,
3829
+ runId: run.id,
3830
+ seq: cmd.seq
3831
+ });
3832
+ await deliverBuffered(lateBuffered.payload);
3833
+ }
3673
3834
  }
3674
3835
  } else if (cmd.kind === "startChild") {
3675
3836
  const childId = `${run.id}.child.${cmd.seq}`;
@@ -4529,47 +4690,6 @@ var TERMINAL_RUN_STATUSES = [
4529
4690
  "dead"
4530
4691
  ];
4531
4692
 
4532
- // src/run-waiting.ts
4533
- var WEBHOOK_PREFIX = "wh:";
4534
- var CHILD_PREFIX = "child:";
4535
- var BREAKPOINT_PREFIX = "bp:";
4536
- var EVENT_PREFIX = "event:";
4537
- function classifyWaiterToken(token) {
4538
- if (token.startsWith(WEBHOOK_PREFIX)) return {
4539
- on: "webhook",
4540
- name: token
4541
- };
4542
- if (token.startsWith(BREAKPOINT_PREFIX)) return {
4543
- on: "breakpoint",
4544
- name: "breakpoint"
4545
- };
4546
- if (token.startsWith(CHILD_PREFIX)) return {
4547
- on: "child",
4548
- name: token.slice(CHILD_PREFIX.length)
4549
- };
4550
- if (token.startsWith(EVENT_PREFIX)) return {
4551
- on: "signal",
4552
- name: eventNameOf(token)
4553
- };
4554
- return {
4555
- on: "signal",
4556
- name: token
4557
- };
4558
- }
4559
- __name(classifyWaiterToken, "classifyWaiterToken");
4560
- function resolveRunWaiting(run, waiterByRun) {
4561
- if (run.status !== "suspended") return void 0;
4562
- const waiter = waiterByRun.get(run.id);
4563
- return waiter ? classifyWaiterToken(waiter.token) : void 0;
4564
- }
4565
- __name(resolveRunWaiting, "resolveRunWaiting");
4566
- function indexWaitersByRun(waiters) {
4567
- const byRun = /* @__PURE__ */ new Map();
4568
- for (const w of waiters) if (!byRun.has(w.runId)) byRun.set(w.runId, w);
4569
- return byRun;
4570
- }
4571
- __name(indexWaitersByRun, "indexWaitersByRun");
4572
-
4573
4693
  // src/codec-state-store.ts
4574
4694
  var CodecStateStore = class {
4575
4695
  static {
@@ -4677,6 +4797,9 @@ var CodecStateStore = class {
4677
4797
  listSignalWaiters(prefix) {
4678
4798
  return this.inner.listSignalWaiters(prefix);
4679
4799
  }
4800
+ removeSignalWaiter(waiter) {
4801
+ return this.inner.removeSignalWaiter(waiter);
4802
+ }
4680
4803
  bufferSignal(token, payload) {
4681
4804
  return this.inner.bufferSignal(token, this.enc(payload));
4682
4805
  }
@@ -5129,6 +5252,12 @@ var InMemoryStateStore = class {
5129
5252
  ...w
5130
5253
  }));
5131
5254
  }
5255
+ async removeSignalWaiter(waiter) {
5256
+ const current = this.signalWaiters.get(waiter.token);
5257
+ if (current && current.runId === waiter.runId && current.seq === waiter.seq) {
5258
+ this.signalWaiters.delete(waiter.token);
5259
+ }
5260
+ }
5132
5261
  // No real DB transaction (there's no DB) — run the work and save the checkpoint. Exactly-once
5133
5262
  // across a crash needs a SQL store; this keeps `ctx.transaction` usable in tests / local dev.
5134
5263
  async transaction(work) {