@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.js CHANGED
@@ -173,6 +173,13 @@ function currentWorkflowCtx() {
173
173
  }
174
174
  __name(currentWorkflowCtx, "currentWorkflowCtx");
175
175
 
176
+ // src/control-flow-signal.ts
177
+ var CONTROL_FLOW_SIGNAL = Symbol.for("aviary:durable:control-flow");
178
+ function isWorkflowControlFlowSignal(error) {
179
+ return typeof error === "object" && error !== null && error[CONTROL_FLOW_SIGNAL] === true;
180
+ }
181
+ __name(isWorkflowControlFlowSignal, "isWorkflowControlFlowSignal");
182
+
176
183
  // src/errors.ts
177
184
  var FatalError = class extends Error {
178
185
  static {
@@ -271,6 +278,8 @@ var WorkflowSuspended = class extends Error {
271
278
  }
272
279
  /** Epoch ms to auto-resume (durable sleep), or undefined when waiting on an external signal. */
273
280
  wakeAt;
281
+ /** Marks this as a control-flow signal — see {@link isWorkflowControlFlowSignal}. */
282
+ [CONTROL_FLOW_SIGNAL] = true;
274
283
  constructor(wakeAt) {
275
284
  super("workflow suspended");
276
285
  this.name = "WorkflowSuspended";
@@ -282,6 +291,8 @@ var ContinueAsNew = class extends Error {
282
291
  __name(this, "ContinueAsNew");
283
292
  }
284
293
  input;
294
+ /** Marks this as a control-flow signal — see {@link isWorkflowControlFlowSignal}. */
295
+ [CONTROL_FLOW_SIGNAL] = true;
285
296
  constructor(input) {
286
297
  super("workflow continued as new"), this.input = input;
287
298
  this.name = "ContinueAsNew";
@@ -854,6 +865,47 @@ var RemoteWorkflowExecutor = class {
854
865
  }
855
866
  };
856
867
 
868
+ // src/run-waiting.ts
869
+ var WEBHOOK_PREFIX = "wh:";
870
+ var CHILD_PREFIX = "child:";
871
+ var BREAKPOINT_PREFIX = "bp:";
872
+ var EVENT_PREFIX = "event:";
873
+ function classifyWaiterToken(token) {
874
+ if (token.startsWith(WEBHOOK_PREFIX)) return {
875
+ on: "webhook",
876
+ name: token
877
+ };
878
+ if (token.startsWith(BREAKPOINT_PREFIX)) return {
879
+ on: "breakpoint",
880
+ name: "breakpoint"
881
+ };
882
+ if (token.startsWith(CHILD_PREFIX)) return {
883
+ on: "child",
884
+ name: token.slice(CHILD_PREFIX.length)
885
+ };
886
+ if (token.startsWith(EVENT_PREFIX)) return {
887
+ on: "signal",
888
+ name: eventNameOf(token)
889
+ };
890
+ return {
891
+ on: "signal",
892
+ name: token
893
+ };
894
+ }
895
+ __name(classifyWaiterToken, "classifyWaiterToken");
896
+ function resolveRunWaiting(run, waiterByRun) {
897
+ if (run.status !== "suspended") return void 0;
898
+ const waiter = waiterByRun.get(run.id);
899
+ return waiter ? classifyWaiterToken(waiter.token) : void 0;
900
+ }
901
+ __name(resolveRunWaiting, "resolveRunWaiting");
902
+ function indexWaitersByRun(waiters) {
903
+ const byRun = /* @__PURE__ */ new Map();
904
+ for (const w of waiters) if (!byRun.has(w.runId)) byRun.set(w.runId, w);
905
+ return byRun;
906
+ }
907
+ __name(indexWaitersByRun, "indexWaitersByRun");
908
+
857
909
  // src/singleton-gate.ts
858
910
  var SINGLETON_RETRY_MS = 1e3;
859
911
  var SINGLETON_RETRY_JITTER_MS = 250;
@@ -1309,6 +1361,15 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1309
1361
  runId,
1310
1362
  seq: current
1311
1363
  });
1364
+ const lateBuffered2 = await consumeBuffered(token, current);
1365
+ if (lateBuffered2) {
1366
+ await store.removeSignalWaiter({
1367
+ token,
1368
+ runId,
1369
+ seq: current
1370
+ });
1371
+ return lateBuffered2.value;
1372
+ }
1312
1373
  throw new WorkflowSuspended();
1313
1374
  }
1314
1375
  const timeoutMs = opts.timeoutMs;
@@ -1320,7 +1381,11 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1320
1381
  const buffered = await consumeBuffered(token, waitSeq);
1321
1382
  if (buffered) return buffered.value;
1322
1383
  if (host.clock() >= deadline) {
1323
- await store.takeSignalWaiter(token).catch(() => void 0);
1384
+ await store.removeSignalWaiter({
1385
+ token,
1386
+ runId,
1387
+ seq: waitSeq
1388
+ }).catch(() => void 0);
1324
1389
  throw new SignalTimeoutError(token, timeoutMs);
1325
1390
  }
1326
1391
  await store.putSignalWaiter({
@@ -1328,6 +1393,15 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1328
1393
  runId,
1329
1394
  seq: waitSeq
1330
1395
  });
1396
+ const lateBuffered = await consumeBuffered(token, waitSeq);
1397
+ if (lateBuffered) {
1398
+ await store.removeSignalWaiter({
1399
+ token,
1400
+ runId,
1401
+ seq: waitSeq
1402
+ });
1403
+ return lateBuffered.value;
1404
+ }
1331
1405
  throw new WorkflowSuspended(deadline);
1332
1406
  }, "waitForSignal");
1333
1407
  const waitForEvent = /* @__PURE__ */ __name(async (name, opts) => {
@@ -1351,7 +1425,11 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
1351
1425
  const waited = await readCheckpoint(waitSeq);
1352
1426
  if (waited && waited.status === "completed") return waited.output;
1353
1427
  if (host.clock() >= deadline) {
1354
- await store.takeSignalWaiter(token).catch(() => void 0);
1428
+ await store.removeSignalWaiter({
1429
+ token,
1430
+ runId,
1431
+ seq: waitSeq
1432
+ }).catch(() => void 0);
1355
1433
  throw new SignalTimeoutError(`event:${name}`, timeoutMs);
1356
1434
  }
1357
1435
  await store.putSignalWaiter({
@@ -2497,9 +2575,46 @@ var WorkflowEngine = class {
2497
2575
  /**
2498
2576
  * Resume every suspended run whose durable timer is due. Call periodically (a poller) and on
2499
2577
  * boot. A run still not due re-suspends cheaply without running new work.
2578
+ *
2579
+ * Piggy-backs the signal-waiter reconcile safety net (see {@link reconcileSignalWaiter}): a crash
2580
+ * between the waiter/buffer pairing ops on EITHER side (this run's own `putSignalWaiter` →
2581
+ * `takeBufferedSignal`, or a signaler's `takeSignalWaiter` → `bufferSignal` → re-check — see
2582
+ * `signal`'s interleaving proof) can leave both rows stranded with nothing left to pair them: the
2583
+ * waiter's turn already suspended, the signaler that buffered it already returned. Event-wait
2584
+ * suspends already carry a `reconcileMs` fallback `wakeAt` (see {@link reconcileWakeAt}) precisely so
2585
+ * they show up HERE — that's what makes this due-timer pass the natural place for the sweep. One
2586
+ * bulk waiter scan, filtered to just this tick's batch, keeps it cheap regardless of store size.
2500
2587
  */
2501
2588
  async resumeDueTimers(nowMs = this.clock()) {
2502
- return this.resumeLeased(await this.store.listDueTimers(nowMs, this.namespace), nowMs);
2589
+ const due = await this.store.listDueTimers(nowMs, this.namespace);
2590
+ if (due.length === 0) return [];
2591
+ const waiterByRun = indexWaitersByRun(await this.store.listSignalWaiters(""));
2592
+ return this.resumeLeased(due, nowMs, (run) => this.reconcileSignalWaiter(run, waiterByRun.get(run.id)));
2593
+ }
2594
+ /**
2595
+ * The reconcile safety net's per-run check (see {@link resumeDueTimers}): if this suspended run has
2596
+ * a registered signal waiter AND its token already has a buffered payload, pair and deliver them
2597
+ * directly instead of falling through to a full replay. Event/child/breakpoint waiter tokens are
2598
+ * never buffered (`publishEvent` doesn't buffer, and a child/breakpoint notifies via `signal` only
2599
+ * once its counterpart is already registered) — so `takeBufferedSignal` on those is a guaranteed
2600
+ * miss, and this is naturally signal-only without needing to classify the token.
2601
+ *
2602
+ * Returns `undefined` (not settled — `resumeLeased` proceeds with its normal `resume`) when there's
2603
+ * no waiter or no buffered hit. Deliberately never returns `null`: a delivered signal always resumes
2604
+ * via {@link deliverSignal}, and even a `NamespaceMismatch` there is reported as a real (non-nullish)
2605
+ * result — returning `null` would read as "not settled" to `resumeLeased`'s `??` fallback and
2606
+ * trigger a second, redundant `resume` call on the same run.
2607
+ */
2608
+ async reconcileSignalWaiter(run, waiter) {
2609
+ if (!waiter) return void 0;
2610
+ const buffered = await this.store.takeBufferedSignal(waiter.token);
2611
+ if (!buffered) return void 0;
2612
+ await this.store.removeSignalWaiter(waiter);
2613
+ const settled = await this.deliverSignal(waiter, buffered.payload);
2614
+ return settled ?? {
2615
+ runId: run.id,
2616
+ status: run.status
2617
+ };
2503
2618
  }
2504
2619
  /**
2505
2620
  * Lease and execute one run by id — the worker side of dispatch. Acquires the recovery lease (so
@@ -2717,16 +2832,17 @@ var WorkflowEngine = class {
2717
2832
  }
2718
2833
  return touched;
2719
2834
  }
2720
- async signal(token, payload) {
2721
- const waiter = await this.store.takeSignalWaiter(token);
2722
- if (!waiter) {
2723
- await this.store.bufferSignal(token, payload);
2724
- return null;
2725
- }
2835
+ /**
2836
+ * Deliver a signal to an already-known waiter: write its resolving `signal:<token>` checkpoint and
2837
+ * resume the run. Shared by {@link signal}'s direct hit, its post-buffer re-check reclaim, and the
2838
+ * due-timer reconcile sweep ({@link reconcileSignalWaiter}) — all three resolve a waiter the exact
2839
+ * same way.
2840
+ */
2841
+ async deliverSignal(waiter, payload) {
2726
2842
  await this.store.saveCheckpoint(instantCheckpoint({
2727
2843
  runId: waiter.runId,
2728
2844
  seq: waiter.seq,
2729
- name: `signal:${token}`,
2845
+ name: `signal:${waiter.token}`,
2730
2846
  kind: "signal",
2731
2847
  output: payload,
2732
2848
  // Carry the awaiting command's fan group (set when a `ctx.gather_children`/`ctx.all` fan-out
@@ -2741,6 +2857,42 @@ var WorkflowEngine = class {
2741
2857
  });
2742
2858
  }
2743
2859
  /**
2860
+ * Deliver an external signal to the run waiting on `token`, resume it with `payload`, and return
2861
+ * the run result — or null if no run is (or ends up) waiting for it.
2862
+ *
2863
+ * Race with the waiter side (`waitForSignal`'s check → `putSignalWaiter` → re-check, same shape in
2864
+ * the remote `waitSignal` command): a waiter can register in the sliver between this method's
2865
+ * initial `takeSignalWaiter` miss and its `bufferSignal` write, and the naive old flow (buffer and
2866
+ * return) would then leave BOTH a buffered payload and a registered waiter sitting in the store with
2867
+ * nothing left to pair them — a lost wake, forever suspended.
2868
+ *
2869
+ * The interleaving proof: the waiter side does check → put → re-check, this side does take → buffer
2870
+ * → re-check. Whichever side's SECOND look runs last is guaranteed to observe the other side's
2871
+ * write, because it always follows that write in real time:
2872
+ * - If the waiter's `put` lands before this method's first `take`, the take just finds it directly
2873
+ * (the pre-existing, non-racy path).
2874
+ * - If the waiter's `put` lands after this method's `bufferSignal`, our re-check below either sees
2875
+ * it (if `put` also lands before the re-check) or the waiter's OWN post-put re-check sees our
2876
+ * buffer (if `put` lands after our re-check but the waiter hasn't re-checked yet either — its
2877
+ * re-check strictly follows its own put).
2878
+ * - If the waiter's `put` lands between this method's `take` and its re-`take`, the re-check below
2879
+ * catches it.
2880
+ * Either way, the destructive `take*` ops (on the waiter row, on the buffer) arbitrate: whichever
2881
+ * side actually removes the row is the one that delivers, so a signal is never delivered twice.
2882
+ */
2883
+ async signal(token, payload) {
2884
+ const waiter = await this.store.takeSignalWaiter(token);
2885
+ if (waiter) return this.deliverSignal(waiter, payload);
2886
+ await this.store.bufferSignal(token, payload);
2887
+ const lateWaiter = await this.store.takeSignalWaiter(token);
2888
+ if (!lateWaiter) return null;
2889
+ const reclaimed = await this.store.takeBufferedSignal(token);
2890
+ if (!reclaimed) {
2891
+ return null;
2892
+ }
2893
+ return this.deliverSignal(lateWaiter, reclaimed.payload);
2894
+ }
2895
+ /**
2744
2896
  * Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
2745
2897
  * signal buffering: if the run is new (or busy / not yet waiting), the signal is buffered and
2746
2898
  * consumed when it reaches `waitForSignal(token)`. The canonical **durable-entity / accumulator**
@@ -3213,6 +3365,14 @@ var WorkflowEngine = class {
3213
3365
  error: outcome.error
3214
3366
  };
3215
3367
  }
3368
+ const latest = await this.store.getRun(run.id);
3369
+ if (latest?.status === "cancelled") {
3370
+ return {
3371
+ runId: run.id,
3372
+ status: "cancelled",
3373
+ error: latest.error
3374
+ };
3375
+ }
3216
3376
  await this.store.updateRun(run.id, {
3217
3377
  status: "suspended",
3218
3378
  wakeAt: this.reconcileWakeAt(outcome.wakeAt),
@@ -3555,22 +3715,34 @@ var WorkflowEngine = class {
3555
3715
  }));
3556
3716
  wakeAt = wakeAt == null ? deadline : Math.min(wakeAt, deadline);
3557
3717
  } else if (cmd.kind === "waitSignal") {
3558
- const buffered = await this.store.takeBufferedSignal(cmd.signal);
3559
- if (buffered) {
3718
+ const deliverBuffered = /* @__PURE__ */ __name(async (payload) => {
3560
3719
  await this.store.saveCheckpoint(instantCheckpoint({
3561
3720
  runId: run.id,
3562
3721
  seq: cmd.seq,
3563
3722
  name: `signal:${cmd.signal}`,
3564
3723
  kind: "signal",
3565
- output: buffered.payload
3724
+ output: payload
3566
3725
  }));
3567
3726
  setTimeout(() => void this.resume(run.id).catch(() => void 0), 0);
3727
+ }, "deliverBuffered");
3728
+ const buffered = await this.store.takeBufferedSignal(cmd.signal);
3729
+ if (buffered) {
3730
+ await deliverBuffered(buffered.payload);
3568
3731
  } else {
3569
3732
  await this.store.putSignalWaiter({
3570
3733
  token: cmd.signal,
3571
3734
  runId: run.id,
3572
3735
  seq: cmd.seq
3573
3736
  });
3737
+ const lateBuffered = await this.store.takeBufferedSignal(cmd.signal);
3738
+ if (lateBuffered) {
3739
+ await this.store.removeSignalWaiter({
3740
+ token: cmd.signal,
3741
+ runId: run.id,
3742
+ seq: cmd.seq
3743
+ });
3744
+ await deliverBuffered(lateBuffered.payload);
3745
+ }
3574
3746
  }
3575
3747
  } else if (cmd.kind === "startChild") {
3576
3748
  const childId = `${run.id}.child.${cmd.seq}`;
@@ -4430,47 +4602,6 @@ var TERMINAL_RUN_STATUSES = [
4430
4602
  "dead"
4431
4603
  ];
4432
4604
 
4433
- // src/run-waiting.ts
4434
- var WEBHOOK_PREFIX = "wh:";
4435
- var CHILD_PREFIX = "child:";
4436
- var BREAKPOINT_PREFIX = "bp:";
4437
- var EVENT_PREFIX = "event:";
4438
- function classifyWaiterToken(token) {
4439
- if (token.startsWith(WEBHOOK_PREFIX)) return {
4440
- on: "webhook",
4441
- name: token
4442
- };
4443
- if (token.startsWith(BREAKPOINT_PREFIX)) return {
4444
- on: "breakpoint",
4445
- name: "breakpoint"
4446
- };
4447
- if (token.startsWith(CHILD_PREFIX)) return {
4448
- on: "child",
4449
- name: token.slice(CHILD_PREFIX.length)
4450
- };
4451
- if (token.startsWith(EVENT_PREFIX)) return {
4452
- on: "signal",
4453
- name: eventNameOf(token)
4454
- };
4455
- return {
4456
- on: "signal",
4457
- name: token
4458
- };
4459
- }
4460
- __name(classifyWaiterToken, "classifyWaiterToken");
4461
- function resolveRunWaiting(run, waiterByRun) {
4462
- if (run.status !== "suspended") return void 0;
4463
- const waiter = waiterByRun.get(run.id);
4464
- return waiter ? classifyWaiterToken(waiter.token) : void 0;
4465
- }
4466
- __name(resolveRunWaiting, "resolveRunWaiting");
4467
- function indexWaitersByRun(waiters) {
4468
- const byRun = /* @__PURE__ */ new Map();
4469
- for (const w of waiters) if (!byRun.has(w.runId)) byRun.set(w.runId, w);
4470
- return byRun;
4471
- }
4472
- __name(indexWaitersByRun, "indexWaitersByRun");
4473
-
4474
4605
  // src/codec-state-store.ts
4475
4606
  var CodecStateStore = class {
4476
4607
  static {
@@ -4578,6 +4709,9 @@ var CodecStateStore = class {
4578
4709
  listSignalWaiters(prefix) {
4579
4710
  return this.inner.listSignalWaiters(prefix);
4580
4711
  }
4712
+ removeSignalWaiter(waiter) {
4713
+ return this.inner.removeSignalWaiter(waiter);
4714
+ }
4581
4715
  bufferSignal(token, payload) {
4582
4716
  return this.inner.bufferSignal(token, this.enc(payload));
4583
4717
  }
@@ -5030,6 +5164,12 @@ var InMemoryStateStore = class {
5030
5164
  ...w
5031
5165
  }));
5032
5166
  }
5167
+ async removeSignalWaiter(waiter) {
5168
+ const current = this.signalWaiters.get(waiter.token);
5169
+ if (current && current.runId === waiter.runId && current.seq === waiter.seq) {
5170
+ this.signalWaiters.delete(waiter.token);
5171
+ }
5172
+ }
5033
5173
  // No real DB transaction (there's no DB) — run the work and save the checkpoint. Exactly-once
5034
5174
  // across a crash needs a SQL store; this keeps `ctx.transaction` usable in tests / local dev.
5035
5175
  async transaction(work) {
@@ -5203,6 +5343,7 @@ var InMemoryTransport = class {
5203
5343
  }
5204
5344
  };
5205
5345
  export {
5346
+ CONTROL_FLOW_SIGNAL,
5206
5347
  CodecStateStore,
5207
5348
  ContinueAsNew,
5208
5349
  DURABLE_OPTIONS,
@@ -5248,6 +5389,7 @@ export {
5248
5389
  eventPrefix,
5249
5390
  eventToken,
5250
5391
  indexWaitersByRun,
5392
+ isWorkflowControlFlowSignal,
5251
5393
  matchesAttributes,
5252
5394
  normalizeAttributeRows,
5253
5395
  parseDuration,