@dudousxd/nestjs-durable-core 0.30.0 → 0.32.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.js CHANGED
@@ -1,6 +1,161 @@
1
1
  var __defProp = Object.defineProperty;
2
2
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
3
 
4
+ // src/queue.ts
5
+ var QueueController = class {
6
+ static {
7
+ __name(this, "QueueController");
8
+ }
9
+ config;
10
+ clock;
11
+ inFlight = 0;
12
+ windowStart = 0;
13
+ windowCount = 0;
14
+ /** Registered waiters by id (only used when priority/fairness ordering is in play). */
15
+ waiters = /* @__PURE__ */ new Map();
16
+ /** Monotonic counter stamping each newly-seen waiter for a stable FIFO tiebreak. */
17
+ waiterSeq = 0;
18
+ /** Per-key "last served" order for round-robin fairness — higher = more recently served. */
19
+ keyServed = /* @__PURE__ */ new Map();
20
+ servedTick = 0;
21
+ constructor(config, clock) {
22
+ this.config = config;
23
+ this.clock = clock;
24
+ }
25
+ /** Whether this controller does any priority/fairness ordering (else it's a plain FIFO gate). */
26
+ get ordered() {
27
+ return this.config.fairness === "key" || this.config.order === "lifo";
28
+ }
29
+ /**
30
+ * Try to admit one unit of work. On `ok`, the caller has taken a concurrency slot and must call
31
+ * {@link release} when the step settles. On block, `retryAt` is when admission may next succeed.
32
+ *
33
+ * `item` carries optional priority/fairness hints; omitting it is the legacy FIFO path. When
34
+ * ordering is configured (or a priority is given), a blocked call REGISTERS as a waiter and is only
35
+ * admitted on a later retry once it is the rightful next under (priority desc, fairness, FIFO).
36
+ */
37
+ tryAdmit(item) {
38
+ const now = this.clock();
39
+ const rl = this.config.rateLimit;
40
+ if (rl) {
41
+ if (now - this.windowStart >= rl.periodMs) {
42
+ this.windowStart = now;
43
+ this.windowCount = 0;
44
+ }
45
+ if (this.windowCount >= rl.limit) return {
46
+ ok: false,
47
+ retryAt: this.windowStart + rl.periodMs
48
+ };
49
+ }
50
+ const usingOrder = this.ordered || item?.priority != null || item?.waiterId != null;
51
+ const waiterId = item?.waiterId;
52
+ const full = this.config.concurrency != null && this.inFlight >= this.config.concurrency;
53
+ if (full) {
54
+ if (usingOrder && waiterId != null) this.register(waiterId, item);
55
+ return {
56
+ ok: false,
57
+ retryAt: now + (this.config.retryMs ?? 1e3)
58
+ };
59
+ }
60
+ if (usingOrder && this.waiters.size > 0) {
61
+ if (waiterId != null) this.register(waiterId, item);
62
+ if (!this.isNext(waiterId, item)) {
63
+ return {
64
+ ok: false,
65
+ retryAt: now + (this.config.retryMs ?? 1e3)
66
+ };
67
+ }
68
+ }
69
+ if (rl) this.windowCount += 1;
70
+ this.inFlight += 1;
71
+ if (waiterId != null) this.waiters.delete(waiterId);
72
+ if (item?.key != null) this.keyServed.set(item.key, ++this.servedTick);
73
+ return {
74
+ ok: true
75
+ };
76
+ }
77
+ /** Release a concurrency slot taken by a successful {@link tryAdmit}. */
78
+ release() {
79
+ if (this.inFlight > 0) this.inFlight -= 1;
80
+ }
81
+ /** Track (or refresh) a blocked call as a waiter; its FIFO seq is stamped only on first sight. */
82
+ register(waiterId, item) {
83
+ const existing = this.waiters.get(waiterId);
84
+ if (existing) {
85
+ existing.priority = item?.priority ?? 0;
86
+ existing.key = item?.key;
87
+ return;
88
+ }
89
+ this.waiters.set(waiterId, {
90
+ priority: item?.priority ?? 0,
91
+ key: item?.key,
92
+ seq: this.waiterSeq++
93
+ });
94
+ }
95
+ /**
96
+ * Is the calling item the best candidate among all registered waiters? Ordering, best first:
97
+ * 1. higher `priority`;
98
+ * 2. fairness: with `fairness: 'key'`, the least-recently-served key wins (round-robin);
99
+ * 3. earliest registration (FIFO) — stable tiebreak.
100
+ * A bare item (no waiterId) is compared on its hints directly.
101
+ */
102
+ isNext(waiterId, item) {
103
+ const self = (waiterId != null ? this.waiters.get(waiterId) : void 0) ?? {
104
+ priority: item?.priority ?? 0,
105
+ key: item?.key,
106
+ seq: Number.POSITIVE_INFINITY
107
+ };
108
+ let best = self;
109
+ for (const w of this.waiters.values()) {
110
+ if (this.better(w, best)) best = w;
111
+ }
112
+ return best === self;
113
+ }
114
+ /** Strict "a should be admitted before b" comparison under the active policy. */
115
+ better(a, b) {
116
+ if (a.priority !== b.priority) return a.priority > b.priority;
117
+ if (this.config.fairness === "key") {
118
+ const sa = this.servedOf(a.key);
119
+ const sb = this.servedOf(b.key);
120
+ if (sa !== sb) return sa < sb;
121
+ }
122
+ return this.config.order === "lifo" ? a.seq > b.seq : a.seq < b.seq;
123
+ }
124
+ /** Last-served tick for a key (0 = never served, so an unseen key is admitted before a busy one). */
125
+ servedOf(key) {
126
+ if (key == null) return 0;
127
+ return this.keyServed.get(key) ?? 0;
128
+ }
129
+ };
130
+
131
+ // src/admission.ts
132
+ var InMemoryAdmissionBackend = class {
133
+ static {
134
+ __name(this, "InMemoryAdmissionBackend");
135
+ }
136
+ clock;
137
+ controllers = /* @__PURE__ */ new Map();
138
+ constructor(clock) {
139
+ this.clock = clock;
140
+ }
141
+ register(config) {
142
+ this.controllers.set(config.name, new QueueController(config, this.clock));
143
+ }
144
+ handles(queue) {
145
+ return this.controllers.has(queue);
146
+ }
147
+ async tryAdmit(queue, item) {
148
+ const controller = this.controllers.get(queue);
149
+ if (!controller) return {
150
+ ok: true
151
+ };
152
+ return controller.tryAdmit(item);
153
+ }
154
+ async release(queue, _slotId) {
155
+ this.controllers.get(queue)?.release();
156
+ }
157
+ };
158
+
4
159
  // src/duration.ts
5
160
  var UNIT_MS = {
6
161
  ms: 1,
@@ -517,133 +672,6 @@ function eventMatches(payload, match) {
517
672
  }
518
673
  __name(eventMatches, "eventMatches");
519
674
 
520
- // src/queue.ts
521
- var QueueController = class {
522
- static {
523
- __name(this, "QueueController");
524
- }
525
- config;
526
- clock;
527
- inFlight = 0;
528
- windowStart = 0;
529
- windowCount = 0;
530
- /** Registered waiters by id (only used when priority/fairness ordering is in play). */
531
- waiters = /* @__PURE__ */ new Map();
532
- /** Monotonic counter stamping each newly-seen waiter for a stable FIFO tiebreak. */
533
- waiterSeq = 0;
534
- /** Per-key "last served" order for round-robin fairness — higher = more recently served. */
535
- keyServed = /* @__PURE__ */ new Map();
536
- servedTick = 0;
537
- constructor(config, clock) {
538
- this.config = config;
539
- this.clock = clock;
540
- }
541
- /** Whether this controller does any priority/fairness ordering (else it's a plain FIFO gate). */
542
- get ordered() {
543
- return this.config.fairness === "key";
544
- }
545
- /**
546
- * Try to admit one unit of work. On `ok`, the caller has taken a concurrency slot and must call
547
- * {@link release} when the step settles. On block, `retryAt` is when admission may next succeed.
548
- *
549
- * `item` carries optional priority/fairness hints; omitting it is the legacy FIFO path. When
550
- * ordering is configured (or a priority is given), a blocked call REGISTERS as a waiter and is only
551
- * admitted on a later retry once it is the rightful next under (priority desc, fairness, FIFO).
552
- */
553
- tryAdmit(item) {
554
- const now = this.clock();
555
- const rl = this.config.rateLimit;
556
- if (rl) {
557
- if (now - this.windowStart >= rl.periodMs) {
558
- this.windowStart = now;
559
- this.windowCount = 0;
560
- }
561
- if (this.windowCount >= rl.limit) return {
562
- ok: false,
563
- retryAt: this.windowStart + rl.periodMs
564
- };
565
- }
566
- const usingOrder = this.ordered || item?.priority != null || item?.waiterId != null;
567
- const waiterId = item?.waiterId;
568
- const full = this.config.concurrency != null && this.inFlight >= this.config.concurrency;
569
- if (full) {
570
- if (usingOrder && waiterId != null) this.register(waiterId, item);
571
- return {
572
- ok: false,
573
- retryAt: now + (this.config.retryMs ?? 1e3)
574
- };
575
- }
576
- if (usingOrder && this.waiters.size > 0) {
577
- if (waiterId != null) this.register(waiterId, item);
578
- if (!this.isNext(waiterId, item)) {
579
- return {
580
- ok: false,
581
- retryAt: now + (this.config.retryMs ?? 1e3)
582
- };
583
- }
584
- }
585
- if (rl) this.windowCount += 1;
586
- this.inFlight += 1;
587
- if (waiterId != null) this.waiters.delete(waiterId);
588
- if (item?.key != null) this.keyServed.set(item.key, ++this.servedTick);
589
- return {
590
- ok: true
591
- };
592
- }
593
- /** Release a concurrency slot taken by a successful {@link tryAdmit}. */
594
- release() {
595
- if (this.inFlight > 0) this.inFlight -= 1;
596
- }
597
- /** Track (or refresh) a blocked call as a waiter; its FIFO seq is stamped only on first sight. */
598
- register(waiterId, item) {
599
- const existing = this.waiters.get(waiterId);
600
- if (existing) {
601
- existing.priority = item?.priority ?? 0;
602
- existing.key = item?.key;
603
- return;
604
- }
605
- this.waiters.set(waiterId, {
606
- priority: item?.priority ?? 0,
607
- key: item?.key,
608
- seq: this.waiterSeq++
609
- });
610
- }
611
- /**
612
- * Is the calling item the best candidate among all registered waiters? Ordering, best first:
613
- * 1. higher `priority`;
614
- * 2. fairness: with `fairness: 'key'`, the least-recently-served key wins (round-robin);
615
- * 3. earliest registration (FIFO) — stable tiebreak.
616
- * A bare item (no waiterId) is compared on its hints directly.
617
- */
618
- isNext(waiterId, item) {
619
- const self = (waiterId != null ? this.waiters.get(waiterId) : void 0) ?? {
620
- priority: item?.priority ?? 0,
621
- key: item?.key,
622
- seq: Number.POSITIVE_INFINITY
623
- };
624
- let best = self;
625
- for (const w of this.waiters.values()) {
626
- if (this.better(w, best)) best = w;
627
- }
628
- return best === self;
629
- }
630
- /** Strict "a should be admitted before b" comparison under the active policy. */
631
- better(a, b) {
632
- if (a.priority !== b.priority) return a.priority > b.priority;
633
- if (this.config.fairness === "key") {
634
- const sa = this.servedOf(a.key);
635
- const sb = this.servedOf(b.key);
636
- if (sa !== sb) return sa < sb;
637
- }
638
- return a.seq < b.seq;
639
- }
640
- /** Last-served tick for a key (0 = never served, so an unseen key is admitted before a busy one). */
641
- servedOf(key) {
642
- if (key == null) return 0;
643
- return this.keyServed.get(key) ?? 0;
644
- }
645
- };
646
-
647
675
  // src/singleton-gate.ts
648
676
  var SINGLETON_RETRY_MS = 1e3;
649
677
  var SINGLETON_RETRY_JITTER_MS = 250;
@@ -837,6 +865,12 @@ function workflowName(ref) {
837
865
  __name(workflowName, "workflowName");
838
866
 
839
867
  // src/workflow-ctx.ts
868
+ function normalizeChildOptions(options) {
869
+ return typeof options === "string" ? {
870
+ childId: options
871
+ } : options ?? {};
872
+ }
873
+ __name(normalizeChildOptions, "normalizeChildOptions");
840
874
  var Position = class Position2 {
841
875
  static {
842
876
  __name(this, "Position");
@@ -1086,7 +1120,8 @@ function createWorkflowCtx(host, runId, compensations, workflow = "") {
1086
1120
  await step(`task:dispatch:${name}`, dispatch, options);
1087
1121
  return unwrapCompletion(await waitForSignal(`task:${runId}:${name}`), `task "${name}"`);
1088
1122
  }, "task");
1089
- const child = /* @__PURE__ */ __name(async (workflow2, input, childId) => {
1123
+ const child = /* @__PURE__ */ __name(async (workflow2, input, options) => {
1124
+ const { childId, priority } = normalizeChildOptions(options);
1090
1125
  const current = pos.next();
1091
1126
  const id = childId ?? `${runId}.child.${current}`;
1092
1127
  const existing = await readCheckpoint(current);
@@ -1098,7 +1133,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "") {
1098
1133
  runId,
1099
1134
  seq: current
1100
1135
  });
1101
- if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id);
1136
+ if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority);
1102
1137
  if (!existing) {
1103
1138
  await writeCheckpoint(instantCheckpoint({
1104
1139
  runId,
@@ -1110,12 +1145,13 @@ function createWorkflowCtx(host, runId, compensations, workflow = "") {
1110
1145
  }
1111
1146
  throw new WorkflowSuspended();
1112
1147
  }, "child");
1113
- const startChild = /* @__PURE__ */ __name(async (workflow2, input, childId) => {
1148
+ const startChild = /* @__PURE__ */ __name(async (workflow2, input, options) => {
1149
+ const { childId, priority } = normalizeChildOptions(options);
1114
1150
  const current = pos.next();
1115
1151
  const id = childId ?? `${runId}.child.${current}`;
1116
1152
  const existing = await readCheckpoint(current);
1117
1153
  if (existing && existing.status === "completed") return existing.output;
1118
- if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id);
1154
+ if (!await store.getRun(id)) host.startChild(workflowName(workflow2), input, id, priority);
1119
1155
  await writeCheckpoint(instantCheckpoint({
1120
1156
  runId,
1121
1157
  seq: current,
@@ -1339,8 +1375,10 @@ var WorkflowEngine = class {
1339
1375
  updateValidators = /* @__PURE__ */ new Map();
1340
1376
  /** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
1341
1377
  cancelRequested = /* @__PURE__ */ new Set();
1342
- /** Flow-control queues for remote steps, keyed by name (see {@link registerQueue}). */
1343
- queues = /* @__PURE__ */ new Map();
1378
+ /** Flow-control admission backend for remote steps (see {@link registerQueue}). */
1379
+ admission;
1380
+ /** Runs on THIS instance blocked on admission, by queue — woken early on a freed-slot signal. */
1381
+ queueWaiters = /* @__PURE__ */ new Map();
1344
1382
  /** Which queue a dispatched step took a slot from, by stepId — so the result can release it. */
1345
1383
  stepQueue = /* @__PURE__ */ new Map();
1346
1384
  /** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
@@ -1356,6 +1394,8 @@ var WorkflowEngine = class {
1356
1394
  ] : []));
1357
1395
  this.controlPlane = deps.controlPlane;
1358
1396
  this.clock = deps.clock ?? Date.now;
1397
+ this.admission = deps.admission ?? new InMemoryAdmissionBackend(this.clock);
1398
+ this.admission.onFreed?.((queue) => this.wakeQueueWaiters(queue));
1359
1399
  this.instanceId = deps.instanceId ?? globalThis.crypto.randomUUID();
1360
1400
  this.leaseMs = deps.leaseMs ?? 3e4;
1361
1401
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
@@ -1519,7 +1559,7 @@ var WorkflowEngine = class {
1519
1559
  * durable. Per engine instance (see {@link QueueConfig}). Registering the same name replaces it.
1520
1560
  */
1521
1561
  registerQueue(config) {
1522
- this.queues.set(config.name, new QueueController(config, this.clock));
1562
+ this.admission.register(config);
1523
1563
  }
1524
1564
  /** Subscribe to lifecycle events. Returns an unsubscribe function. */
1525
1565
  subscribe(listener) {
@@ -1640,6 +1680,7 @@ var WorkflowEngine = class {
1640
1680
  input,
1641
1681
  tags,
1642
1682
  searchAttributes: opts?.searchAttributes,
1683
+ priority: opts?.priority,
1643
1684
  createdAt: now,
1644
1685
  updatedAt: now
1645
1686
  };
@@ -2866,8 +2907,10 @@ var WorkflowEngine = class {
2866
2907
  failStep: /* @__PURE__ */ __name((s) => this.failStep(s), "failStep"),
2867
2908
  callRemote: /* @__PURE__ */ __name((runId, seq, step, input, queue, transport, admission) => this.callRemote(runId, seq, step, input, queue, transport, replay, admission), "callRemote"),
2868
2909
  // Defer so a fast child can't reentrantly resume a still-running parent.
2869
- startChild: /* @__PURE__ */ __name((workflow, input, id) => {
2870
- queueMicrotask(() => void this.start(workflow, input, id).catch(() => void 0));
2910
+ startChild: /* @__PURE__ */ __name((workflow, input, id, priority) => {
2911
+ queueMicrotask(() => void this.start(workflow, input, id, {
2912
+ priority
2913
+ }).catch(() => void 0));
2871
2914
  }, "startChild"),
2872
2915
  signalEntity: /* @__PURE__ */ __name((name, key, op, arg, reply) => {
2873
2916
  queueMicrotask(() => void this.entities.dispatch(name, key, op, arg, reply).catch(() => void 0));
@@ -2968,14 +3011,19 @@ var WorkflowEngine = class {
2968
3011
  attempt = existing.attempts + 1;
2969
3012
  }
2970
3013
  const id = stepId(runId, seq);
2971
- const controller = queue ? this.queues.get(queue) : void 0;
2972
- if (controller) {
2973
- const decision = controller.tryAdmit({
3014
+ if (queue && this.admission.handles(queue)) {
3015
+ const decision = await this.admission.tryAdmit(queue, {
2974
3016
  priority: admission?.priority,
2975
3017
  key: admission?.fairnessKey ?? runId,
2976
3018
  waiterId: id
2977
3019
  });
2978
- if (!decision.ok) throw new WorkflowSuspended(decision.retryAt);
3020
+ if (!decision.ok) {
3021
+ const waiters = this.queueWaiters.get(queue) ?? /* @__PURE__ */ new Set();
3022
+ waiters.add(runId);
3023
+ this.queueWaiters.set(queue, waiters);
3024
+ throw new WorkflowSuspended(decision.retryAt);
3025
+ }
3026
+ this.queueWaiters.get(queue)?.delete(runId);
2979
3027
  this.stepQueue.set(id, queue);
2980
3028
  }
2981
3029
  const validInput = step.input.parse(input);
@@ -3003,6 +3051,7 @@ var WorkflowEngine = class {
3003
3051
  input: validInput,
3004
3052
  traceparent: this.traceparent?.(),
3005
3053
  context: this.context?.(),
3054
+ priority: admission?.priority,
3006
3055
  attempt
3007
3056
  }, transport);
3008
3057
  this.emit({
@@ -3022,7 +3071,7 @@ var WorkflowEngine = class {
3022
3071
  async completeRemoteResult(result) {
3023
3072
  const cp = await this.store.getCheckpoint(result.runId, result.seq);
3024
3073
  if (!cp || cp.status !== "pending") return;
3025
- this.releaseQueueSlot(cp.stepId);
3074
+ void this.releaseQueueSlot(cp.stepId).catch(() => void 0);
3026
3075
  const run = await this.store.getRun(result.runId);
3027
3076
  if (run && (run.status === "cancelled" || run.status === "completed")) return;
3028
3077
  const finishedAt = /* @__PURE__ */ new Date();
@@ -3049,12 +3098,26 @@ var WorkflowEngine = class {
3049
3098
  });
3050
3099
  await this.resume(result.runId);
3051
3100
  }
3101
+ /**
3102
+ * A slot freed on `queue` (a fleet-wide signal): resume this instance's runs blocked on it so they
3103
+ * re-contend now instead of at their retry tick. Snapshot-and-clear — a run still blocked after the
3104
+ * retry re-registers itself, and one that's gone (cancelled/admitted) is dropped. Best-effort.
3105
+ */
3106
+ wakeQueueWaiters(queue) {
3107
+ const waiters = this.queueWaiters.get(queue);
3108
+ if (!waiters || waiters.size === 0) return;
3109
+ const runIds = [
3110
+ ...waiters
3111
+ ];
3112
+ waiters.clear();
3113
+ for (const runId of runIds) void this.resume(runId).catch(() => void 0);
3114
+ }
3052
3115
  /** Release the flow-control slot a dispatched step held (if any), by its stepId. */
3053
- releaseQueueSlot(id) {
3116
+ async releaseQueueSlot(id) {
3054
3117
  const queue = this.stepQueue.get(id);
3055
3118
  if (queue === void 0) return;
3056
3119
  this.stepQueue.delete(id);
3057
- this.queues.get(queue)?.release();
3120
+ await this.admission.release(queue, id);
3058
3121
  }
3059
3122
  /** In-memory await path for a remote step with a liveness `timeoutMs` (re-dispatch on timeout). */
3060
3123
  async callRemoteInMemory(runId, seq, step, input, transport) {
@@ -3215,6 +3278,7 @@ var RemoteWorkflowExecutor = class {
3215
3278
  input: run.input,
3216
3279
  history,
3217
3280
  group: this.group,
3281
+ priority: run.priority,
3218
3282
  attempt: 1
3219
3283
  };
3220
3284
  const decision = new Promise((resolve, reject) => {
@@ -3629,6 +3693,9 @@ __name(attributeOperand, "attributeOperand");
3629
3693
  var STATE_STORE = Symbol.for("nestjs-durable:STATE_STORE");
3630
3694
  var TRANSPORT = Symbol.for("nestjs-durable:TRANSPORT");
3631
3695
  var DURABLE_OPTIONS = Symbol.for("nestjs-durable:DURABLE_OPTIONS");
3696
+ var STATE_STORE_CANONICAL = Symbol.for("@dudousxd/nestjs-durable:state-store");
3697
+ var TRANSPORT_CANONICAL = Symbol.for("@dudousxd/nestjs-durable:transport");
3698
+ var DURABLE_OPTIONS_CANONICAL = Symbol.for("@dudousxd/nestjs-durable:options");
3632
3699
 
3633
3700
  // src/testing/in-memory-state-store.ts
3634
3701
  var InMemoryStateStore = class {
@@ -3943,9 +4010,11 @@ export {
3943
4010
  CodecStateStore,
3944
4011
  ContinueAsNew,
3945
4012
  DURABLE_OPTIONS,
4013
+ DURABLE_OPTIONS_CANONICAL,
3946
4014
  Entities,
3947
4015
  EventAccumulators,
3948
4016
  FatalError,
4017
+ InMemoryAdmissionBackend,
3949
4018
  InMemoryStateStore,
3950
4019
  InMemoryTransport,
3951
4020
  NonDeterminismError,
@@ -3954,9 +4023,11 @@ export {
3954
4023
  RemoteStepTimeout,
3955
4024
  RemoteWorkflowExecutor,
3956
4025
  STATE_STORE,
4026
+ STATE_STORE_CANONICAL,
3957
4027
  SignalTimeoutError,
3958
4028
  SingletonQueueFullError,
3959
4029
  TRANSPORT,
4030
+ TRANSPORT_CANONICAL,
3960
4031
  WORKFLOW_NAME_KEY,
3961
4032
  WorkflowEngine,
3962
4033
  WorkflowSuspended,