@dudousxd/nestjs-durable-core 0.33.0 → 0.35.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
@@ -804,7 +804,8 @@ var SingletonGate = class {
804
804
  statuses: [
805
805
  "pending",
806
806
  "running",
807
- "suspended"
807
+ "suspended",
808
+ "cancelling"
808
809
  ]
809
810
  });
810
811
  if (queued.length >= cap) {
@@ -822,7 +823,8 @@ var SingletonGate = class {
822
823
  workflow: run.workflow,
823
824
  statuses: [
824
825
  "running",
825
- "suspended"
826
+ "suspended",
827
+ "cancelling"
826
828
  ]
827
829
  })).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id));
828
830
  const idx = inflight.findIndex((r) => r.id === run.id);
@@ -1502,6 +1504,7 @@ var WorkflowEngine = class {
1502
1504
  instanceId;
1503
1505
  leaseMs;
1504
1506
  maxRecoveryAttempts;
1507
+ remoteAdvanceSilenceMs;
1505
1508
  webhookUrl;
1506
1509
  traceparent;
1507
1510
  context;
@@ -1526,7 +1529,9 @@ var WorkflowEngine = class {
1526
1529
  accumulators;
1527
1530
  /** In-flight remote steps awaiting a worker result, keyed by stepId. */
1528
1531
  pending = /* @__PURE__ */ new Map();
1529
- /** Per-step "reset the liveness timer" callbacks, called when a heartbeat arrives. */
1532
+ /** "Reset the liveness timer" callbacks keyed by the beat's target — a stepId (in-flight remote step)
1533
+ * or a runId (in-flight remote workflow turn) — invoked when a matching heartbeat arrives. The two id
1534
+ * shapes don't collide in practice: a stepId is `${runId}:${seq}`, a run key is the bare runId. */
1530
1535
  heartbeatResets = /* @__PURE__ */ new Map();
1531
1536
  listeners = /* @__PURE__ */ new Set();
1532
1537
  /** Step interceptors (onion middleware around real local-step execution), first = outermost. */
@@ -1565,6 +1570,7 @@ var WorkflowEngine = class {
1565
1570
  this.instanceId = deps.instanceId ?? globalThis.crypto.randomUUID();
1566
1571
  this.leaseMs = deps.leaseMs ?? 3e4;
1567
1572
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
1573
+ this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs;
1568
1574
  this.webhookUrl = deps.webhookUrl;
1569
1575
  this.traceparent = deps.traceparent;
1570
1576
  this.context = deps.context;
@@ -1601,9 +1607,11 @@ var WorkflowEngine = class {
1601
1607
  }
1602
1608
  await this.completeRemoteResult(result);
1603
1609
  },
1604
- // A heartbeat for an in-flight long step resets its liveness window (see callRemote).
1610
+ // A heartbeat resets the liveness window for whatever it targets: an in-flight long STEP (keyed
1611
+ // by stepId, see callRemote) or — when stepId is absent — the in-flight workflow TURN (keyed by
1612
+ // runId, see runRemoteExecution's heartbeat-rearmed advance).
1605
1613
  async (beat) => {
1606
- this.heartbeatResets.get(beat.stepId)?.();
1614
+ this.heartbeatResets.get(beat.stepId ?? beat.runId)?.();
1607
1615
  },
1608
1616
  // A remote workflow worker streams each local step's lifecycle (running → completed/failed) so
1609
1617
  // it's checkpointed live, not all-at-once when the long turn ends.
@@ -1965,14 +1973,16 @@ var WorkflowEngine = class {
1965
1973
  continue;
1966
1974
  }
1967
1975
  await this.store.releaseRunLock(run.id);
1968
- await this.store.updateRun(run.id, {
1969
- status: "pending",
1970
- updatedAt: /* @__PURE__ */ new Date()
1971
- });
1976
+ if (run.status !== "cancelling") {
1977
+ await this.store.updateRun(run.id, {
1978
+ status: "pending",
1979
+ updatedAt: /* @__PURE__ */ new Date()
1980
+ });
1981
+ }
1972
1982
  await this.runDispatcher.dispatch(run.id);
1973
1983
  results.push({
1974
1984
  runId: run.id,
1975
- status: "pending"
1985
+ status: run.status === "cancelling" ? "cancelling" : "pending"
1976
1986
  });
1977
1987
  }
1978
1988
  return results;
@@ -2091,7 +2101,7 @@ var WorkflowEngine = class {
2091
2101
  * `start` when a call site needs the outcome — `await start(...); const r = await waitForRun(id)`.
2092
2102
  */
2093
2103
  waitForRun(runId, opts) {
2094
- const isSettled = /* @__PURE__ */ __name((s) => s !== "pending" && s !== "running", "isSettled");
2104
+ const isSettled = /* @__PURE__ */ __name((s) => s !== "pending" && s !== "running" && s !== "cancelling", "isSettled");
2095
2105
  const toResult = /* @__PURE__ */ __name((run) => ({
2096
2106
  runId,
2097
2107
  status: run.status,
@@ -2255,8 +2265,19 @@ var WorkflowEngine = class {
2255
2265
  error: run.error
2256
2266
  };
2257
2267
  }
2268
+ if (run.status === "cancelling") {
2269
+ return {
2270
+ runId,
2271
+ status: "cancelling"
2272
+ };
2273
+ }
2258
2274
  if (opts?.compensate && (run.status === "suspended" || run.status === "running")) {
2259
2275
  this.cancelRequested.add(runId);
2276
+ await this.store.updateRun(runId, {
2277
+ status: "cancelling",
2278
+ updatedAt: /* @__PURE__ */ new Date()
2279
+ });
2280
+ run.status = "cancelling";
2260
2281
  if (this.controlPlane) {
2261
2282
  void this.controlPlane.publishControl({
2262
2283
  kind: "cancel",
@@ -2270,7 +2291,7 @@ var WorkflowEngine = class {
2270
2291
  await this.cancelChildren(runId, opts);
2271
2292
  return {
2272
2293
  runId,
2273
- status: run.status
2294
+ status: "cancelling"
2274
2295
  };
2275
2296
  }
2276
2297
  const error = {
@@ -2661,6 +2682,36 @@ var WorkflowEngine = class {
2661
2682
  };
2662
2683
  }
2663
2684
  async runRemoteExecution(run, registered) {
2685
+ if (run.status === "cancelling") {
2686
+ const error = {
2687
+ message: "cancelled"
2688
+ };
2689
+ await this.store.updateRun(run.id, {
2690
+ status: "cancelled",
2691
+ error,
2692
+ updatedAt: /* @__PURE__ */ new Date()
2693
+ });
2694
+ this.emit({
2695
+ type: "run.failed",
2696
+ runId: run.id,
2697
+ workflow: run.workflow,
2698
+ error
2699
+ });
2700
+ this.notifyCancelled(run.id);
2701
+ if (this.controlPlane) {
2702
+ void this.controlPlane.publishControl({
2703
+ kind: "cancel",
2704
+ runId: run.id,
2705
+ from: this.instanceId
2706
+ }).catch(() => void 0);
2707
+ }
2708
+ await this.store.releaseRunLock(run.id);
2709
+ return {
2710
+ runId: run.id,
2711
+ status: "cancelled",
2712
+ error
2713
+ };
2714
+ }
2664
2715
  const remote = registered.remote;
2665
2716
  if (run.status === "pending") {
2666
2717
  await this.store.updateRun(run.id, {
@@ -2695,7 +2746,9 @@ var WorkflowEngine = class {
2695
2746
  const history = await this.remoteHistory(run.id);
2696
2747
  let decision;
2697
2748
  try {
2698
- decision = await remote.executor.advance(run, history);
2749
+ const silenceMs = this.remoteAdvanceSilenceMs;
2750
+ const advance = remote.executor.advance(run, history);
2751
+ decision = silenceMs != null ? await this.awaitWithLivenessDeadline(run.id, advance, silenceMs, () => new RemoteWorkflowTimeout(run.id, silenceMs)) : await advance;
2699
2752
  } catch (err) {
2700
2753
  if (err instanceof RemoteWorkflowTimeout) {
2701
2754
  await this.store.releaseRunLock(run.id);
@@ -2752,6 +2805,15 @@ var WorkflowEngine = class {
2752
2805
  status: "cancelled"
2753
2806
  };
2754
2807
  }
2808
+ const fresh = await this.store.getRun(run.id);
2809
+ if (fresh && (fresh.status === "cancelled" || fresh.status === "completed" || fresh.status === "dead")) {
2810
+ return {
2811
+ runId: run.id,
2812
+ status: fresh.status,
2813
+ output: fresh.output,
2814
+ error: fresh.error
2815
+ };
2816
+ }
2755
2817
  const wakeAt = await this.applyCommands(run, decision.commands);
2756
2818
  return this.settleRun(run, {
2757
2819
  kind: "suspended",
@@ -2919,8 +2981,41 @@ var WorkflowEngine = class {
2919
2981
  }
2920
2982
  return wakeAt;
2921
2983
  }
2984
+ /**
2985
+ * Undo the registered saga compensations in reverse and settle the run `cancelled`. Shared by EVERY
2986
+ * exit path of a run with a compensating cancel in flight — whether the resumed replay returned,
2987
+ * suspended, hit continue-as-new, or threw — so a cancel is never lost just because the body didn't
2988
+ * stop at a suspension point. Clears the in-memory cancel flag.
2989
+ */
2990
+ async compensateAndCancel(run, compensations) {
2991
+ this.cancelRequested.delete(run.id);
2992
+ for (let i = compensations.length - 1; i >= 0; i -= 1) {
2993
+ const comp = compensations[i];
2994
+ if (comp) await this.runCompensation(run, comp);
2995
+ }
2996
+ const error = {
2997
+ message: "cancelled"
2998
+ };
2999
+ await this.store.updateRun(run.id, {
3000
+ status: "cancelled",
3001
+ error,
3002
+ updatedAt: /* @__PURE__ */ new Date()
3003
+ });
3004
+ this.emit({
3005
+ type: "run.failed",
3006
+ runId: run.id,
3007
+ workflow: run.workflow,
3008
+ error
3009
+ });
3010
+ return {
3011
+ runId: run.id,
3012
+ status: "cancelled",
3013
+ error
3014
+ };
3015
+ }
2922
3016
  /** The run body, lease held + renewed by {@link execute}. */
2923
3017
  async runExecution(run, fn, registered) {
3018
+ if (run.status === "cancelling") this.cancelRequested.add(run.id);
2924
3019
  if (run.status === "pending") {
2925
3020
  await this.store.updateRun(run.id, {
2926
3021
  status: "running",
@@ -2933,7 +3028,7 @@ var WorkflowEngine = class {
2933
3028
  workflow: run.workflow
2934
3029
  });
2935
3030
  }
2936
- if (registered?.singleton && !await this.singletons.admit(run, registered.singleton)) {
3031
+ if (run.status !== "cancelling" && registered?.singleton && !await this.singletons.admit(run, registered.singleton)) {
2937
3032
  const wakeAt = this.singletons.retryWakeAt();
2938
3033
  await this.store.updateRun(run.id, {
2939
3034
  status: "suspended",
@@ -2958,12 +3053,14 @@ var WorkflowEngine = class {
2958
3053
  const ctx = createWorkflowCtx(this.ctxHostFor(replay), run.id, compensations, run.workflow);
2959
3054
  try {
2960
3055
  const output = await fn(ctx, run.input);
3056
+ if (this.cancelRequested.has(run.id)) return this.compensateAndCancel(run, compensations);
2961
3057
  return this.settleRun(run, {
2962
3058
  kind: "completed",
2963
3059
  output
2964
3060
  });
2965
3061
  } catch (err) {
2966
3062
  if (err instanceof ContinueAsNew) {
3063
+ if (this.cancelRequested.has(run.id)) return this.compensateAndCancel(run, compensations);
2967
3064
  await this.store.updateRun(run.id, {
2968
3065
  status: "completed",
2969
3066
  output: void 0,
@@ -2987,37 +3084,13 @@ var WorkflowEngine = class {
2987
3084
  };
2988
3085
  }
2989
3086
  if (err instanceof WorkflowSuspended) {
2990
- if (this.cancelRequested.has(run.id)) {
2991
- this.cancelRequested.delete(run.id);
2992
- for (let i = compensations.length - 1; i >= 0; i -= 1) {
2993
- const comp = compensations[i];
2994
- if (comp) await this.runCompensation(run, comp);
2995
- }
2996
- const error2 = {
2997
- message: "cancelled"
2998
- };
2999
- await this.store.updateRun(run.id, {
3000
- status: "cancelled",
3001
- error: error2,
3002
- updatedAt: /* @__PURE__ */ new Date()
3003
- });
3004
- this.emit({
3005
- type: "run.failed",
3006
- runId: run.id,
3007
- workflow: run.workflow,
3008
- error: error2
3009
- });
3010
- return {
3011
- runId: run.id,
3012
- status: "cancelled",
3013
- error: error2
3014
- };
3015
- }
3087
+ if (this.cancelRequested.has(run.id)) return this.compensateAndCancel(run, compensations);
3016
3088
  return this.settleRun(run, {
3017
3089
  kind: "suspended",
3018
3090
  wakeAt: err.wakeAt
3019
3091
  });
3020
3092
  }
3093
+ if (this.cancelRequested.has(run.id)) return this.compensateAndCancel(run, compensations);
3021
3094
  const error = {
3022
3095
  message: err instanceof Error ? err.message : String(err),
3023
3096
  stack: err instanceof Error ? err.stack : void 0
@@ -3356,11 +3429,13 @@ var WorkflowEngine = class {
3356
3429
  }
3357
3430
  }
3358
3431
  /**
3359
- * Await a remote result, but reject with `RemoteStepTimeout` if neither the result nor a heartbeat
3360
- * arrives within `timeoutMs`. Each heartbeat (delivered via `transport.onHeartbeat`) rearms the
3361
- * window, so a worker that keeps beating stays alive past `timeoutMs`.
3432
+ * Await `resultPromise`, but reject with `onTimeout()` if neither it settles nor a heartbeat for `id`
3433
+ * arrives within `timeoutMs`. Each heartbeat (delivered via `transport.onHeartbeat`, looked up in
3434
+ * `heartbeatResets` by `id`) REARMS the window, so a worker that keeps beating stays alive past
3435
+ * `timeoutMs`. Shared by the remote-STEP liveness (keyed by stepId) and the remote-WORKFLOW advance
3436
+ * liveness (keyed by runId); the caller supplies the timeout error and any extra cleanup via `onTimeout`.
3362
3437
  */
3363
- awaitWithHeartbeat(id, resultPromise, timeoutMs) {
3438
+ awaitWithLivenessDeadline(id, resultPromise, timeoutMs, onTimeout) {
3364
3439
  return new Promise((resolve, reject) => {
3365
3440
  let timer;
3366
3441
  const cleanup = /* @__PURE__ */ __name(() => {
@@ -3370,8 +3445,7 @@ var WorkflowEngine = class {
3370
3445
  const arm = /* @__PURE__ */ __name(() => {
3371
3446
  timer = setTimeout(() => {
3372
3447
  cleanup();
3373
- this.pending.delete(id);
3374
- reject(new RemoteStepTimeout(id, timeoutMs));
3448
+ reject(onTimeout());
3375
3449
  }, timeoutMs);
3376
3450
  timer.unref?.();
3377
3451
  }, "arm");
@@ -3389,6 +3463,14 @@ var WorkflowEngine = class {
3389
3463
  });
3390
3464
  });
3391
3465
  }
3466
+ /** Remote-STEP liveness: await a worker result, timing out (with the step's pending-map cleanup) to
3467
+ * `RemoteStepTimeout` if it neither lands nor beats within `timeoutMs`. See {@link awaitWithLivenessDeadline}. */
3468
+ awaitWithHeartbeat(id, resultPromise, timeoutMs) {
3469
+ return this.awaitWithLivenessDeadline(id, resultPromise, timeoutMs, () => {
3470
+ this.pending.delete(id);
3471
+ return new RemoteStepTimeout(id, timeoutMs);
3472
+ });
3473
+ }
3392
3474
  };
3393
3475
  var RemoteStepError = class extends Error {
3394
3476
  static {
@@ -3962,7 +4044,7 @@ var InMemoryStateStore = class {
3962
4044
  async listIncompleteRuns() {
3963
4045
  return [
3964
4046
  ...this.runs.values()
3965
- ].filter((r) => r.status === "running").map((r) => ({
4047
+ ].filter((r) => r.status === "running" || r.status === "cancelling").map((r) => ({
3966
4048
  ...r
3967
4049
  }));
3968
4050
  }
@@ -4154,6 +4236,7 @@ var InMemoryTransport = class {
4154
4236
  }
4155
4237
  handlers = /* @__PURE__ */ new Map();
4156
4238
  resultHandler;
4239
+ heartbeatHandler;
4157
4240
  stepEventHandler;
4158
4241
  controlHandlers = /* @__PURE__ */ new Set();
4159
4242
  /** Register a fake worker handler for a step name. */
@@ -4168,7 +4251,12 @@ var InMemoryTransport = class {
4168
4251
  onResult(handler) {
4169
4252
  this.resultHandler = handler;
4170
4253
  }
4171
- onHeartbeat(_handler) {
4254
+ onHeartbeat(handler) {
4255
+ this.heartbeatHandler = handler;
4256
+ }
4257
+ /** Test hook: deliver a liveness heartbeat to the engine, as a real broker would on its channel. */
4258
+ async emitHeartbeat(beat) {
4259
+ await this.heartbeatHandler?.(beat);
4172
4260
  }
4173
4261
  /** Workflow step lifecycle: deliver to the engine's handler (async, mirroring a real broker). */
4174
4262
  async dispatchStepEvent(event) {