@dudousxd/nestjs-durable-core 0.72.1 → 0.73.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
@@ -56,6 +56,8 @@ __export(index_exports, {
56
56
  RemoteWorkflowExecutor: () => RemoteWorkflowExecutor,
57
57
  RemoteWorkflowTimeout: () => RemoteWorkflowTimeout,
58
58
  RunGateway: () => RunGateway,
59
+ SINGLETON_ACTIVE_STATUSES: () => SINGLETON_ACTIVE_STATUSES,
60
+ SINGLETON_ADMITTED_TAG: () => SINGLETON_ADMITTED_TAG,
59
61
  STATE_STORE: () => STATE_STORE,
60
62
  STATE_STORE_CANONICAL: () => STATE_STORE_CANONICAL,
61
63
  ScheduleAlreadyExistsError: () => ScheduleAlreadyExistsError,
@@ -86,6 +88,7 @@ __export(index_exports, {
86
88
  capableWorkers: () => capableWorkers,
87
89
  classifyWaiterToken: () => classifyWaiterToken,
88
90
  clearDurableExecutionWrappers: () => clearDurableExecutionWrappers,
91
+ clearTerminalSingletonAdmission: () => clearTerminalSingletonAdmission,
89
92
  collectMetrics: () => collectMetrics,
90
93
  concurrencyTag: () => concurrencyTag,
91
94
  controlPlaneDescriptor: () => controlPlaneDescriptor,
@@ -143,6 +146,8 @@ __export(index_exports, {
143
146
  runValueFacetsFromRuns: () => runValueFacetsFromRuns,
144
147
  sanitizeQueueToken: () => sanitizeQueueToken,
145
148
  scheduledRunId: () => scheduledRunId,
149
+ singletonAdmissionDenialPatch: () => singletonAdmissionDenialPatch,
150
+ singletonAdmissionTags: () => singletonAdmissionTags,
146
151
  sqlComparator: () => sqlComparator,
147
152
  stepConfigOf: () => stepConfigOf,
148
153
  stepId: () => stepId,
@@ -2444,6 +2449,54 @@ function errorMessage(error2) {
2444
2449
  }
2445
2450
  __name(errorMessage, "errorMessage");
2446
2451
 
2452
+ // src/singleton-admission.ts
2453
+ var SINGLETON_ADMITTED_TAG = "durable:singleton:admitted";
2454
+ var SINGLETON_ACTIVE_STATUSES = [
2455
+ "pending",
2456
+ "running",
2457
+ "suspended",
2458
+ "blocked",
2459
+ "cancelling"
2460
+ ];
2461
+ function singletonAdmissionTags(runs, runId, tag, workflow, limit) {
2462
+ if (!Number.isInteger(limit) || limit < 1) throw new Error("singleton limit must be a positive integer");
2463
+ const active = runs.filter((run) => run.workflow === workflow && SINGLETON_ACTIVE_STATUSES.includes(run.status) && run.tags?.includes(tag));
2464
+ const candidate = active.find((run) => run.id === runId);
2465
+ if (!candidate) return null;
2466
+ if (candidate.tags?.includes(SINGLETON_ADMITTED_TAG)) return candidate.tags;
2467
+ if (candidate.status === "cancelling") return null;
2468
+ const holders = active.filter((run) => run.tags?.includes(SINGLETON_ADMITTED_TAG));
2469
+ const remaining = limit - holders.length;
2470
+ if (remaining <= 0) return null;
2471
+ const waiters = active.filter((run) => !run.tags?.includes(SINGLETON_ADMITTED_TAG)).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id));
2472
+ if (!waiters.slice(0, remaining).some((run) => run.id === runId)) return null;
2473
+ return [
2474
+ ...candidate.tags ?? [],
2475
+ SINGLETON_ADMITTED_TAG
2476
+ ];
2477
+ }
2478
+ __name(singletonAdmissionTags, "singletonAdmissionTags");
2479
+ async function clearTerminalSingletonAdmission(runId, patch, getRun) {
2480
+ if (!patch.status || !TERMINAL_RUN_STATUSES.includes(patch.status)) return patch;
2481
+ const tags = "tags" in patch ? patch.tags : (await getRun(runId))?.tags;
2482
+ return {
2483
+ ...patch,
2484
+ tags: tags?.filter((tag) => tag !== SINGLETON_ADMITTED_TAG)
2485
+ };
2486
+ }
2487
+ __name(clearTerminalSingletonAdmission, "clearTerminalSingletonAdmission");
2488
+ function singletonAdmissionDenialPatch(runs, runId, tag, workflow, retryWakeAt) {
2489
+ if (retryWakeAt === void 0) return void 0;
2490
+ const candidate = runs.find((run) => run.id === runId && run.workflow === workflow && run.tags?.includes(tag) && SINGLETON_ACTIVE_STATUSES.includes(run.status));
2491
+ if (!candidate || candidate.status === "cancelling") return void 0;
2492
+ return {
2493
+ status: "suspended",
2494
+ wakeAt: retryWakeAt,
2495
+ updatedAt: /* @__PURE__ */ new Date()
2496
+ };
2497
+ }
2498
+ __name(singletonAdmissionDenialPatch, "singletonAdmissionDenialPatch");
2499
+
2447
2500
  // src/singleton-gate.ts
2448
2501
  var SINGLETON_RETRY_MS = 1e3;
2449
2502
  var SINGLETON_RETRY_JITTER_MS = 250;
@@ -2457,6 +2510,9 @@ var SingletonGate = class {
2457
2510
  }
2458
2511
  /** The tag a singleton run carries, so the gate can find others sharing its key. */
2459
2512
  tag(cfg, input) {
2513
+ if (!this.deps.store.tryAdmitSingleton) {
2514
+ throw new Error("Singleton workflows require StateStore.tryAdmitSingleton for atomic durable admission");
2515
+ }
2460
2516
  return `singleton:${cfg.key(input)}`;
2461
2517
  }
2462
2518
  /** Next wake time for a gated run: the base retry delay jittered to avoid a wakeup stampede. */
@@ -2465,7 +2521,7 @@ var SingletonGate = class {
2465
2521
  }
2466
2522
  /**
2467
2523
  * Reject a start that would grow the same-key backlog past `limit + maxQueueDepth` (counting
2468
- * `pending`/`running`/`suspended` runs sharing the key in one scan). No-op when no `maxQueueDepth`
2524
+ * `pending`/`running`/`suspended`/`blocked`/`cancelling` runs sharing the key in one scan). No-op when no `maxQueueDepth`
2469
2525
  * is configured.
2470
2526
  */
2471
2527
  async assertCapacity(workflow, cfg, input) {
@@ -2474,34 +2530,25 @@ var SingletonGate = class {
2474
2530
  const queued = await this.deps.store.listRuns({
2475
2531
  tag: this.tag(cfg, input),
2476
2532
  workflow,
2477
- statuses: [
2478
- "pending",
2479
- "running",
2480
- "suspended",
2481
- "cancelling"
2482
- ]
2533
+ statuses: SINGLETON_ACTIVE_STATUSES
2483
2534
  });
2484
2535
  if (queued.length >= cap) {
2485
2536
  throw new SingletonQueueFullError(workflow, cfg.key(input), cfg.maxQueueDepth);
2486
2537
  }
2487
2538
  }
2488
- /**
2489
- * Whether `run` may run now under its key: it's among the `limit` oldest in-flight (running or
2490
- * suspended) runs sharing the key, by `(createdAt, id)` order. A consistent store gives every
2491
- * instance the same ordering, so admission is race-free + FIFO.
2492
- */
2539
+ /** Preserve durable holders and atomically claim one of the remaining slots. */
2493
2540
  async admit(run, cfg) {
2494
- const inflight = (await this.deps.store.listRuns({
2495
- tag: this.tag(cfg, run.input),
2496
- workflow: run.workflow,
2497
- statuses: [
2498
- "running",
2499
- "suspended",
2500
- "cancelling"
2501
- ]
2502
- })).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id));
2503
- const idx = inflight.findIndex((r) => r.id === run.id);
2504
- return idx >= 0 && idx < (cfg.limit ?? 1);
2541
+ if (!this.deps.store.tryAdmitSingleton) {
2542
+ throw new Error("Singleton workflows require StateStore.tryAdmitSingleton for atomic durable admission");
2543
+ }
2544
+ const admitted = await this.deps.store.tryAdmitSingleton(run.id, this.tag(cfg, run.input), run.workflow, cfg.limit ?? 1, this.retryWakeAt());
2545
+ if (admitted && !run.tags?.includes(SINGLETON_ADMITTED_TAG)) {
2546
+ run.tags = [
2547
+ ...run.tags ?? [],
2548
+ SINGLETON_ADMITTED_TAG
2549
+ ];
2550
+ }
2551
+ return admitted;
2505
2552
  }
2506
2553
  /**
2507
2554
  * Notify-on-release: a singleton run settled (freeing a slot), so dispatch the oldest gated
@@ -4018,7 +4065,7 @@ var WorkflowEngine = class {
4018
4065
  VERSION_UNDECLARED_TAG
4019
4066
  ] : []
4020
4067
  ];
4021
- const tags = mergeTags(registered.tags, stampedTags);
4068
+ const tags = mergeTags(registered.tags, stampedTags)?.filter((tag) => tag !== SINGLETON_ADMITTED_TAG);
4022
4069
  if (registered.singleton) {
4023
4070
  await this.singletons.assertCapacity(name, registered.singleton, input);
4024
4071
  }
@@ -4093,6 +4140,12 @@ var WorkflowEngine = class {
4093
4140
  await this.store.releaseRunLock(runId);
4094
4141
  throw new NamespaceMismatch();
4095
4142
  }
4143
+ if (run.status === "failed" && run.tags?.includes(SINGLETON_ADMITTED_TAG)) {
4144
+ run.tags = run.tags.filter((tag) => tag !== SINGLETON_ADMITTED_TAG);
4145
+ await this.store.updateRun(run.id, {
4146
+ tags: run.tags
4147
+ });
4148
+ }
4096
4149
  const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run) ?? await this.resolveRemoteByConvention(run);
4097
4150
  if (!registered) {
4098
4151
  throw new Error(`workflow ${run.workflow}@${run.workflowVersion} is not registered \u2014 keep the prior version deployed so in-flight runs can drain (skew protection)`);
@@ -4242,7 +4295,10 @@ var WorkflowEngine = class {
4242
4295
  /** Track an in-flight execution so {@link drain} can wait for it. */
4243
4296
  track(p) {
4244
4297
  this.inflight.add(p);
4245
- void p.finally(() => this.inflight.delete(p));
4298
+ const done = /* @__PURE__ */ __name(() => {
4299
+ this.inflight.delete(p);
4300
+ }, "done");
4301
+ void p.then(done, done);
4246
4302
  return p;
4247
4303
  }
4248
4304
  /**
@@ -4593,7 +4649,8 @@ var WorkflowEngine = class {
4593
4649
  status: "pending",
4594
4650
  error: void 0,
4595
4651
  ...resurrecting ? {
4596
- recoveryAttempts: 0
4652
+ recoveryAttempts: 0,
4653
+ tags: run.tags?.filter((tag) => tag !== SINGLETON_ADMITTED_TAG)
4597
4654
  } : {},
4598
4655
  updatedAt: /* @__PURE__ */ new Date()
4599
4656
  });
@@ -4712,10 +4769,19 @@ var WorkflowEngine = class {
4712
4769
  off();
4713
4770
  resolve(toResult(run));
4714
4771
  }, "finish");
4772
+ const fail = /* @__PURE__ */ __name((error2) => {
4773
+ if (done) return;
4774
+ done = true;
4775
+ if (timer) clearTimeout(timer);
4776
+ if (poller) clearInterval(poller);
4777
+ off();
4778
+ reject(error2);
4779
+ }, "fail");
4715
4780
  const check = /* @__PURE__ */ __name(() => {
4781
+ if (done) return;
4716
4782
  void this.store.getRun(runId).then((run) => {
4717
4783
  if (run && isSettled(run)) finish(run);
4718
- });
4784
+ }, fail);
4719
4785
  }, "check");
4720
4786
  const poller = opts?.until === "terminal" ? (() => {
4721
4787
  const interval = setInterval(check, 250);
@@ -5498,6 +5564,26 @@ var WorkflowEngine = class {
5498
5564
  durationMs: Date.now() - step.startedAt.getTime()
5499
5565
  });
5500
5566
  }
5567
+ /** Admission persists the wait atomically; never overwrite a cancellation after it returns. */
5568
+ async singletonWaitResult(run) {
5569
+ const current = await this.store.getRun(run.id);
5570
+ await this.store.releaseRunLock(run.id);
5571
+ if (!current) throw new Error(`run ${run.id} was deleted during singleton admission`);
5572
+ if (current.status === "suspended") {
5573
+ this.emit({
5574
+ type: "run.suspended",
5575
+ runId: current.id,
5576
+ workflow: current.workflow,
5577
+ namespace: current.namespace
5578
+ });
5579
+ }
5580
+ return {
5581
+ runId: current.id,
5582
+ status: current.status,
5583
+ output: current.output,
5584
+ error: current.error
5585
+ };
5586
+ }
5501
5587
  async execute(run, fn) {
5502
5588
  const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? await this.findInheritedRegistration(run) ?? await this.resolveRemoteByConvention(run);
5503
5589
  const lockNow = this.clock();
@@ -5667,23 +5753,7 @@ var WorkflowEngine = class {
5667
5753
  });
5668
5754
  }
5669
5755
  if (registered.singleton && !await this.singletons.admit(run, registered.singleton)) {
5670
- const wakeAt = this.singletons.retryWakeAt();
5671
- await this.store.updateRun(run.id, {
5672
- status: "suspended",
5673
- wakeAt,
5674
- updatedAt: /* @__PURE__ */ new Date()
5675
- });
5676
- this.emit({
5677
- type: "run.suspended",
5678
- runId: run.id,
5679
- workflow: run.workflow,
5680
- namespace: run.namespace
5681
- });
5682
- await this.store.releaseRunLock(run.id);
5683
- return {
5684
- runId: run.id,
5685
- status: "suspended"
5686
- };
5756
+ return this.singletonWaitResult(run);
5687
5757
  }
5688
5758
  const wfPlan = await this.planRoute(tenantGroup(sanitizeQueueToken(remote.group), void 0), registered.requires);
5689
5759
  if (wfPlan?.status === "blocked") return this.parkBlocked(run, wfPlan);
@@ -6297,23 +6367,7 @@ var WorkflowEngine = class {
6297
6367
  });
6298
6368
  }
6299
6369
  if (run.status !== "cancelling" && registered?.singleton && !await this.singletons.admit(run, registered.singleton)) {
6300
- const wakeAt = this.singletons.retryWakeAt();
6301
- await this.store.updateRun(run.id, {
6302
- status: "suspended",
6303
- wakeAt,
6304
- updatedAt: /* @__PURE__ */ new Date()
6305
- });
6306
- this.emit({
6307
- type: "run.suspended",
6308
- runId: run.id,
6309
- workflow: run.workflow,
6310
- namespace: run.namespace
6311
- });
6312
- await this.store.releaseRunLock(run.id);
6313
- return {
6314
- runId: run.id,
6315
- status: "suspended"
6316
- };
6370
+ return this.singletonWaitResult(run);
6317
6371
  }
6318
6372
  const compensations = [];
6319
6373
  const snapshot = await this.store.listCheckpoints(run.id);
@@ -7332,6 +7386,8 @@ var CodecStateStore = class {
7332
7386
  }
7333
7387
  inner;
7334
7388
  codec;
7389
+ /** Atomic claims touch only searchable metadata and bypass payload encoding. */
7390
+ tryAdmitSingleton;
7335
7391
  /**
7336
7392
  * Forwarded verbatim — a facet cell counts `status`/`origin`, neither of which the codec touches
7337
7393
  * (it encodes payloads: input/output/error). Bound as a PROPERTY, and only when the inner store
@@ -7364,6 +7420,7 @@ var CodecStateStore = class {
7364
7420
  constructor(inner, codec) {
7365
7421
  this.inner = inner;
7366
7422
  this.codec = codec;
7423
+ if (inner.tryAdmitSingleton) this.tryAdmitSingleton = inner.tryAdmitSingleton.bind(inner);
7367
7424
  const deleteRuns = inner.deleteRuns;
7368
7425
  if (deleteRuns) this.deleteRuns = (ids) => deleteRuns.call(inner, ids);
7369
7426
  const prune = inner.pruneTerminalRuns;
@@ -7816,6 +7873,28 @@ var InMemoryStateStore = class {
7816
7873
  byRun.set(runId, row);
7817
7874
  }
7818
7875
  }
7876
+ async tryAdmitSingleton(runId, tag, workflow, limit, retryWakeAt) {
7877
+ const runs = [
7878
+ ...this.runs.values()
7879
+ ];
7880
+ const tags = singletonAdmissionTags(runs, runId, tag, workflow, limit);
7881
+ if (!tags) {
7882
+ const patch = singletonAdmissionDenialPatch(runs, runId, tag, workflow, retryWakeAt);
7883
+ const candidate = this.runs.get(runId);
7884
+ if (patch && candidate) this.runs.set(runId, {
7885
+ ...candidate,
7886
+ ...patch
7887
+ });
7888
+ return false;
7889
+ }
7890
+ const run = this.runs.get(runId);
7891
+ if (!run) return false;
7892
+ this.runs.set(runId, {
7893
+ ...run,
7894
+ tags
7895
+ });
7896
+ return true;
7897
+ }
7819
7898
  async createRun(run) {
7820
7899
  this.runs.set(run.id, {
7821
7900
  ...run,
@@ -7823,7 +7902,11 @@ var InMemoryStateStore = class {
7823
7902
  });
7824
7903
  this.reindexAttributes(run.id, run.searchAttributes);
7825
7904
  }
7826
- async updateRun(runId, patch) {
7905
+ async updateRun(runId, inputPatch) {
7906
+ const patch = inputPatch.status && TERMINAL_RUN_STATUSES.includes(inputPatch.status) ? {
7907
+ ...inputPatch,
7908
+ tags: ("tags" in inputPatch ? inputPatch.tags : this.runs.get(runId)?.tags)?.filter((tag) => tag !== SINGLETON_ADMITTED_TAG)
7909
+ } : inputPatch;
7827
7910
  const existing = this.runs.get(runId);
7828
7911
  if (!existing) throw new Error(`run ${runId} not found`);
7829
7912
  const next = {
@@ -8248,6 +8331,8 @@ var InMemoryTransport = class {
8248
8331
  RemoteWorkflowExecutor,
8249
8332
  RemoteWorkflowTimeout,
8250
8333
  RunGateway,
8334
+ SINGLETON_ACTIVE_STATUSES,
8335
+ SINGLETON_ADMITTED_TAG,
8251
8336
  STATE_STORE,
8252
8337
  STATE_STORE_CANONICAL,
8253
8338
  ScheduleAlreadyExistsError,
@@ -8278,6 +8363,7 @@ var InMemoryTransport = class {
8278
8363
  capableWorkers,
8279
8364
  classifyWaiterToken,
8280
8365
  clearDurableExecutionWrappers,
8366
+ clearTerminalSingletonAdmission,
8281
8367
  collectMetrics,
8282
8368
  concurrencyTag,
8283
8369
  controlPlaneDescriptor,
@@ -8335,6 +8421,8 @@ var InMemoryTransport = class {
8335
8421
  runValueFacetsFromRuns,
8336
8422
  sanitizeQueueToken,
8337
8423
  scheduledRunId,
8424
+ singletonAdmissionDenialPatch,
8425
+ singletonAdmissionTags,
8338
8426
  sqlComparator,
8339
8427
  stepConfigOf,
8340
8428
  stepId,