@dudousxd/nestjs-durable-core 0.75.1 → 0.76.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.d.cts CHANGED
@@ -969,6 +969,16 @@ interface StateStore {
969
969
  * being a single transaction; stores without transactions cannot give the strong guarantee.
970
970
  */
971
971
  saveCheckpoint(checkpoint: StepCheckpoint): Promise<void>;
972
+ /**
973
+ * Overwrite the checkpoint at `(runId, seq)` with `checkpoint`, but only while the stored row is
974
+ * still `pending`; resolves whether it wrote. The engine writes a dispatched step's row from two
975
+ * places at once (a turn stamping its redispatch deadline, and the worker's result settling it),
976
+ * so a blind {@link saveCheckpoint} from a row read a moment earlier can put a `completed` step
977
+ * back to `pending`. This is the compare-and-set that prevents it.
978
+ *
979
+ * Optional: a store that omits it falls back to {@link saveCheckpoint}, which keeps that race.
980
+ */
981
+ saveCheckpointIfPending?(checkpoint: StepCheckpoint): Promise<boolean>;
972
982
  /** Used by recovery on boot to find runs to resume (crashed, left `running`). */
973
983
  listIncompleteRuns(namespace?: string): Promise<WorkflowRun[]>;
974
984
  /** The oldest `pending` runs awaiting dispatch (FIFO, by `createdAt`), capped at `limit`. */
@@ -3790,6 +3800,12 @@ declare class WorkflowEngine {
3790
3800
  private readonly updateValidators;
3791
3801
  /** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
3792
3802
  private readonly cancelRequested;
3803
+ /**
3804
+ * Seqs of the dispatched remote steps a turn suspended on, by run, while that turn executes on
3805
+ * THIS instance — so the turn can re-check them once its lease is released (see
3806
+ * {@link redriveSettledSteps}).
3807
+ */
3808
+ private readonly awaitedRemoteSteps;
3793
3809
  /** Flow-control admission backend for remote steps (see {@link registerQueue}). */
3794
3810
  private readonly admission;
3795
3811
  /** Runs on THIS instance blocked on admission, by queue — woken early on a freed-slot signal. */
@@ -4300,6 +4316,16 @@ declare class WorkflowEngine {
4300
4316
  * itself. Only re-drives for a group with no waiter left whose checkpoints are all settled, which
4301
4317
  * the next turn is then guaranteed to see — so it cannot loop.
4302
4318
  */
4319
+ /**
4320
+ * Re-drive a run that just suspended on dispatched remote steps which, by now, have a result.
4321
+ *
4322
+ * Same race as {@link redriveSettledChildren}, for `ctx.step`: a result landing while the turn
4323
+ * that dispatched it still holds the lease has its wake dropped by {@link execute}, and the turn
4324
+ * then suspends with nothing left to wake it before the step's redispatch deadline. Called after
4325
+ * the lease is released, so any later result wakes the run itself; it only re-drives for a
4326
+ * checkpoint that is already settled, which the next turn replays past — so it cannot loop.
4327
+ */
4328
+ private redriveSettledSteps;
4303
4329
  private redriveSettledChildren;
4304
4330
  /**
4305
4331
  * Deliver an external signal to the run waiting on `token`, resume it with `payload`, and return
@@ -4817,6 +4843,8 @@ declare class WorkflowEngine {
4817
4843
  * capable+compatible worker has appeared, else re-parks with a fresh `wakeAt` (design §7.5). */
4818
4844
  private dueBlockedRuns;
4819
4845
  private callRemote;
4846
+ /** {@link StateStore.saveCheckpointIfPending}, or a plain save on a store without it. */
4847
+ private savePendingCheckpoint;
4820
4848
  /** Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
4821
4849
  * dispatch, the opt-in self-heal re-dispatch (see `remoteRedispatchMs`), and the explicit
4822
4850
  * {@link redispatchPending}. Carries the current trace/context so a re-dispatch is traceable. */
@@ -5492,6 +5520,9 @@ declare class CodecStateStore implements StateStore {
5492
5520
  /** Forwarded verbatim (deletes touch no payload). Bound only when the inner store has it, so the
5493
5521
  * engine's per-run fallback still kicks in through the wrapper. */
5494
5522
  readonly deleteRuns?: (runIds: string[]) => Promise<void>;
5523
+ /** Encodes like {@link saveCheckpoint}. Bound only when the inner store has it, so the engine's
5524
+ * plain-save fallback still kicks in through the wrapper. */
5525
+ readonly saveCheckpointIfPending?: (checkpoint: StepCheckpoint) => Promise<boolean>;
5495
5526
  /** Forwarded verbatim — retention selects by status/updatedAt/scope, never by payload. Bound only
5496
5527
  * when the inner store prunes, so the retention poller still sees (and warns about) its absence. */
5497
5528
  readonly pruneTerminalRuns?: (policy: RetentionPolicy, nowMs: number, limit: number) => Promise<number>;
@@ -5961,6 +5992,7 @@ declare class InMemoryStateStore implements StateStore {
5961
5992
  pruneTerminalRuns(policy: RetentionPolicy, nowMs: number, limit: number): Promise<number>;
5962
5993
  getCheckpoint(runId: string, seq: number): Promise<StepCheckpoint | null>;
5963
5994
  saveCheckpoint(checkpoint: StepCheckpoint): Promise<void>;
5995
+ saveCheckpointIfPending(checkpoint: StepCheckpoint): Promise<boolean>;
5964
5996
  listIncompleteRuns(namespace?: string): Promise<WorkflowRun[]>;
5965
5997
  listPendingRuns(limit: number, namespace?: string): Promise<WorkflowRun[]>;
5966
5998
  listDueTimers(nowMs: number, namespace?: string): Promise<WorkflowRun[]>;
package/dist/index.d.ts CHANGED
@@ -969,6 +969,16 @@ interface StateStore {
969
969
  * being a single transaction; stores without transactions cannot give the strong guarantee.
970
970
  */
971
971
  saveCheckpoint(checkpoint: StepCheckpoint): Promise<void>;
972
+ /**
973
+ * Overwrite the checkpoint at `(runId, seq)` with `checkpoint`, but only while the stored row is
974
+ * still `pending`; resolves whether it wrote. The engine writes a dispatched step's row from two
975
+ * places at once (a turn stamping its redispatch deadline, and the worker's result settling it),
976
+ * so a blind {@link saveCheckpoint} from a row read a moment earlier can put a `completed` step
977
+ * back to `pending`. This is the compare-and-set that prevents it.
978
+ *
979
+ * Optional: a store that omits it falls back to {@link saveCheckpoint}, which keeps that race.
980
+ */
981
+ saveCheckpointIfPending?(checkpoint: StepCheckpoint): Promise<boolean>;
972
982
  /** Used by recovery on boot to find runs to resume (crashed, left `running`). */
973
983
  listIncompleteRuns(namespace?: string): Promise<WorkflowRun[]>;
974
984
  /** The oldest `pending` runs awaiting dispatch (FIFO, by `createdAt`), capped at `limit`. */
@@ -3790,6 +3800,12 @@ declare class WorkflowEngine {
3790
3800
  private readonly updateValidators;
3791
3801
  /** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
3792
3802
  private readonly cancelRequested;
3803
+ /**
3804
+ * Seqs of the dispatched remote steps a turn suspended on, by run, while that turn executes on
3805
+ * THIS instance — so the turn can re-check them once its lease is released (see
3806
+ * {@link redriveSettledSteps}).
3807
+ */
3808
+ private readonly awaitedRemoteSteps;
3793
3809
  /** Flow-control admission backend for remote steps (see {@link registerQueue}). */
3794
3810
  private readonly admission;
3795
3811
  /** Runs on THIS instance blocked on admission, by queue — woken early on a freed-slot signal. */
@@ -4300,6 +4316,16 @@ declare class WorkflowEngine {
4300
4316
  * itself. Only re-drives for a group with no waiter left whose checkpoints are all settled, which
4301
4317
  * the next turn is then guaranteed to see — so it cannot loop.
4302
4318
  */
4319
+ /**
4320
+ * Re-drive a run that just suspended on dispatched remote steps which, by now, have a result.
4321
+ *
4322
+ * Same race as {@link redriveSettledChildren}, for `ctx.step`: a result landing while the turn
4323
+ * that dispatched it still holds the lease has its wake dropped by {@link execute}, and the turn
4324
+ * then suspends with nothing left to wake it before the step's redispatch deadline. Called after
4325
+ * the lease is released, so any later result wakes the run itself; it only re-drives for a
4326
+ * checkpoint that is already settled, which the next turn replays past — so it cannot loop.
4327
+ */
4328
+ private redriveSettledSteps;
4303
4329
  private redriveSettledChildren;
4304
4330
  /**
4305
4331
  * Deliver an external signal to the run waiting on `token`, resume it with `payload`, and return
@@ -4817,6 +4843,8 @@ declare class WorkflowEngine {
4817
4843
  * capable+compatible worker has appeared, else re-parks with a fresh `wakeAt` (design §7.5). */
4818
4844
  private dueBlockedRuns;
4819
4845
  private callRemote;
4846
+ /** {@link StateStore.saveCheckpointIfPending}, or a plain save on a store without it. */
4847
+ private savePendingCheckpoint;
4820
4848
  /** Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
4821
4849
  * dispatch, the opt-in self-heal re-dispatch (see `remoteRedispatchMs`), and the explicit
4822
4850
  * {@link redispatchPending}. Carries the current trace/context so a re-dispatch is traceable. */
@@ -5492,6 +5520,9 @@ declare class CodecStateStore implements StateStore {
5492
5520
  /** Forwarded verbatim (deletes touch no payload). Bound only when the inner store has it, so the
5493
5521
  * engine's per-run fallback still kicks in through the wrapper. */
5494
5522
  readonly deleteRuns?: (runIds: string[]) => Promise<void>;
5523
+ /** Encodes like {@link saveCheckpoint}. Bound only when the inner store has it, so the engine's
5524
+ * plain-save fallback still kicks in through the wrapper. */
5525
+ readonly saveCheckpointIfPending?: (checkpoint: StepCheckpoint) => Promise<boolean>;
5495
5526
  /** Forwarded verbatim — retention selects by status/updatedAt/scope, never by payload. Bound only
5496
5527
  * when the inner store prunes, so the retention poller still sees (and warns about) its absence. */
5497
5528
  readonly pruneTerminalRuns?: (policy: RetentionPolicy, nowMs: number, limit: number) => Promise<number>;
@@ -5961,6 +5992,7 @@ declare class InMemoryStateStore implements StateStore {
5961
5992
  pruneTerminalRuns(policy: RetentionPolicy, nowMs: number, limit: number): Promise<number>;
5962
5993
  getCheckpoint(runId: string, seq: number): Promise<StepCheckpoint | null>;
5963
5994
  saveCheckpoint(checkpoint: StepCheckpoint): Promise<void>;
5995
+ saveCheckpointIfPending(checkpoint: StepCheckpoint): Promise<boolean>;
5964
5996
  listIncompleteRuns(namespace?: string): Promise<WorkflowRun[]>;
5965
5997
  listPendingRuns(limit: number, namespace?: string): Promise<WorkflowRun[]>;
5966
5998
  listDueTimers(nowMs: number, namespace?: string): Promise<WorkflowRun[]>;
package/dist/index.js CHANGED
@@ -3698,6 +3698,12 @@ var WorkflowEngine = class {
3698
3698
  updateValidators = /* @__PURE__ */ new Map();
3699
3699
  /** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
3700
3700
  cancelRequested = /* @__PURE__ */ new Set();
3701
+ /**
3702
+ * Seqs of the dispatched remote steps a turn suspended on, by run, while that turn executes on
3703
+ * THIS instance — so the turn can re-check them once its lease is released (see
3704
+ * {@link redriveSettledSteps}).
3705
+ */
3706
+ awaitedRemoteSteps = /* @__PURE__ */ new Map();
3701
3707
  /** Flow-control admission backend for remote steps (see {@link registerQueue}). */
3702
3708
  admission;
3703
3709
  /** Runs on THIS instance blocked on admission, by queue — woken early on a freed-slot signal. */
@@ -4172,7 +4178,18 @@ var WorkflowEngine = class {
4172
4178
  createdAt: now,
4173
4179
  updatedAt: now
4174
4180
  };
4175
- await this.store.createRun(run);
4181
+ try {
4182
+ await this.store.createRun(run);
4183
+ } catch (err) {
4184
+ const winner = await this.store.getRun(runId).catch(() => null);
4185
+ if (!winner) throw err;
4186
+ return {
4187
+ runId,
4188
+ status: winner.status,
4189
+ output: winner.output,
4190
+ error: winner.error
4191
+ };
4192
+ }
4176
4193
  await this.runDispatcher.dispatch(runId);
4177
4194
  if (this.controlPlane) {
4178
4195
  void this.controlPlane.publishControl({
@@ -5101,6 +5118,24 @@ var WorkflowEngine = class {
5101
5118
  * itself. Only re-drives for a group with no waiter left whose checkpoints are all settled, which
5102
5119
  * the next turn is then guaranteed to see — so it cannot loop.
5103
5120
  */
5121
+ /**
5122
+ * Re-drive a run that just suspended on dispatched remote steps which, by now, have a result.
5123
+ *
5124
+ * Same race as {@link redriveSettledChildren}, for `ctx.step`: a result landing while the turn
5125
+ * that dispatched it still holds the lease has its wake dropped by {@link execute}, and the turn
5126
+ * then suspends with nothing left to wake it before the step's redispatch deadline. Called after
5127
+ * the lease is released, so any later result wakes the run itself; it only re-drives for a
5128
+ * checkpoint that is already settled, which the next turn replays past — so it cannot loop.
5129
+ */
5130
+ async redriveSettledSteps(runId, seqs) {
5131
+ for (const seq of seqs) {
5132
+ const cp = await this.store.getCheckpoint(runId, seq);
5133
+ if (cp && (cp.status === "completed" || cp.status === "failed")) {
5134
+ await this.resumeImplicit(runId);
5135
+ return;
5136
+ }
5137
+ }
5138
+ }
5104
5139
  async redriveSettledChildren(runId, fan) {
5105
5140
  if (!this.store.hasSignalWaiter) return;
5106
5141
  for (const wait of fan.waits) {
@@ -6555,6 +6590,9 @@ var WorkflowEngine = class {
6555
6590
  const replay = /* @__PURE__ */ new Map();
6556
6591
  for (const cp of snapshot) replay.set(cp.seq, cp);
6557
6592
  let suspendedOnFan;
6593
+ let suspendedOnSteps = false;
6594
+ const awaitedSteps = /* @__PURE__ */ new Set();
6595
+ this.awaitedRemoteSteps.set(run.id, awaitedSteps);
6558
6596
  const ctx = createWorkflowCtx(this.ctxHostFor(replay, run.namespace), run.id, compensations, run.workflow, registered?.searchAttributesSchema);
6559
6597
  try {
6560
6598
  const output = await runInWorkflowCtx(ctx, () => fn(ctx, run.input));
@@ -6611,6 +6649,7 @@ var WorkflowEngine = class {
6611
6649
  wakeAt: err.wakeAt
6612
6650
  });
6613
6651
  suspendedOnFan = err.fan;
6652
+ suspendedOnSteps = suspended.status === "suspended";
6614
6653
  return suspended;
6615
6654
  }
6616
6655
  if (this.cancelRequested.has(run.id)) {
@@ -6630,10 +6669,16 @@ var WorkflowEngine = class {
6630
6669
  error: error2
6631
6670
  });
6632
6671
  } finally {
6672
+ if (this.awaitedRemoteSteps.get(run.id) === awaitedSteps) {
6673
+ this.awaitedRemoteSteps.delete(run.id);
6674
+ }
6633
6675
  await this.store.releaseRunLock(run.id);
6634
6676
  if (suspendedOnFan) {
6635
6677
  void this.redriveSettledChildren(run.id, suspendedOnFan).catch(() => void 0);
6636
6678
  }
6679
+ if (suspendedOnSteps && awaitedSteps.size > 0) {
6680
+ void this.redriveSettledSteps(run.id, awaitedSteps).catch(() => void 0);
6681
+ }
6637
6682
  }
6638
6683
  }
6639
6684
  /**
@@ -7053,13 +7098,16 @@ var WorkflowEngine = class {
7053
7098
  if (this.pool.size === 0) throw new Error("remote steps require a Transport");
7054
7099
  if (step.timeoutMs) return this.callRemoteInMemory(runId, seq, step, input, transport, group);
7055
7100
  if (existing?.status === "pending") {
7101
+ this.awaitedRemoteSteps.get(runId)?.add(seq);
7056
7102
  if (this.remoteRedispatchMs == null) throw new WorkflowSuspended();
7057
7103
  if (existing.wakeAt == null) {
7058
7104
  const wakeAt = this.clock() + this.remoteRedispatchMs;
7059
- await this.store.saveCheckpoint({
7105
+ if (!await this.savePendingCheckpoint({
7060
7106
  ...existing,
7061
7107
  wakeAt
7062
- });
7108
+ })) {
7109
+ return this.callRemote(runId, seq, step, input, queue, transport, void 0, admission, routing);
7110
+ }
7063
7111
  throw new WorkflowSuspended(wakeAt);
7064
7112
  }
7065
7113
  if (this.clock() < existing.wakeAt) throw new WorkflowSuspended(existing.wakeAt);
@@ -7072,7 +7120,7 @@ var WorkflowEngine = class {
7072
7120
  const reAttempt = existing.attempts + 1;
7073
7121
  const nextDeadline = this.clock() + this.remoteRedispatchMs;
7074
7122
  const reEnqueuedAt = /* @__PURE__ */ new Date();
7075
- await this.store.saveCheckpoint({
7123
+ const claimed = await this.savePendingCheckpoint({
7076
7124
  ...existing,
7077
7125
  attempts: reAttempt,
7078
7126
  wakeAt: nextDeadline,
@@ -7086,6 +7134,9 @@ var WorkflowEngine = class {
7086
7134
  lostStepEvent(this.clock(), reAttempt, "redispatched")
7087
7135
  ]
7088
7136
  });
7137
+ if (!claimed) {
7138
+ return this.callRemote(runId, seq, step, input, queue, transport, void 0, admission, routing);
7139
+ }
7089
7140
  await this.dispatchRemoteTask({
7090
7141
  runId,
7091
7142
  seq,
@@ -7173,8 +7224,15 @@ var WorkflowEngine = class {
7173
7224
  name: step.name,
7174
7225
  kind: "remote"
7175
7226
  });
7227
+ this.awaitedRemoteSteps.get(runId)?.add(seq);
7176
7228
  throw new WorkflowSuspended();
7177
7229
  }
7230
+ /** {@link StateStore.saveCheckpointIfPending}, or a plain save on a store without it. */
7231
+ async savePendingCheckpoint(checkpoint) {
7232
+ if (this.store.saveCheckpointIfPending) return this.store.saveCheckpointIfPending(checkpoint);
7233
+ await this.store.saveCheckpoint(checkpoint);
7234
+ return true;
7235
+ }
7178
7236
  /** Enqueue a remote step task to its worker group via the transport pool. Shared by the initial
7179
7237
  * dispatch, the opt-in self-heal re-dispatch (see `remoteRedispatchMs`), and the explicit
7180
7238
  * {@link redispatchPending}. Carries the current trace/context so a re-dispatch is traceable. */
@@ -7205,7 +7263,7 @@ var WorkflowEngine = class {
7205
7263
  if (run && (run.status === "cancelled" || run.status === "completed")) return;
7206
7264
  const finishedAt = /* @__PURE__ */ new Date();
7207
7265
  const startedAt = result.startedAt ? new Date(result.startedAt) : cp.startedAt;
7208
- await this.store.saveCheckpoint({
7266
+ const claimed = await this.savePendingCheckpoint({
7209
7267
  ...cp,
7210
7268
  status: result.status,
7211
7269
  output: result.status === "completed" ? result.output : cp.output,
@@ -7214,6 +7272,7 @@ var WorkflowEngine = class {
7214
7272
  startedAt,
7215
7273
  finishedAt
7216
7274
  });
7275
+ if (!claimed) return;
7217
7276
  this.emit({
7218
7277
  type: result.status === "completed" ? "step.completed" : "step.failed",
7219
7278
  runId: result.runId,
@@ -7606,6 +7665,9 @@ var CodecStateStore = class {
7606
7665
  /** Forwarded verbatim (deletes touch no payload). Bound only when the inner store has it, so the
7607
7666
  * engine's per-run fallback still kicks in through the wrapper. */
7608
7667
  deleteRuns;
7668
+ /** Encodes like {@link saveCheckpoint}. Bound only when the inner store has it, so the engine's
7669
+ * plain-save fallback still kicks in through the wrapper. */
7670
+ saveCheckpointIfPending;
7609
7671
  /** Forwarded verbatim — retention selects by status/updatedAt/scope, never by payload. Bound only
7610
7672
  * when the inner store prunes, so the retention poller still sees (and warns about) its absence. */
7611
7673
  pruneTerminalRuns;
@@ -7633,6 +7695,8 @@ var CodecStateStore = class {
7633
7695
  if (inner.tryAdmitSingleton) this.tryAdmitSingleton = inner.tryAdmitSingleton.bind(inner);
7634
7696
  const deleteRuns = inner.deleteRuns;
7635
7697
  if (deleteRuns) this.deleteRuns = (ids) => deleteRuns.call(inner, ids);
7698
+ const saveIfPending = inner.saveCheckpointIfPending;
7699
+ if (saveIfPending) this.saveCheckpointIfPending = (cp) => saveIfPending.call(inner, this.encCp(cp));
7636
7700
  const prune = inner.pruneTerminalRuns;
7637
7701
  if (prune) this.pruneTerminalRuns = (policy, now, limit) => prune.call(inner, policy, now, limit);
7638
7702
  const count = inner.countRuns;
@@ -8196,6 +8260,14 @@ var InMemoryStateStore = class {
8196
8260
  ...checkpoint
8197
8261
  });
8198
8262
  }
8263
+ async saveCheckpointIfPending(checkpoint) {
8264
+ const key = this.key(checkpoint.runId, checkpoint.seq);
8265
+ if (this.checkpoints.get(key)?.status !== "pending") return false;
8266
+ this.checkpoints.set(key, {
8267
+ ...checkpoint
8268
+ });
8269
+ return true;
8270
+ }
8199
8271
  async listIncompleteRuns(namespace) {
8200
8272
  return [
8201
8273
  ...this.runs.values()