@dudousxd/nestjs-durable-core 0.30.0 → 0.31.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
@@ -28,6 +28,7 @@ __export(index_exports, {
28
28
  Entities: () => Entities,
29
29
  EventAccumulators: () => EventAccumulators,
30
30
  FatalError: () => FatalError,
31
+ InMemoryAdmissionBackend: () => InMemoryAdmissionBackend,
31
32
  InMemoryStateStore: () => InMemoryStateStore,
32
33
  InMemoryTransport: () => InMemoryTransport,
33
34
  NonDeterminismError: () => NonDeterminismError,
@@ -65,6 +66,161 @@ __export(index_exports, {
65
66
  });
66
67
  module.exports = __toCommonJS(index_exports);
67
68
 
69
+ // src/queue.ts
70
+ var QueueController = class {
71
+ static {
72
+ __name(this, "QueueController");
73
+ }
74
+ config;
75
+ clock;
76
+ inFlight = 0;
77
+ windowStart = 0;
78
+ windowCount = 0;
79
+ /** Registered waiters by id (only used when priority/fairness ordering is in play). */
80
+ waiters = /* @__PURE__ */ new Map();
81
+ /** Monotonic counter stamping each newly-seen waiter for a stable FIFO tiebreak. */
82
+ waiterSeq = 0;
83
+ /** Per-key "last served" order for round-robin fairness — higher = more recently served. */
84
+ keyServed = /* @__PURE__ */ new Map();
85
+ servedTick = 0;
86
+ constructor(config, clock) {
87
+ this.config = config;
88
+ this.clock = clock;
89
+ }
90
+ /** Whether this controller does any priority/fairness ordering (else it's a plain FIFO gate). */
91
+ get ordered() {
92
+ return this.config.fairness === "key" || this.config.order === "lifo";
93
+ }
94
+ /**
95
+ * Try to admit one unit of work. On `ok`, the caller has taken a concurrency slot and must call
96
+ * {@link release} when the step settles. On block, `retryAt` is when admission may next succeed.
97
+ *
98
+ * `item` carries optional priority/fairness hints; omitting it is the legacy FIFO path. When
99
+ * ordering is configured (or a priority is given), a blocked call REGISTERS as a waiter and is only
100
+ * admitted on a later retry once it is the rightful next under (priority desc, fairness, FIFO).
101
+ */
102
+ tryAdmit(item) {
103
+ const now = this.clock();
104
+ const rl = this.config.rateLimit;
105
+ if (rl) {
106
+ if (now - this.windowStart >= rl.periodMs) {
107
+ this.windowStart = now;
108
+ this.windowCount = 0;
109
+ }
110
+ if (this.windowCount >= rl.limit) return {
111
+ ok: false,
112
+ retryAt: this.windowStart + rl.periodMs
113
+ };
114
+ }
115
+ const usingOrder = this.ordered || item?.priority != null || item?.waiterId != null;
116
+ const waiterId = item?.waiterId;
117
+ const full = this.config.concurrency != null && this.inFlight >= this.config.concurrency;
118
+ if (full) {
119
+ if (usingOrder && waiterId != null) this.register(waiterId, item);
120
+ return {
121
+ ok: false,
122
+ retryAt: now + (this.config.retryMs ?? 1e3)
123
+ };
124
+ }
125
+ if (usingOrder && this.waiters.size > 0) {
126
+ if (waiterId != null) this.register(waiterId, item);
127
+ if (!this.isNext(waiterId, item)) {
128
+ return {
129
+ ok: false,
130
+ retryAt: now + (this.config.retryMs ?? 1e3)
131
+ };
132
+ }
133
+ }
134
+ if (rl) this.windowCount += 1;
135
+ this.inFlight += 1;
136
+ if (waiterId != null) this.waiters.delete(waiterId);
137
+ if (item?.key != null) this.keyServed.set(item.key, ++this.servedTick);
138
+ return {
139
+ ok: true
140
+ };
141
+ }
142
+ /** Release a concurrency slot taken by a successful {@link tryAdmit}. */
143
+ release() {
144
+ if (this.inFlight > 0) this.inFlight -= 1;
145
+ }
146
+ /** Track (or refresh) a blocked call as a waiter; its FIFO seq is stamped only on first sight. */
147
+ register(waiterId, item) {
148
+ const existing = this.waiters.get(waiterId);
149
+ if (existing) {
150
+ existing.priority = item?.priority ?? 0;
151
+ existing.key = item?.key;
152
+ return;
153
+ }
154
+ this.waiters.set(waiterId, {
155
+ priority: item?.priority ?? 0,
156
+ key: item?.key,
157
+ seq: this.waiterSeq++
158
+ });
159
+ }
160
+ /**
161
+ * Is the calling item the best candidate among all registered waiters? Ordering, best first:
162
+ * 1. higher `priority`;
163
+ * 2. fairness: with `fairness: 'key'`, the least-recently-served key wins (round-robin);
164
+ * 3. earliest registration (FIFO) — stable tiebreak.
165
+ * A bare item (no waiterId) is compared on its hints directly.
166
+ */
167
+ isNext(waiterId, item) {
168
+ const self = (waiterId != null ? this.waiters.get(waiterId) : void 0) ?? {
169
+ priority: item?.priority ?? 0,
170
+ key: item?.key,
171
+ seq: Number.POSITIVE_INFINITY
172
+ };
173
+ let best = self;
174
+ for (const w of this.waiters.values()) {
175
+ if (this.better(w, best)) best = w;
176
+ }
177
+ return best === self;
178
+ }
179
+ /** Strict "a should be admitted before b" comparison under the active policy. */
180
+ better(a, b) {
181
+ if (a.priority !== b.priority) return a.priority > b.priority;
182
+ if (this.config.fairness === "key") {
183
+ const sa = this.servedOf(a.key);
184
+ const sb = this.servedOf(b.key);
185
+ if (sa !== sb) return sa < sb;
186
+ }
187
+ return this.config.order === "lifo" ? a.seq > b.seq : a.seq < b.seq;
188
+ }
189
+ /** Last-served tick for a key (0 = never served, so an unseen key is admitted before a busy one). */
190
+ servedOf(key) {
191
+ if (key == null) return 0;
192
+ return this.keyServed.get(key) ?? 0;
193
+ }
194
+ };
195
+
196
+ // src/admission.ts
197
+ var InMemoryAdmissionBackend = class {
198
+ static {
199
+ __name(this, "InMemoryAdmissionBackend");
200
+ }
201
+ clock;
202
+ controllers = /* @__PURE__ */ new Map();
203
+ constructor(clock) {
204
+ this.clock = clock;
205
+ }
206
+ register(config) {
207
+ this.controllers.set(config.name, new QueueController(config, this.clock));
208
+ }
209
+ handles(queue) {
210
+ return this.controllers.has(queue);
211
+ }
212
+ async tryAdmit(queue, item) {
213
+ const controller = this.controllers.get(queue);
214
+ if (!controller) return {
215
+ ok: true
216
+ };
217
+ return controller.tryAdmit(item);
218
+ }
219
+ async release(queue, _slotId) {
220
+ this.controllers.get(queue)?.release();
221
+ }
222
+ };
223
+
68
224
  // src/duration.ts
69
225
  var UNIT_MS = {
70
226
  ms: 1,
@@ -581,133 +737,6 @@ function eventMatches(payload, match) {
581
737
  }
582
738
  __name(eventMatches, "eventMatches");
583
739
 
584
- // src/queue.ts
585
- var QueueController = class {
586
- static {
587
- __name(this, "QueueController");
588
- }
589
- config;
590
- clock;
591
- inFlight = 0;
592
- windowStart = 0;
593
- windowCount = 0;
594
- /** Registered waiters by id (only used when priority/fairness ordering is in play). */
595
- waiters = /* @__PURE__ */ new Map();
596
- /** Monotonic counter stamping each newly-seen waiter for a stable FIFO tiebreak. */
597
- waiterSeq = 0;
598
- /** Per-key "last served" order for round-robin fairness — higher = more recently served. */
599
- keyServed = /* @__PURE__ */ new Map();
600
- servedTick = 0;
601
- constructor(config, clock) {
602
- this.config = config;
603
- this.clock = clock;
604
- }
605
- /** Whether this controller does any priority/fairness ordering (else it's a plain FIFO gate). */
606
- get ordered() {
607
- return this.config.fairness === "key";
608
- }
609
- /**
610
- * Try to admit one unit of work. On `ok`, the caller has taken a concurrency slot and must call
611
- * {@link release} when the step settles. On block, `retryAt` is when admission may next succeed.
612
- *
613
- * `item` carries optional priority/fairness hints; omitting it is the legacy FIFO path. When
614
- * ordering is configured (or a priority is given), a blocked call REGISTERS as a waiter and is only
615
- * admitted on a later retry once it is the rightful next under (priority desc, fairness, FIFO).
616
- */
617
- tryAdmit(item) {
618
- const now = this.clock();
619
- const rl = this.config.rateLimit;
620
- if (rl) {
621
- if (now - this.windowStart >= rl.periodMs) {
622
- this.windowStart = now;
623
- this.windowCount = 0;
624
- }
625
- if (this.windowCount >= rl.limit) return {
626
- ok: false,
627
- retryAt: this.windowStart + rl.periodMs
628
- };
629
- }
630
- const usingOrder = this.ordered || item?.priority != null || item?.waiterId != null;
631
- const waiterId = item?.waiterId;
632
- const full = this.config.concurrency != null && this.inFlight >= this.config.concurrency;
633
- if (full) {
634
- if (usingOrder && waiterId != null) this.register(waiterId, item);
635
- return {
636
- ok: false,
637
- retryAt: now + (this.config.retryMs ?? 1e3)
638
- };
639
- }
640
- if (usingOrder && this.waiters.size > 0) {
641
- if (waiterId != null) this.register(waiterId, item);
642
- if (!this.isNext(waiterId, item)) {
643
- return {
644
- ok: false,
645
- retryAt: now + (this.config.retryMs ?? 1e3)
646
- };
647
- }
648
- }
649
- if (rl) this.windowCount += 1;
650
- this.inFlight += 1;
651
- if (waiterId != null) this.waiters.delete(waiterId);
652
- if (item?.key != null) this.keyServed.set(item.key, ++this.servedTick);
653
- return {
654
- ok: true
655
- };
656
- }
657
- /** Release a concurrency slot taken by a successful {@link tryAdmit}. */
658
- release() {
659
- if (this.inFlight > 0) this.inFlight -= 1;
660
- }
661
- /** Track (or refresh) a blocked call as a waiter; its FIFO seq is stamped only on first sight. */
662
- register(waiterId, item) {
663
- const existing = this.waiters.get(waiterId);
664
- if (existing) {
665
- existing.priority = item?.priority ?? 0;
666
- existing.key = item?.key;
667
- return;
668
- }
669
- this.waiters.set(waiterId, {
670
- priority: item?.priority ?? 0,
671
- key: item?.key,
672
- seq: this.waiterSeq++
673
- });
674
- }
675
- /**
676
- * Is the calling item the best candidate among all registered waiters? Ordering, best first:
677
- * 1. higher `priority`;
678
- * 2. fairness: with `fairness: 'key'`, the least-recently-served key wins (round-robin);
679
- * 3. earliest registration (FIFO) — stable tiebreak.
680
- * A bare item (no waiterId) is compared on its hints directly.
681
- */
682
- isNext(waiterId, item) {
683
- const self = (waiterId != null ? this.waiters.get(waiterId) : void 0) ?? {
684
- priority: item?.priority ?? 0,
685
- key: item?.key,
686
- seq: Number.POSITIVE_INFINITY
687
- };
688
- let best = self;
689
- for (const w of this.waiters.values()) {
690
- if (this.better(w, best)) best = w;
691
- }
692
- return best === self;
693
- }
694
- /** Strict "a should be admitted before b" comparison under the active policy. */
695
- better(a, b) {
696
- if (a.priority !== b.priority) return a.priority > b.priority;
697
- if (this.config.fairness === "key") {
698
- const sa = this.servedOf(a.key);
699
- const sb = this.servedOf(b.key);
700
- if (sa !== sb) return sa < sb;
701
- }
702
- return a.seq < b.seq;
703
- }
704
- /** Last-served tick for a key (0 = never served, so an unseen key is admitted before a busy one). */
705
- servedOf(key) {
706
- if (key == null) return 0;
707
- return this.keyServed.get(key) ?? 0;
708
- }
709
- };
710
-
711
740
  // src/singleton-gate.ts
712
741
  var SINGLETON_RETRY_MS = 1e3;
713
742
  var SINGLETON_RETRY_JITTER_MS = 250;
@@ -901,6 +930,12 @@ function workflowName(ref) {
901
930
  __name(workflowName, "workflowName");
902
931
 
903
932
  // src/workflow-ctx.ts
933
+ function normalizeChildOptions(options) {
934
+ return typeof options === "string" ? {
935
+ childId: options
936
+ } : options ?? {};
937
+ }
938
+ __name(normalizeChildOptions, "normalizeChildOptions");
904
939
  var Position = class Position2 {
905
940
  static {
906
941
  __name(this, "Position");
@@ -1150,7 +1185,8 @@ function createWorkflowCtx(host, runId, compensations, workflow = "") {
1150
1185
  await step(`task:dispatch:${name}`, dispatch, options);
1151
1186
  return unwrapCompletion(await waitForSignal(`task:${runId}:${name}`), `task "${name}"`);
1152
1187
  }, "task");
1153
- const child = /* @__PURE__ */ __name(async (workflow2, input, childId) => {
1188
+ const child = /* @__PURE__ */ __name(async (workflow2, input, options) => {
1189
+ const { childId, priority } = normalizeChildOptions(options);
1154
1190
  const current = pos.next();
1155
1191
  const id = childId ?? `${runId}.child.${current}`;
1156
1192
  const existing = await readCheckpoint(current);
@@ -1162,7 +1198,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "") {
1162
1198
  runId,
1163
1199
  seq: current
1164
1200
  });
1165
- if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id);
1201
+ if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority);
1166
1202
  if (!existing) {
1167
1203
  await writeCheckpoint(instantCheckpoint({
1168
1204
  runId,
@@ -1174,12 +1210,13 @@ function createWorkflowCtx(host, runId, compensations, workflow = "") {
1174
1210
  }
1175
1211
  throw new WorkflowSuspended();
1176
1212
  }, "child");
1177
- const startChild = /* @__PURE__ */ __name(async (workflow2, input, childId) => {
1213
+ const startChild = /* @__PURE__ */ __name(async (workflow2, input, options) => {
1214
+ const { childId, priority } = normalizeChildOptions(options);
1178
1215
  const current = pos.next();
1179
1216
  const id = childId ?? `${runId}.child.${current}`;
1180
1217
  const existing = await readCheckpoint(current);
1181
1218
  if (existing && existing.status === "completed") return existing.output;
1182
- if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id);
1219
+ if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority);
1183
1220
  await writeCheckpoint(instantCheckpoint({
1184
1221
  runId,
1185
1222
  seq: current,
@@ -1403,8 +1440,10 @@ var WorkflowEngine = class {
1403
1440
  updateValidators = /* @__PURE__ */ new Map();
1404
1441
  /** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
1405
1442
  cancelRequested = /* @__PURE__ */ new Set();
1406
- /** Flow-control queues for remote steps, keyed by name (see {@link registerQueue}). */
1407
- queues = /* @__PURE__ */ new Map();
1443
+ /** Flow-control admission backend for remote steps (see {@link registerQueue}). */
1444
+ admission;
1445
+ /** Runs on THIS instance blocked on admission, by queue — woken early on a freed-slot signal. */
1446
+ queueWaiters = /* @__PURE__ */ new Map();
1408
1447
  /** Which queue a dispatched step took a slot from, by stepId — so the result can release it. */
1409
1448
  stepQueue = /* @__PURE__ */ new Map();
1410
1449
  /** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
@@ -1420,6 +1459,8 @@ var WorkflowEngine = class {
1420
1459
  ] : []));
1421
1460
  this.controlPlane = deps.controlPlane;
1422
1461
  this.clock = deps.clock ?? Date.now;
1462
+ this.admission = deps.admission ?? new InMemoryAdmissionBackend(this.clock);
1463
+ this.admission.onFreed?.((queue) => this.wakeQueueWaiters(queue));
1423
1464
  this.instanceId = deps.instanceId ?? globalThis.crypto.randomUUID();
1424
1465
  this.leaseMs = deps.leaseMs ?? 3e4;
1425
1466
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
@@ -1583,7 +1624,7 @@ var WorkflowEngine = class {
1583
1624
  * durable. Per engine instance (see {@link QueueConfig}). Registering the same name replaces it.
1584
1625
  */
1585
1626
  registerQueue(config) {
1586
- this.queues.set(config.name, new QueueController(config, this.clock));
1627
+ this.admission.register(config);
1587
1628
  }
1588
1629
  /** Subscribe to lifecycle events. Returns an unsubscribe function. */
1589
1630
  subscribe(listener) {
@@ -1704,6 +1745,7 @@ var WorkflowEngine = class {
1704
1745
  input,
1705
1746
  tags,
1706
1747
  searchAttributes: opts?.searchAttributes,
1748
+ priority: opts?.priority,
1707
1749
  createdAt: now,
1708
1750
  updatedAt: now
1709
1751
  };
@@ -2930,8 +2972,10 @@ var WorkflowEngine = class {
2930
2972
  failStep: /* @__PURE__ */ __name((s) => this.failStep(s), "failStep"),
2931
2973
  callRemote: /* @__PURE__ */ __name((runId, seq, step, input, queue, transport, admission) => this.callRemote(runId, seq, step, input, queue, transport, replay, admission), "callRemote"),
2932
2974
  // Defer so a fast child can't reentrantly resume a still-running parent.
2933
- startChild: /* @__PURE__ */ __name((workflow, input, id) => {
2934
- queueMicrotask(() => void this.start(workflow, input, id).catch(() => void 0));
2975
+ startChild: /* @__PURE__ */ __name((workflow, input, id, priority) => {
2976
+ queueMicrotask(() => void this.start(workflow, input, id, {
2977
+ priority
2978
+ }).catch(() => void 0));
2935
2979
  }, "startChild"),
2936
2980
  signalEntity: /* @__PURE__ */ __name((name, key, op, arg, reply) => {
2937
2981
  queueMicrotask(() => void this.entities.dispatch(name, key, op, arg, reply).catch(() => void 0));
@@ -3032,14 +3076,19 @@ var WorkflowEngine = class {
3032
3076
  attempt = existing.attempts + 1;
3033
3077
  }
3034
3078
  const id = stepId(runId, seq);
3035
- const controller = queue ? this.queues.get(queue) : void 0;
3036
- if (controller) {
3037
- const decision = controller.tryAdmit({
3079
+ if (queue && this.admission.handles(queue)) {
3080
+ const decision = await this.admission.tryAdmit(queue, {
3038
3081
  priority: admission?.priority,
3039
3082
  key: admission?.fairnessKey ?? runId,
3040
3083
  waiterId: id
3041
3084
  });
3042
- if (!decision.ok) throw new WorkflowSuspended(decision.retryAt);
3085
+ if (!decision.ok) {
3086
+ const waiters = this.queueWaiters.get(queue) ?? /* @__PURE__ */ new Set();
3087
+ waiters.add(runId);
3088
+ this.queueWaiters.set(queue, waiters);
3089
+ throw new WorkflowSuspended(decision.retryAt);
3090
+ }
3091
+ this.queueWaiters.get(queue)?.delete(runId);
3043
3092
  this.stepQueue.set(id, queue);
3044
3093
  }
3045
3094
  const validInput = step.input.parse(input);
@@ -3067,6 +3116,7 @@ var WorkflowEngine = class {
3067
3116
  input: validInput,
3068
3117
  traceparent: this.traceparent?.(),
3069
3118
  context: this.context?.(),
3119
+ priority: admission?.priority,
3070
3120
  attempt
3071
3121
  }, transport);
3072
3122
  this.emit({
@@ -3086,7 +3136,7 @@ var WorkflowEngine = class {
3086
3136
  async completeRemoteResult(result) {
3087
3137
  const cp = await this.store.getCheckpoint(result.runId, result.seq);
3088
3138
  if (!cp || cp.status !== "pending") return;
3089
- this.releaseQueueSlot(cp.stepId);
3139
+ void this.releaseQueueSlot(cp.stepId).catch(() => void 0);
3090
3140
  const run = await this.store.getRun(result.runId);
3091
3141
  if (run && (run.status === "cancelled" || run.status === "completed")) return;
3092
3142
  const finishedAt = /* @__PURE__ */ new Date();
@@ -3113,12 +3163,26 @@ var WorkflowEngine = class {
3113
3163
  });
3114
3164
  await this.resume(result.runId);
3115
3165
  }
3166
+ /**
3167
+ * A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
3168
+ * re-contend now instead of at their retry tick. Snapshot-and-clear — a run still blocked after the
3169
+ * retry re-registers itself, and one that's gone (cancelled/admitted) is dropped. Best-effort.
3170
+ */
3171
+ wakeQueueWaiters(queue) {
3172
+ const waiters = this.queueWaiters.get(queue);
3173
+ if (!waiters || waiters.size === 0) return;
3174
+ const runIds = [
3175
+ ...waiters
3176
+ ];
3177
+ waiters.clear();
3178
+ for (const runId of runIds) void this.resume(runId).catch(() => void 0);
3179
+ }
3116
3180
  /** Release the flow-control slot a dispatched step held (if any), by its stepId. */
3117
- releaseQueueSlot(id) {
3181
+ async releaseQueueSlot(id) {
3118
3182
  const queue = this.stepQueue.get(id);
3119
3183
  if (queue === void 0) return;
3120
3184
  this.stepQueue.delete(id);
3121
- this.queues.get(queue)?.release();
3185
+ await this.admission.release(queue, id);
3122
3186
  }
3123
3187
  /** In-memory await path for a remote step with a liveness `timeoutMs` (re-dispatch on timeout). */
3124
3188
  async callRemoteInMemory(runId, seq, step, input, transport) {
@@ -3279,6 +3343,7 @@ var RemoteWorkflowExecutor = class {
3279
3343
  input: run.input,
3280
3344
  history,
3281
3345
  group: this.group,
3346
+ priority: run.priority,
3282
3347
  attempt: 1
3283
3348
  };
3284
3349
  const decision = new Promise((resolve, reject) => {
@@ -4011,6 +4076,7 @@ var InMemoryTransport = class {
4011
4076
  Entities,
4012
4077
  EventAccumulators,
4013
4078
  FatalError,
4079
+ InMemoryAdmissionBackend,
4014
4080
  InMemoryStateStore,
4015
4081
  InMemoryTransport,
4016
4082
  NonDeterminismError,