@dudousxd/nestjs-durable-core 0.51.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
@@ -22,6 +22,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
22
22
  // src/index.ts
23
23
  var index_exports = {};
24
24
  __export(index_exports, {
25
+ CONTROL_FLOW_SIGNAL: () => CONTROL_FLOW_SIGNAL,
25
26
  CodecStateStore: () => CodecStateStore,
26
27
  ContinueAsNew: () => ContinueAsNew,
27
28
  DURABLE_OPTIONS: () => DURABLE_OPTIONS,
@@ -67,6 +68,7 @@ __export(index_exports, {
67
68
  eventPrefix: () => eventPrefix,
68
69
  eventToken: () => eventToken,
69
70
  indexWaitersByRun: () => indexWaitersByRun,
71
+ isWorkflowControlFlowSignal: () => isWorkflowControlFlowSignal,
70
72
  matchesAttributes: () => matchesAttributes,
71
73
  normalizeAttributeRows: () => normalizeAttributeRows,
72
74
  parseDuration: () => parseDuration,
@@ -259,6 +261,13 @@ function currentWorkflowCtx() {
259
261
  }
260
262
  __name(currentWorkflowCtx, "currentWorkflowCtx");
261
263
 
264
+ // src/control-flow-signal.ts
265
+ var CONTROL_FLOW_SIGNAL = Symbol.for("aviary:durable:control-flow");
266
+ function isWorkflowControlFlowSignal(error) {
267
+ return typeof error === "object" && error !== null && error[CONTROL_FLOW_SIGNAL] === true;
268
+ }
269
+ __name(isWorkflowControlFlowSignal, "isWorkflowControlFlowSignal");
270
+
262
271
  // src/errors.ts
263
272
  var FatalError = class extends Error {
264
273
  static {
@@ -357,6 +366,8 @@ var WorkflowSuspended = class extends Error {
357
366
  }
358
367
  /** Epoch ms to auto-resume (durable sleep), or undefined when waiting on an external signal. */
359
368
  wakeAt;
369
+ /** Marks this as a control-flow signal — see {@link isWorkflowControlFlowSignal}. */
370
+ [CONTROL_FLOW_SIGNAL] = true;
360
371
  constructor(wakeAt) {
361
372
  super("workflow suspended");
362
373
  this.name = "WorkflowSuspended";
@@ -368,6 +379,8 @@ var ContinueAsNew = class extends Error {
368
379
  __name(this, "ContinueAsNew");
369
380
  }
370
381
  input;
382
+ /** Marks this as a control-flow signal — see {@link isWorkflowControlFlowSignal}. */
383
+ [CONTROL_FLOW_SIGNAL] = true;
371
384
  constructor(input) {
372
385
  super("workflow continued as new"), this.input = input;
373
386
  this.name = "ContinueAsNew";
@@ -940,6 +953,47 @@ var RemoteWorkflowExecutor = class {
940
953
  }
941
954
  };
942
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
+
943
997
  // src/singleton-gate.ts
944
998
  var SINGLETON_RETRY_MS = 1e3;
945
999
  var SINGLETON_RETRY_JITTER_MS = 250;
@@ -1395,6 +1449,15 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1395
1449
  runId,
1396
1450
  seq: current
1397
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
+ }
1398
1461
  throw new WorkflowSuspended();
1399
1462
  }
1400
1463
  const timeoutMs = opts.timeoutMs;
@@ -1406,7 +1469,11 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1406
1469
  const buffered = await consumeBuffered(token, waitSeq);
1407
1470
  if (buffered) return buffered.value;
1408
1471
  if (host.clock() >= deadline) {
1409
- await store.takeSignalWaiter(token).catch(() => void 0);
1472
+ await store.removeSignalWaiter({
1473
+ token,
1474
+ runId,
1475
+ seq: waitSeq
1476
+ }).catch(() => void 0);
1410
1477
  throw new SignalTimeoutError(token, timeoutMs);
1411
1478
  }
1412
1479
  await store.putSignalWaiter({
@@ -1414,6 +1481,15 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1414
1481
  runId,
1415
1482
  seq: waitSeq
1416
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
+ }
1417
1493
  throw new WorkflowSuspended(deadline);
1418
1494
  }, "waitForSignal");
1419
1495
  const waitForEvent = /* @__PURE__ */ __name(async (name, opts) => {
@@ -1437,7 +1513,11 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1437
1513
  const waited = await readCheckpoint(waitSeq);
1438
1514
  if (waited && waited.status === "completed") return waited.output;
1439
1515
  if (host.clock() >= deadline) {
1440
- await store.takeSignalWaiter(token).catch(() => void 0);
1516
+ await store.removeSignalWaiter({
1517
+ token,
1518
+ runId,
1519
+ seq: waitSeq
1520
+ }).catch(() => void 0);
1441
1521
  throw new SignalTimeoutError(`event:${name}`, timeoutMs);
1442
1522
  }
1443
1523
  await store.putSignalWaiter({
@@ -2583,9 +2663,46 @@ var WorkflowEngine = class {
2583
2663
  /**
2584
2664
  * Resume every suspended run whose durable timer is due. Call periodically (a poller) and on
2585
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.
2586
2675
  */
2587
2676
  async resumeDueTimers(nowMs = this.clock()) {
2588
- 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
+ };
2589
2706
  }
2590
2707
  /**
2591
2708
  * Lease and execute one run by id — the worker side of dispatch. Acquires the recovery lease (so
@@ -2803,16 +2920,17 @@ var WorkflowEngine = class {
2803
2920
  }
2804
2921
  return touched;
2805
2922
  }
2806
- async signal(token, payload) {
2807
- const waiter = await this.store.takeSignalWaiter(token);
2808
- if (!waiter) {
2809
- await this.store.bufferSignal(token, payload);
2810
- return null;
2811
- }
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) {
2812
2930
  await this.store.saveCheckpoint(instantCheckpoint({
2813
2931
  runId: waiter.runId,
2814
2932
  seq: waiter.seq,
2815
- name: `signal:${token}`,
2933
+ name: `signal:${waiter.token}`,
2816
2934
  kind: "signal",
2817
2935
  output: payload,
2818
2936
  // Carry the awaiting command's fan group (set when a `ctx.gather_children`/`ctx.all` fan-out
@@ -2827,6 +2945,42 @@ var WorkflowEngine = class {
2827
2945
  });
2828
2946
  }
2829
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
+ /**
2830
2984
  * Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
2831
2985
  * signal buffering: if the run is new (or busy / not yet waiting), the signal is buffered and
2832
2986
  * consumed when it reaches `waitForSignal(token)`. The canonical **durable-entity / accumulator**
@@ -3299,6 +3453,14 @@ var WorkflowEngine = class {
3299
3453
  error: outcome.error
3300
3454
  };
3301
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
+ }
3302
3464
  await this.store.updateRun(run.id, {
3303
3465
  status: "suspended",
3304
3466
  wakeAt: this.reconcileWakeAt(outcome.wakeAt),
@@ -3641,22 +3803,34 @@ var WorkflowEngine = class {
3641
3803
  }));
3642
3804
  wakeAt = wakeAt == null ? deadline : Math.min(wakeAt, deadline);
3643
3805
  } else if (cmd.kind === "waitSignal") {
3644
- const buffered = await this.store.takeBufferedSignal(cmd.signal);
3645
- if (buffered) {
3806
+ const deliverBuffered = /* @__PURE__ */ __name(async (payload) => {
3646
3807
  await this.store.saveCheckpoint(instantCheckpoint({
3647
3808
  runId: run.id,
3648
3809
  seq: cmd.seq,
3649
3810
  name: `signal:${cmd.signal}`,
3650
3811
  kind: "signal",
3651
- output: buffered.payload
3812
+ output: payload
3652
3813
  }));
3653
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);
3654
3819
  } else {
3655
3820
  await this.store.putSignalWaiter({
3656
3821
  token: cmd.signal,
3657
3822
  runId: run.id,
3658
3823
  seq: cmd.seq
3659
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
+ }
3660
3834
  }
3661
3835
  } else if (cmd.kind === "startChild") {
3662
3836
  const childId = `${run.id}.child.${cmd.seq}`;
@@ -4516,47 +4690,6 @@ var TERMINAL_RUN_STATUSES = [
4516
4690
  "dead"
4517
4691
  ];
4518
4692
 
4519
- // src/run-waiting.ts
4520
- var WEBHOOK_PREFIX = "wh:";
4521
- var CHILD_PREFIX = "child:";
4522
- var BREAKPOINT_PREFIX = "bp:";
4523
- var EVENT_PREFIX = "event:";
4524
- function classifyWaiterToken(token) {
4525
- if (token.startsWith(WEBHOOK_PREFIX)) return {
4526
- on: "webhook",
4527
- name: token
4528
- };
4529
- if (token.startsWith(BREAKPOINT_PREFIX)) return {
4530
- on: "breakpoint",
4531
- name: "breakpoint"
4532
- };
4533
- if (token.startsWith(CHILD_PREFIX)) return {
4534
- on: "child",
4535
- name: token.slice(CHILD_PREFIX.length)
4536
- };
4537
- if (token.startsWith(EVENT_PREFIX)) return {
4538
- on: "signal",
4539
- name: eventNameOf(token)
4540
- };
4541
- return {
4542
- on: "signal",
4543
- name: token
4544
- };
4545
- }
4546
- __name(classifyWaiterToken, "classifyWaiterToken");
4547
- function resolveRunWaiting(run, waiterByRun) {
4548
- if (run.status !== "suspended") return void 0;
4549
- const waiter = waiterByRun.get(run.id);
4550
- return waiter ? classifyWaiterToken(waiter.token) : void 0;
4551
- }
4552
- __name(resolveRunWaiting, "resolveRunWaiting");
4553
- function indexWaitersByRun(waiters) {
4554
- const byRun = /* @__PURE__ */ new Map();
4555
- for (const w of waiters) if (!byRun.has(w.runId)) byRun.set(w.runId, w);
4556
- return byRun;
4557
- }
4558
- __name(indexWaitersByRun, "indexWaitersByRun");
4559
-
4560
4693
  // src/codec-state-store.ts
4561
4694
  var CodecStateStore = class {
4562
4695
  static {
@@ -4664,6 +4797,9 @@ var CodecStateStore = class {
4664
4797
  listSignalWaiters(prefix) {
4665
4798
  return this.inner.listSignalWaiters(prefix);
4666
4799
  }
4800
+ removeSignalWaiter(waiter) {
4801
+ return this.inner.removeSignalWaiter(waiter);
4802
+ }
4667
4803
  bufferSignal(token, payload) {
4668
4804
  return this.inner.bufferSignal(token, this.enc(payload));
4669
4805
  }
@@ -5116,6 +5252,12 @@ var InMemoryStateStore = class {
5116
5252
  ...w
5117
5253
  }));
5118
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
+ }
5119
5261
  // No real DB transaction (there's no DB) — run the work and save the checkpoint. Exactly-once
5120
5262
  // across a crash needs a SQL store; this keeps `ctx.transaction` usable in tests / local dev.
5121
5263
  async transaction(work) {
@@ -5290,6 +5432,7 @@ var InMemoryTransport = class {
5290
5432
  };
5291
5433
  // Annotate the CommonJS export names for ESM import in node:
5292
5434
  0 && (module.exports = {
5435
+ CONTROL_FLOW_SIGNAL,
5293
5436
  CodecStateStore,
5294
5437
  ContinueAsNew,
5295
5438
  DURABLE_OPTIONS,
@@ -5335,6 +5478,7 @@ var InMemoryTransport = class {
5335
5478
  eventPrefix,
5336
5479
  eventToken,
5337
5480
  indexWaitersByRun,
5481
+ isWorkflowControlFlowSignal,
5338
5482
  matchesAttributes,
5339
5483
  normalizeAttributeRows,
5340
5484
  parseDuration,