@dudousxd/nestjs-durable-core 0.81.0 → 0.83.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
@@ -30,6 +30,7 @@ __export(index_exports, {
30
30
  ContinueAsNew: () => ContinueAsNew,
31
31
  DEFAULT_CONCURRENCY_STATUSES: () => DEFAULT_CONCURRENCY_STATUSES,
32
32
  DEFAULT_PARTITION: () => DEFAULT_PARTITION,
33
+ DEFAULT_STALLED_AFTER_MS: () => DEFAULT_STALLED_AFTER_MS,
33
34
  DURABLE_OPTIONS: () => DURABLE_OPTIONS,
34
35
  DURABLE_OPTIONS_CANONICAL: () => DURABLE_OPTIONS_CANONICAL,
35
36
  DURABLE_STEP_CONFIG: () => DURABLE_STEP_CONFIG,
@@ -53,6 +54,7 @@ __export(index_exports, {
53
54
  NestedWorkflowCallError: () => NestedWorkflowCallError,
54
55
  NonDeterminismError: () => NonDeterminismError,
55
56
  QueueController: () => QueueController,
57
+ RUN_HISTORY_FIXTURE_VERSION: () => RUN_HISTORY_FIXTURE_VERSION,
56
58
  RUN_VALUE_FACET_LIMIT: () => RUN_VALUE_FACET_LIMIT,
57
59
  RUN_VALUE_FACET_SCAN: () => RUN_VALUE_FACET_SCAN,
58
60
  RemoteStepError: () => RemoteStepError,
@@ -70,6 +72,7 @@ __export(index_exports, {
70
72
  ScheduleAlreadyExistsError: () => ScheduleAlreadyExistsError,
71
73
  ScheduleClient: () => ScheduleClient,
72
74
  ScheduleNotFoundError: () => ScheduleNotFoundError,
75
+ SchemaValidationError: () => SchemaValidationError,
73
76
  SignalTimeoutError: () => SignalTimeoutError,
74
77
  SingletonQueueFullError: () => SingletonQueueFullError,
75
78
  TERMINAL_RUN_STATUSES: () => TERMINAL_RUN_STATUSES,
@@ -126,6 +129,7 @@ __export(index_exports, {
126
129
  facetOrigin: () => facetOrigin,
127
130
  fanPageBounds: () => fanPageBounds,
128
131
  fanPageEtag: () => fanPageEtag,
132
+ findStalledRuns: () => findStalledRuns,
129
133
  foldedCount: () => foldedCount,
130
134
  handoffHandlerId: () => handoffHandlerId,
131
135
  handoffName: () => handoffName,
@@ -180,6 +184,7 @@ __export(index_exports, {
180
184
  runStepHandler: () => runStepHandler,
181
185
  runValueFacetsFromRuns: () => runValueFacetsFromRuns,
182
186
  sanitizeQueueToken: () => sanitizeQueueToken,
187
+ scheduleRunIdAt: () => scheduleRunIdAt,
183
188
  scheduledRunId: () => scheduledRunId,
184
189
  singletonAdmissionDenialPatch: () => singletonAdmissionDenialPatch,
185
190
  singletonAdmissionTags: () => singletonAdmissionTags,
@@ -478,6 +483,31 @@ var FatalError = class extends Error {
478
483
  this.details = details;
479
484
  }
480
485
  };
486
+ var SchemaValidationError = class extends FatalError {
487
+ static {
488
+ __name(this, "SchemaValidationError");
489
+ }
490
+ static code = "schema_validation";
491
+ issues;
492
+ /**
493
+ * @param label What was validated, e.g. `workflow "checkout" input`; the message is
494
+ * `invalid <label>: <path>: <message>; …`.
495
+ * @param summary Overrides the `<path>: <message>; …` part, for issues whose messages already
496
+ * name their field (class-validator's `orderId must be a string`).
497
+ */
498
+ constructor(label, issues, code = "schema_validation", summary) {
499
+ const normalized = issues.map((issue) => ({
500
+ message: issue.message,
501
+ path: (issue.path ?? []).map((segment) => String(typeof segment === "object" && segment !== null ? segment.key : segment))
502
+ }));
503
+ const text = summary ?? normalized.map((issue) => issue.path.length > 0 ? `${issue.path.join(".")}: ${issue.message}` : issue.message).join("; ");
504
+ super(`invalid ${label}: ${text}`, code, {
505
+ issues: normalized
506
+ });
507
+ this.name = "SchemaValidationError";
508
+ this.issues = normalized;
509
+ }
510
+ };
481
511
  var ConcurrencyLimitError = class extends Error {
482
512
  static {
483
513
  __name(this, "ConcurrencyLimitError");
@@ -2209,9 +2239,14 @@ function liveHandoff(runId, checkpoints) {
2209
2239
  return live;
2210
2240
  }
2211
2241
  __name(liveHandoff, "liveHandoff");
2242
+ var SCHEMA_VALIDATION_CODES = /* @__PURE__ */ new Set([
2243
+ "invalid_input",
2244
+ "invalid_output"
2245
+ ]);
2212
2246
  function routeFor(routes, error2) {
2213
2247
  if (!routes?.length || error2.code && UNROUTABLE_CODES.has(error2.code)) return void 0;
2214
- return (error2.code ? routes.find((r) => r.code === error2.code) : void 0) ?? routes.find((r) => r.code === void 0);
2248
+ const { code } = error2;
2249
+ return (code ? routes.find((r) => r.code === code) : void 0) ?? (code && SCHEMA_VALIDATION_CODES.has(code) ? routes.find((r) => r.code === "schema_validation") : void 0) ?? routes.find((r) => r.code === void 0);
2215
2250
  }
2216
2251
  __name(routeFor, "routeFor");
2217
2252
  function assertRoutes(workflow, routes) {
@@ -2483,9 +2518,13 @@ __name(scheduleRunIdsAt, "scheduleRunIdsAt");
2483
2518
  async function runSchedules(engine, schedules, nowMs, opts) {
2484
2519
  const random = opts?.random ?? Math.random;
2485
2520
  const sleep = opts?.sleep ?? defaultSleep;
2521
+ const settled = settledIdsFor(engine);
2486
2522
  const ids = [];
2487
2523
  for (const s of schedules) {
2524
+ if (engine.isDraining?.()) break;
2488
2525
  if (s.paused) continue;
2526
+ const candidates = scheduleRunIdsAt(s, nowMs).filter((id) => !settled.get(s.key)?.has(id));
2527
+ if (candidates.length === 0) continue;
2489
2528
  if (s.overlap === "skip" && s.everyMs) {
2490
2529
  const prevBucket = Math.floor(nowMs / s.everyMs) - 1;
2491
2530
  if (prevBucket >= 0) {
@@ -2494,14 +2533,44 @@ async function runSchedules(engine, schedules, nowMs, opts) {
2494
2533
  }
2495
2534
  }
2496
2535
  if (s.jitter && s.jitter > 0) await sleep(Math.floor(random() * s.jitter));
2497
- for (const runId of scheduleRunIdsAt(s, nowMs)) {
2498
- await engine.start(s.workflow, s.input, runId);
2536
+ for (const runId of candidates) {
2537
+ if (engine.isDraining?.()) break;
2538
+ const existing = await engine.getRun(runId);
2539
+ remember(settled, s.key, runId);
2540
+ if (existing) continue;
2541
+ await engine.start(workflowName(s.workflow), s.input, runId, {
2542
+ namespace: s.namespace
2543
+ });
2499
2544
  ids.push(runId);
2500
2545
  }
2501
2546
  }
2502
2547
  return ids;
2503
2548
  }
2504
2549
  __name(runSchedules, "runSchedules");
2550
+ var settledRunIds = /* @__PURE__ */ new WeakMap();
2551
+ function settledIdsFor(engine) {
2552
+ let memo = settledRunIds.get(engine);
2553
+ if (memo === void 0) {
2554
+ memo = /* @__PURE__ */ new Map();
2555
+ settledRunIds.set(engine, memo);
2556
+ }
2557
+ return memo;
2558
+ }
2559
+ __name(settledIdsFor, "settledIdsFor");
2560
+ function remember(memo, key, runId) {
2561
+ const seen = memo.get(key);
2562
+ if (seen === void 0) {
2563
+ memo.set(key, /* @__PURE__ */ new Set([
2564
+ runId
2565
+ ]));
2566
+ return;
2567
+ }
2568
+ seen.add(runId);
2569
+ if (seen.size > 64) memo.set(key, /* @__PURE__ */ new Set([
2570
+ runId
2571
+ ]));
2572
+ }
2573
+ __name(remember, "remember");
2505
2574
 
2506
2575
  // src/schedules.ts
2507
2576
  var ScheduleAlreadyExistsError = class extends Error {
@@ -3017,6 +3086,66 @@ var SingletonGate = class {
3017
3086
  }
3018
3087
  };
3019
3088
 
3089
+ // src/stalled-runs.ts
3090
+ var DEFAULT_STALLED_AFTER_MS = 15 * 6e4;
3091
+ async function findStalledRuns(store, options = {}) {
3092
+ const now = options.now ?? Date.now();
3093
+ const thresholdMs = options.thresholdMs ?? DEFAULT_STALLED_AFTER_MS;
3094
+ const scan = Math.max(1, options.scan ?? 1e3);
3095
+ const pageSize = Math.min(200, scan);
3096
+ const stalled = [];
3097
+ for (let offset = 0; offset < scan; offset += pageSize) {
3098
+ const limit = Math.min(pageSize, scan - offset);
3099
+ const page = await store.listRuns({
3100
+ statuses: [
3101
+ "running",
3102
+ "suspended"
3103
+ ],
3104
+ ...options.namespace !== void 0 ? {
3105
+ namespace: options.namespace
3106
+ } : {},
3107
+ limit,
3108
+ offset
3109
+ });
3110
+ for (const run of page) {
3111
+ const ageMs = now - run.updatedAt.getTime();
3112
+ if (ageMs < thresholdMs) continue;
3113
+ const info2 = await stalledInfo(store, run, now, thresholdMs, ageMs);
3114
+ if (info2) stalled.push(info2);
3115
+ }
3116
+ if (page.length < limit) break;
3117
+ }
3118
+ return stalled;
3119
+ }
3120
+ __name(findStalledRuns, "findStalledRuns");
3121
+ async function stalledInfo(store, run, now, thresholdMs, ageMs) {
3122
+ let stalePending = null;
3123
+ for (const cp of await store.listCheckpoints(run.id)) {
3124
+ if (cp.kind !== "remote" || cp.status !== "pending") continue;
3125
+ const stepAgeMs = now - cp.enqueuedAt.getTime();
3126
+ if (stepAgeMs < thresholdMs) continue;
3127
+ if (cp.wakeAt != null && cp.wakeAt > now) continue;
3128
+ if (!stalePending || stepAgeMs > stalePending.ageMs) {
3129
+ stalePending = {
3130
+ seq: cp.seq,
3131
+ name: cp.name,
3132
+ attempts: cp.attempts,
3133
+ ageMs: stepAgeMs,
3134
+ leaseUntil: cp.wakeAt ?? null
3135
+ };
3136
+ }
3137
+ }
3138
+ const wakeForever = run.status === "suspended" && run.wakeAt == null;
3139
+ if (!wakeForever && !stalePending) return null;
3140
+ return {
3141
+ run,
3142
+ ageMs,
3143
+ wakeForever,
3144
+ stalePending
3145
+ };
3146
+ }
3147
+ __name(stalledInfo, "stalledInfo");
3148
+
3020
3149
  // src/transport-pool.ts
3021
3150
  var TransportPool = class {
3022
3151
  static {
@@ -3240,11 +3369,6 @@ function normalizeChildOptions(options) {
3240
3369
  } : options ?? {};
3241
3370
  }
3242
3371
  __name(normalizeChildOptions, "normalizeChildOptions");
3243
- function formatIssuePath(path) {
3244
- if (!path || path.length === 0) return "(root)";
3245
- return path.map((segment) => String(typeof segment === "object" ? segment.key : segment)).join(".");
3246
- }
3247
- __name(formatIssuePath, "formatIssuePath");
3248
3372
  function resolveStepPolicy(config, opts) {
3249
3373
  const policy = {};
3250
3374
  const retries = opts?.retries ?? config?.retries;
@@ -4076,11 +4200,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
4076
4200
  throw new Error(`workflow "${workflow}": ctx.upsertSearchAttributes schema validation must be synchronous (the searchAttributes schema's \`~standard.validate\` returned a Promise) \u2014 replay-determinism requires a sync validator; drop any async refinement from the schema passed to @Workflow({ searchAttributes }).`);
4077
4201
  }
4078
4202
  if (result.issues) {
4079
- const offendingKeys = [
4080
- ...new Set(result.issues.map((issue) => formatIssuePath(issue.path)))
4081
- ];
4082
- const details = result.issues.map((issue) => `${formatIssuePath(issue.path)}: ${issue.message}`).join("; ");
4083
- throw new Error(`workflow "${workflow}": ctx.upsertSearchAttributes rejected by its searchAttributes schema \u2014 offending key(s) ${offendingKeys.join(", ")} \u2014 ${details}`);
4203
+ throw new SchemaValidationError(`workflow "${workflow}" ctx.upsertSearchAttributes`, result.issues);
4084
4204
  }
4085
4205
  }
4086
4206
  await host.upsertSearchAttributes(runId, attrs);
@@ -4374,6 +4494,18 @@ var WorkflowEngine = class {
4374
4494
  stepQueue = /* @__PURE__ */ new Map();
4375
4495
  /** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
4376
4496
  inflight = /* @__PURE__ */ new Set();
4497
+ /**
4498
+ * The run lease of each execution in flight on THIS instance, by run id — renewed while it runs
4499
+ * and checked by every settle (the zombie fence, see {@link ExecutionLease}). Also what `drain()`
4500
+ * releases when its budget runs out.
4501
+ */
4502
+ executionLeases = /* @__PURE__ */ new Map();
4503
+ /** Pager hooks for stranded runs (see {@link onStalled}). */
4504
+ stalledListeners = /* @__PURE__ */ new Set();
4505
+ /** Runs already paged about, by id → the `updatedAt` of the paged episode (see {@link sweepStalled}). */
4506
+ notifiedStalled = /* @__PURE__ */ new Map();
4507
+ lastStalledSweepAt = Number.NEGATIVE_INFINITY;
4508
+ stalledAfterMs;
4377
4509
  draining = false;
4378
4510
  /** Wait already spent on this shutdown before `drain()` (see `beginDrain`). */
4379
4511
  drainSpentMs = 0;
@@ -4398,6 +4530,8 @@ var WorkflowEngine = class {
4398
4530
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
4399
4531
  this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs;
4400
4532
  this.reconcileMs = deps.reconcileMs === void 0 ? 3e5 : deps.reconcileMs || void 0;
4533
+ const stalledAfter = deps.stalledAfterMs;
4534
+ this.stalledAfterMs = stalledAfter !== void 0 && Number.isFinite(stalledAfter) ? Math.max(6e4, stalledAfter) : DEFAULT_STALLED_AFTER_MS;
4401
4535
  const batch = deps.timerBatchSize;
4402
4536
  this.timerBatchSize = batch !== void 0 && Number.isFinite(batch) && batch >= 1 ? Math.floor(batch) : 500;
4403
4537
  this.eventBufferTtlMs = deps.eventBufferTtlMs || void 0;
@@ -4741,6 +4875,64 @@ var WorkflowEngine = class {
4741
4875
  this.deadListeners.add(listener);
4742
4876
  return () => this.deadListeners.delete(listener);
4743
4877
  }
4878
+ /**
4879
+ * Register a pager hook for STRANDED runs: an in-flight run untouched past `stalledAfterMs` that is
4880
+ * either suspended with NO wake timer (wake-forever: nothing will ever re-drive it) or parked on an
4881
+ * old pending remote step with no live lease (dispatched and, most likely, lost). The timer poller
4882
+ * sweeps for these ({@link sweepStalled}, throttled to one pass a minute) and notifies each episode
4883
+ * ONCE per instance: a run is paged again only after it makes progress and strands again. Wire your
4884
+ * alerting here like {@link onDead}. A listener that throws never breaks the sweep. Returns an
4885
+ * unsubscribe function.
4886
+ */
4887
+ onStalled(listener) {
4888
+ this.stalledListeners.add(listener);
4889
+ return () => this.stalledListeners.delete(listener);
4890
+ }
4891
+ /**
4892
+ * Detect stranded in-flight runs and notify the {@link onStalled} listeners. Self-throttled to one
4893
+ * pass a minute, skipped while draining, and a no-op without listeners — call it every tick (the
4894
+ * NestJS timer poller does). Scoped to this engine's `namespace` (an operator engine with no
4895
+ * namespace sweeps every namespace). Returns the runs notified THIS pass.
4896
+ */
4897
+ async sweepStalled(now = this.clock()) {
4898
+ if (this.stalledListeners.size === 0 || this.draining) return [];
4899
+ if (now - this.lastStalledSweepAt < 6e4) return [];
4900
+ this.lastStalledSweepAt = now;
4901
+ const notified = [];
4902
+ for (const info2 of await this.listStalledRuns({
4903
+ now
4904
+ })) {
4905
+ const episode = info2.run.updatedAt.getTime();
4906
+ if (this.notifiedStalled.get(info2.run.id) === episode) continue;
4907
+ this.notifiedStalled.set(info2.run.id, episode);
4908
+ if (this.notifiedStalled.size > 1e3) this.notifiedStalled.clear();
4909
+ for (const listener of this.stalledListeners) {
4910
+ try {
4911
+ listener(info2);
4912
+ } catch {
4913
+ }
4914
+ }
4915
+ notified.push(info2);
4916
+ }
4917
+ return notified;
4918
+ }
4919
+ /**
4920
+ * The stranded runs right now, without notifying anyone: the query behind {@link sweepStalled} and
4921
+ * the CLI's `durable inspect --stale`. A `running`/`suspended` run qualifies when it has been
4922
+ * untouched for `thresholdMs` (default `stalledAfterMs`) AND either it is suspended with no wake
4923
+ * timer, or its oldest pending remote step is older than the threshold with no lease in the future
4924
+ * (a step with a `timeoutMs`, a heartbeat or `remoteRedispatchMs` keeps a lease; a long
4925
+ * `ctx.sleep` has a future `wakeAt`; neither is stranded). Scans up to `scan` candidates (default
4926
+ * 1000), newest first, in this engine's namespace.
4927
+ */
4928
+ async listStalledRuns(options = {}) {
4929
+ return findStalledRuns(this.store, {
4930
+ now: options.now ?? this.clock(),
4931
+ thresholdMs: options.thresholdMs ?? this.stalledAfterMs,
4932
+ scan: options.scan,
4933
+ namespace: this.namespace
4934
+ });
4935
+ }
4744
4936
  notifyDead(run) {
4745
4937
  for (const fn of this.deadListeners) {
4746
4938
  try {
@@ -4899,6 +5091,11 @@ var WorkflowEngine = class {
4899
5091
  getRun(runId) {
4900
5092
  return this.store.getRun(runId);
4901
5093
  }
5094
+ /** A run's full checkpoint timeline, ordered by seq. A thin pass-through to the store (it is what
5095
+ * the testing kit's `captureHistory` reads, next to {@link getRun}). */
5096
+ listCheckpoints(runId) {
5097
+ return this.store.listCheckpoints(runId);
5098
+ }
4902
5099
  /**
4903
5100
  * Re-execute a run's body (replaying its checkpoints). This is the EXPLICIT entry point: besides
4904
5101
  * driving a live run, it re-drives a `failed` run — the manual retry (prefer {@link requeue}, which
@@ -4984,6 +5181,8 @@ var WorkflowEngine = class {
4984
5181
  * diagnosis.
4985
5182
  */
4986
5183
  async releaseLeaseBestEffort(runId) {
5184
+ if (this.isFenced(runId)) return;
5185
+ this.handOffLease(runId);
4987
5186
  try {
4988
5187
  await this.store.releaseRunLock(runId);
4989
5188
  } catch (err) {
@@ -5207,6 +5406,10 @@ var WorkflowEngine = class {
5207
5406
  for (const rearm of this.drainRearms) rearm();
5208
5407
  }
5209
5408
  }
5409
+ /** True once {@link beginDrain} (or {@link drain}) has been called. */
5410
+ isDraining() {
5411
+ return this.draining;
5412
+ }
5210
5413
  /**
5211
5414
  * Graceful shutdown: stop picking up new runs (recovery/timer become no-ops) and wait for
5212
5415
  * in-flight executions to settle, up to `timeoutMs`. Call from your app's shutdown hook so a
@@ -5233,17 +5436,47 @@ var WorkflowEngine = class {
5233
5436
  rearm();
5234
5437
  });
5235
5438
  this.drainRearms.add(rearm);
5439
+ let settled = false;
5236
5440
  try {
5237
5441
  await Promise.race([
5238
5442
  Promise.allSettled([
5239
5443
  ...this.inflight
5240
- ]),
5444
+ ]).then(() => {
5445
+ settled = true;
5446
+ }),
5241
5447
  timer
5242
5448
  ]);
5243
5449
  } finally {
5244
5450
  this.drainRearms.delete(rearm);
5245
5451
  clearTimeout(timeout);
5246
5452
  }
5453
+ if (!settled) await this.releaseInflightLeases();
5454
+ }
5455
+ /**
5456
+ * The drain budget ran out with executions still in flight: release their recovery leases so
5457
+ * another instance re-drives those runs on its next recovery pass, instead of waiting out
5458
+ * `leaseMs` after this process exits. Each still-running turn is FENCED first, so whatever it
5459
+ * computes after this point is never written over the new owner's work (see settleRun). Its
5460
+ * completed steps are checkpointed, so the new owner replays them; a step still executing here
5461
+ * may run again there, exactly as after a crash. The release is owner-scoped where the store
5462
+ * supports it (only a lease this instance still holds is cleared). Best effort: never throws.
5463
+ */
5464
+ async releaseInflightLeases() {
5465
+ const released = [];
5466
+ for (const [runId, lease] of [
5467
+ ...this.executionLeases
5468
+ ]) {
5469
+ if (lease.lost || lease.handedOff || lease.done) continue;
5470
+ lease.lost = true;
5471
+ try {
5472
+ await this.store.releaseRunLock(runId, this.instanceId);
5473
+ released.push(runId);
5474
+ } catch {
5475
+ }
5476
+ }
5477
+ if (released.length > 0) {
5478
+ console.warn(`[nestjs-durable] drain timed out with ${released.length} run(s) still executing; released their leases so another instance recovers them now: ${released.join(", ")}`);
5479
+ }
5247
5480
  }
5248
5481
  /**
5249
5482
  * Cancel in-flight runs that have outlived their workflow's `executionTimeout`. Call it from the
@@ -6733,7 +6966,10 @@ var WorkflowEngine = class {
6733
6966
  }
6734
6967
  async singletonWaitResult(run) {
6735
6968
  const current = await this.store.getRun(run.id);
6736
- await this.store.releaseRunLock(run.id);
6969
+ if (!this.isFenced(run.id)) {
6970
+ this.handOffLease(run.id);
6971
+ await this.store.releaseRunLock(run.id);
6972
+ }
6737
6973
  if (!current) throw new Error(`run ${run.id} was deleted during singleton admission`);
6738
6974
  if (current.status === "suspended") {
6739
6975
  this.emit({
@@ -6761,10 +6997,7 @@ var WorkflowEngine = class {
6761
6997
  };
6762
6998
  }
6763
6999
  }
6764
- const renew = setInterval(() => {
6765
- void this.store.renewRunLock(run.id, this.instanceId, this.clock() + this.leaseMs).catch(() => void 0);
6766
- }, Math.max(50, Math.floor(this.leaseMs / 2)));
6767
- renew.unref?.();
7000
+ const held = this.holdExecutionLease(run.id);
6768
7001
  try {
6769
7002
  const result = registered?.remote ? await this.runRemoteExecution(run, registered) : await this.runExecution(run, fn, registered);
6770
7003
  if (registered?.singleton && (result.status === "completed" || result.status === "failed" || result.status === "cancelled" || result.status === "dead")) {
@@ -6772,10 +7005,59 @@ var WorkflowEngine = class {
6772
7005
  }
6773
7006
  return result;
6774
7007
  } finally {
6775
- clearInterval(renew);
7008
+ held.stop();
6776
7009
  }
6777
7010
  }
6778
7011
  /**
7012
+ * Renew `runId`'s lease for as long as this execution holds it, and register it in
7013
+ * {@link executionLeases} so settles can check the zombie fence and `drain()` can release it.
7014
+ *
7015
+ * A renewal that returns `false` means the lease is gone: this turn stalled past `leaseMs` and
7016
+ * another instance reclaimed the run, so this executor is now a zombie. It is FENCED (`lost`): its
7017
+ * settles degrade to an echo of the store's state instead of clobbering the new owner's writes, and
7018
+ * its own lease release is skipped (it would free the new owner's lease). A renewal that THROWS (a
7019
+ * transient store error) is not evidence of a takeover, so it stays swallowed and the next
7020
+ * interval retries. A lease this execution released on purpose (`handedOff`) is not renewed, and a
7021
+ * renewal resolving after the execution ended (`done`) fences nothing.
7022
+ */
7023
+ holdExecutionLease(runId) {
7024
+ const lease = {
7025
+ lost: false,
7026
+ handedOff: false,
7027
+ done: false
7028
+ };
7029
+ this.executionLeases.set(runId, lease);
7030
+ const renew = setInterval(() => {
7031
+ if (lease.handedOff || lease.lost || lease.done) return;
7032
+ void this.store.renewRunLock(runId, this.instanceId, this.clock() + this.leaseMs).then((renewed) => {
7033
+ if (renewed || lease.handedOff || lease.lost || lease.done) return;
7034
+ lease.lost = true;
7035
+ console.warn(`[nestjs-durable] lost the lease of run ${runId} while executing it (another instance took it over after it expired); this instance will not write the run's outcome`);
7036
+ }).catch(() => void 0);
7037
+ }, Math.max(50, Math.floor(this.leaseMs / 2)));
7038
+ renew.unref?.();
7039
+ return {
7040
+ lease,
7041
+ stop: /* @__PURE__ */ __name(() => {
7042
+ clearInterval(renew);
7043
+ lease.done = true;
7044
+ if (this.executionLeases.get(runId) === lease) this.executionLeases.delete(runId);
7045
+ }, "stop")
7046
+ };
7047
+ }
7048
+ /** Whether `runId`'s execution on this instance lost its lease (see {@link holdExecutionLease}). */
7049
+ isFenced(runId) {
7050
+ return this.executionLeases.get(runId)?.lost === true;
7051
+ }
7052
+ /**
7053
+ * Mark `runId`'s lease as given away on purpose by its own execution (released mid-turn), so a
7054
+ * renewal that now fails is not read as a takeover.
7055
+ */
7056
+ handOffLease(runId) {
7057
+ const lease = this.executionLeases.get(runId);
7058
+ if (lease) lease.handedOff = true;
7059
+ }
7060
+ /**
6779
7061
  * Advance a remote (cross-SDK) workflow one turn: hand its history to the executor (which dispatches
6780
7062
  * a workflow task to the worker and awaits its replay) and apply the decision. Mirrors
6781
7063
  * {@link runExecution}'s settle/suspend; the lease is held by {@link execute}. The result that lands
@@ -6992,6 +7274,7 @@ ${run.workflowVersion}`;
6992
7274
  * keeps its own lease handling (the TS executor's `finally`, the remote path's caller).
6993
7275
  */
6994
7276
  async settleRun(run, outcome) {
7277
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
6995
7278
  const updatedAt = /* @__PURE__ */ new Date();
6996
7279
  if (outcome.kind === "completed") {
6997
7280
  const applied2 = await (this.store.updateRunIf ?? this.updateRunIfFallback).call(this.store, run.id, SETTLE_ELIGIBLE_STATUSES, {
@@ -7072,6 +7355,7 @@ ${run.workflowVersion}`;
7072
7355
  }
7073
7356
  async runRemoteExecution(run, registered) {
7074
7357
  if (run.status === "cancelling") {
7358
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
7075
7359
  const error2 = {
7076
7360
  message: "cancelled"
7077
7361
  };
@@ -7080,6 +7364,7 @@ ${run.workflowVersion}`;
7080
7364
  error: error2,
7081
7365
  updatedAt: /* @__PURE__ */ new Date()
7082
7366
  });
7367
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
7083
7368
  this.emit({
7084
7369
  type: "run.failed",
7085
7370
  runId: run.id,
@@ -7126,6 +7411,7 @@ ${run.workflowVersion}`;
7126
7411
  }
7127
7412
  const history = await this.remoteHistory(run.id, checkpoints);
7128
7413
  if (remote.executor.dispatch) {
7414
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
7129
7415
  const taskId = `${run.id}:wf:${globalThis.crypto.randomUUID().slice(0, 8)}`;
7130
7416
  const silenceMs = this.remoteAdvanceSilenceMs;
7131
7417
  const wakeAt = silenceMs != null ? this.clock() + silenceMs : void 0;
@@ -7141,6 +7427,7 @@ ${run.workflowVersion}`;
7141
7427
  workflow: run.workflow,
7142
7428
  namespace: run.namespace
7143
7429
  });
7430
+ this.handOffLease(run.id);
7144
7431
  await this.store.releaseRunLock(run.id);
7145
7432
  await remote.executor.dispatch(run, history, taskId);
7146
7433
  return {
@@ -7169,6 +7456,7 @@ ${run.workflowVersion}`;
7169
7456
  message: err instanceof Error ? err.message : String(err),
7170
7457
  stack: err instanceof Error ? err.stack : void 0
7171
7458
  };
7459
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
7172
7460
  await this.store.updateRun(run.id, {
7173
7461
  status: "failed",
7174
7462
  error: error2,
@@ -7197,6 +7485,7 @@ ${run.workflowVersion}`;
7197
7485
  * — the caller has already ensured this is the decision for the currently-awaited turn.
7198
7486
  */
7199
7487
  async applyDecision(run, decision) {
7488
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
7200
7489
  if (decision.status === "completed") {
7201
7490
  await this.applyCommands(run, decision.commands);
7202
7491
  return this.settleRun(run, {
@@ -7666,6 +7955,7 @@ ${run.workflowVersion}`;
7666
7955
  * cancel flag stays too, re-derived from the status on any later re-drive regardless.
7667
7956
  */
7668
7957
  async compensateAndCancel(run, compensations, replay) {
7958
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
7669
7959
  const pending = await this.unwindCompensations(run, compensations, replay);
7670
7960
  if (pending) return {
7671
7961
  runId: run.id,
@@ -7757,6 +8047,7 @@ ${run.workflowVersion}`;
7757
8047
  });
7758
8048
  } catch (err) {
7759
8049
  if (err instanceof ContinueAsNew) {
8050
+ if (this.isFenced(run.id)) return await this.echoCurrentStatus(run.id);
7760
8051
  if (this.cancelRequested.has(run.id)) {
7761
8052
  return await this.compensateAndCancel(run, compensations, replay);
7762
8053
  }
@@ -8208,6 +8499,7 @@ ${run.workflowVersion}`;
8208
8499
  * own `finally` also releases (the run-execution path) is safe.
8209
8500
  */
8210
8501
  async parkBlocked(run, blocked) {
8502
+ if (this.isFenced(run.id)) return this.echoCurrentStatus(run.id);
8211
8503
  const error2 = {
8212
8504
  message: blocked.reason,
8213
8505
  code: blocked.code,
@@ -8794,10 +9086,7 @@ ${run.workflowVersion}`;
8794
9086
  if (!await this.store.tryLockRun(decision.runId, this.instanceId, nowMs + this.leaseMs, nowMs)) {
8795
9087
  return;
8796
9088
  }
8797
- const renew = setInterval(() => {
8798
- void this.store.renewRunLock(decision.runId, this.instanceId, this.clock() + this.leaseMs).catch(() => void 0);
8799
- }, Math.max(50, Math.floor(this.leaseMs / 2)));
8800
- renew.unref?.();
9089
+ const held = this.holdExecutionLease(decision.runId);
8801
9090
  try {
8802
9091
  const fresh = await this.store.getRun(decision.runId);
8803
9092
  if (!fresh || fresh.awaitingDecisionTaskId !== decision.taskId) return;
@@ -8812,8 +9101,9 @@ ${run.workflowVersion}`;
8812
9101
  fresh.awaitingDecisionTaskId = void 0;
8813
9102
  await this.applyDecision(fresh, decision);
8814
9103
  } finally {
8815
- clearInterval(renew);
8816
- await this.store.releaseRunLock(decision.runId);
9104
+ const fenced = held.lease.lost;
9105
+ held.stop();
9106
+ if (!fenced) await this.store.releaseRunLock(decision.runId);
8817
9107
  }
8818
9108
  }
8819
9109
  /** Earliest clock time a suspended turn's deadline is worth LOOKING at again (`runId` → clock ms) —
@@ -9009,6 +9299,9 @@ function childIdsOf(checkpoints) {
9009
9299
  }
9010
9300
  __name(childIdsOf, "childIdsOf");
9011
9301
 
9302
+ // src/run-history.ts
9303
+ var RUN_HISTORY_FIXTURE_VERSION = 1;
9304
+
9012
9305
  // src/run-facets.ts
9013
9306
  function facetOrigin(origin) {
9014
9307
  return origin?.trim() ? origin : null;
@@ -9562,13 +9855,14 @@ var CodecStateStore = class {
9562
9855
  }
9563
9856
  inner;
9564
9857
  codec;
9858
+ extended;
9565
9859
  /** Atomic claims touch only searchable metadata and bypass payload encoding. */
9566
9860
  tryAdmitSingleton;
9567
9861
  /**
9568
9862
  * Forwarded verbatim — a facet cell counts `status`/`origin`, neither of which the codec touches
9569
- * (it encodes payloads: input/output/error). Bound as a PROPERTY, and only when the inner store
9570
- * actually aggregates, so `store.runFacets?.()` still reports absence through the wrapper instead
9571
- * of resolving to a silently wrong empty result.
9863
+ * (it encodes payloads: input/output, plus events/error under `extended` coverage). Bound as a
9864
+ * PROPERTY, and only when the inner store actually aggregates, so `store.runFacets?.()` still
9865
+ * reports absence through the wrapper instead of resolving to a silently wrong empty result.
9572
9866
  */
9573
9867
  runFacets;
9574
9868
  /**
@@ -9620,9 +9914,10 @@ var CodecStateStore = class {
9620
9914
  updateSchedule;
9621
9915
  deleteSchedule;
9622
9916
  listSchedules;
9623
- constructor(inner, codec) {
9917
+ constructor(inner, codec, options = {}) {
9624
9918
  this.inner = inner;
9625
9919
  this.codec = codec;
9920
+ this.extended = options.coverage === "extended";
9626
9921
  if (inner.tryAdmitSingleton) this.tryAdmitSingleton = inner.tryAdmitSingleton.bind(inner);
9627
9922
  const deleteRuns = inner.deleteRuns;
9628
9923
  if (deleteRuns) this.deleteRuns = (ids) => deleteRuns.call(inner, ids);
@@ -9680,32 +9975,52 @@ var CodecStateStore = class {
9680
9975
  return v === void 0 ? void 0 : this.codec.decode(v);
9681
9976
  }
9682
9977
  encRun(run) {
9683
- return {
9978
+ const next = {
9684
9979
  ...run,
9685
9980
  input: this.enc(run.input),
9686
9981
  output: this.enc(run.output)
9687
9982
  };
9983
+ if (this.extended && run.error !== void 0) {
9984
+ next.error = this.enc(run.error);
9985
+ }
9986
+ return next;
9688
9987
  }
9689
9988
  decRun(run) {
9690
- return run && {
9989
+ if (!run) return run;
9990
+ const next = {
9691
9991
  ...run,
9692
9992
  input: this.dec(run.input),
9693
9993
  output: this.dec(run.output)
9694
9994
  };
9995
+ if (this.extended && run.error !== void 0) {
9996
+ next.error = this.dec(run.error);
9997
+ }
9998
+ return next;
9695
9999
  }
9696
10000
  encCp(cp) {
9697
- return {
10001
+ const next = {
9698
10002
  ...cp,
9699
10003
  input: this.enc(cp.input),
9700
10004
  output: this.enc(cp.output)
9701
10005
  };
10006
+ if (this.extended) {
10007
+ if (cp.events !== void 0) next.events = this.enc(cp.events);
10008
+ if (cp.error !== void 0) next.error = this.enc(cp.error);
10009
+ }
10010
+ return next;
9702
10011
  }
9703
10012
  decCp(cp) {
9704
- return cp && {
10013
+ if (!cp) return cp;
10014
+ const next = {
9705
10015
  ...cp,
9706
10016
  input: this.dec(cp.input),
9707
10017
  output: this.dec(cp.output)
9708
10018
  };
10019
+ if (this.extended) {
10020
+ if (cp.events !== void 0) next.events = this.dec(cp.events);
10021
+ if (cp.error !== void 0) next.error = this.dec(cp.error);
10022
+ }
10023
+ return next;
9709
10024
  }
9710
10025
  encSpec(spec) {
9711
10026
  return "input" in spec ? {
@@ -9744,6 +10059,9 @@ var CodecStateStore = class {
9744
10059
  };
9745
10060
  if ("input" in patch) next.input = this.enc(patch.input);
9746
10061
  if ("output" in patch) next.output = this.enc(patch.output);
10062
+ if (this.extended && patch.error !== void 0) {
10063
+ next.error = this.enc(patch.error);
10064
+ }
9747
10065
  return next;
9748
10066
  }
9749
10067
  async getRun(runId) {
@@ -9779,8 +10097,8 @@ var CodecStateStore = class {
9779
10097
  tryLockRun(runId, owner, leaseUntilMs, nowMs) {
9780
10098
  return this.inner.tryLockRun(runId, owner, leaseUntilMs, nowMs);
9781
10099
  }
9782
- releaseRunLock(runId) {
9783
- return this.inner.releaseRunLock(runId);
10100
+ releaseRunLock(runId, owner) {
10101
+ return owner === void 0 ? this.inner.releaseRunLock(runId) : this.inner.releaseRunLock(runId, owner);
9784
10102
  }
9785
10103
  renewRunLock(runId, owner, leaseUntilMs) {
9786
10104
  return this.inner.renewRunLock(runId, owner, leaseUntilMs);
@@ -10288,9 +10606,9 @@ var InMemoryStateStore = class {
10288
10606
  run.lockedUntil = leaseUntilMs;
10289
10607
  return true;
10290
10608
  }
10291
- async releaseRunLock(runId) {
10609
+ async releaseRunLock(runId, owner) {
10292
10610
  const run = this.runs.get(runId);
10293
- if (run) {
10611
+ if (run && (owner === void 0 || run.lockedBy === owner)) {
10294
10612
  run.lockedBy = void 0;
10295
10613
  run.lockedUntil = void 0;
10296
10614
  }
@@ -10641,6 +10959,7 @@ var InMemoryTransport = class {
10641
10959
  ContinueAsNew,
10642
10960
  DEFAULT_CONCURRENCY_STATUSES,
10643
10961
  DEFAULT_PARTITION,
10962
+ DEFAULT_STALLED_AFTER_MS,
10644
10963
  DURABLE_OPTIONS,
10645
10964
  DURABLE_OPTIONS_CANONICAL,
10646
10965
  DURABLE_STEP_CONFIG,
@@ -10664,6 +10983,7 @@ var InMemoryTransport = class {
10664
10983
  NestedWorkflowCallError,
10665
10984
  NonDeterminismError,
10666
10985
  QueueController,
10986
+ RUN_HISTORY_FIXTURE_VERSION,
10667
10987
  RUN_VALUE_FACET_LIMIT,
10668
10988
  RUN_VALUE_FACET_SCAN,
10669
10989
  RemoteStepError,
@@ -10681,6 +11001,7 @@ var InMemoryTransport = class {
10681
11001
  ScheduleAlreadyExistsError,
10682
11002
  ScheduleClient,
10683
11003
  ScheduleNotFoundError,
11004
+ SchemaValidationError,
10684
11005
  SignalTimeoutError,
10685
11006
  SingletonQueueFullError,
10686
11007
  TERMINAL_RUN_STATUSES,
@@ -10737,6 +11058,7 @@ var InMemoryTransport = class {
10737
11058
  facetOrigin,
10738
11059
  fanPageBounds,
10739
11060
  fanPageEtag,
11061
+ findStalledRuns,
10740
11062
  foldedCount,
10741
11063
  handoffHandlerId,
10742
11064
  handoffName,
@@ -10791,6 +11113,7 @@ var InMemoryTransport = class {
10791
11113
  runStepHandler,
10792
11114
  runValueFacetsFromRuns,
10793
11115
  sanitizeQueueToken,
11116
+ scheduleRunIdAt,
10794
11117
  scheduledRunId,
10795
11118
  singletonAdmissionDenialPatch,
10796
11119
  singletonAdmissionTags,