@dudousxd/nestjs-durable-core 0.84.1 → 0.85.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
@@ -570,6 +570,82 @@ function backoffDelay(attempt, options) {
570
570
  }
571
571
  __name(backoffDelay, "backoffDelay");
572
572
 
573
+ // src/bounded-runner.ts
574
+ var BoundedRunner = class {
575
+ static {
576
+ __name(this, "BoundedRunner");
577
+ }
578
+ limit;
579
+ active = 0;
580
+ waiting = /* @__PURE__ */ new Map();
581
+ seq = 0;
582
+ constructor(limit) {
583
+ this.limit = limit;
584
+ }
585
+ /** Tasks running now plus tasks waiting for a slot. */
586
+ get size() {
587
+ return this.active + this.waiting.size;
588
+ }
589
+ /** Tasks running now. */
590
+ get running() {
591
+ return this.active;
592
+ }
593
+ submit(key, task) {
594
+ if (this.active < this.limit) {
595
+ this.active += 1;
596
+ queueMicrotask(() => this.execute(task));
597
+ return;
598
+ }
599
+ if (!this.waiting.has(key)) this.waiting.set(key, {
600
+ task
601
+ });
602
+ }
603
+ /**
604
+ * Run `task` under the bound and resolve once it settled, or once it was dropped by
605
+ * {@link clear} before it got a slot. Never deduplicated.
606
+ */
607
+ run(task) {
608
+ return new Promise((resolve) => {
609
+ const done = /* @__PURE__ */ __name(() => resolve(), "done");
610
+ const wrapped = /* @__PURE__ */ __name(() => task().then(done, done), "wrapped");
611
+ if (this.active < this.limit) {
612
+ this.active += 1;
613
+ queueMicrotask(() => this.execute(wrapped));
614
+ return;
615
+ }
616
+ this.seq += 1;
617
+ this.waiting.set(`\0run:${this.seq}`, {
618
+ task: wrapped,
619
+ dropped: done
620
+ });
621
+ });
622
+ }
623
+ /** Forget every task still waiting for a slot. */
624
+ clear() {
625
+ for (const { dropped } of this.waiting.values()) dropped?.();
626
+ this.waiting.clear();
627
+ }
628
+ execute(task) {
629
+ let settled;
630
+ try {
631
+ settled = task();
632
+ } catch {
633
+ settled = Promise.resolve();
634
+ }
635
+ void settled.catch(() => void 0).then(() => {
636
+ const next = this.waiting.entries().next();
637
+ if (next.done) {
638
+ this.active -= 1;
639
+ return;
640
+ }
641
+ const [key, waiting] = next.value;
642
+ this.waiting.delete(key);
643
+ this.execute(waiting.task);
644
+ });
645
+ }
646
+ };
647
+ var LOCAL_RUN_CONCURRENCY = 256;
648
+
573
649
  // src/execution-hooks.ts
574
650
  var wrappers = [];
575
651
  function useDurableExecution(wrapper) {
@@ -2130,6 +2206,61 @@ var RemoteWorkflowExecutor = class {
2130
2206
  }
2131
2207
  };
2132
2208
 
2209
+ // src/run-dispatch-batcher.ts
2210
+ var RUN_DISPATCH_BATCH = 500;
2211
+ var RUN_DISPATCH_WINDOW_MS = 5;
2212
+ var RunDispatchBatcher = class {
2213
+ static {
2214
+ __name(this, "RunDispatchBatcher");
2215
+ }
2216
+ send;
2217
+ onError;
2218
+ buffer = /* @__PURE__ */ new Map();
2219
+ timer;
2220
+ sending = /* @__PURE__ */ new Set();
2221
+ constructor(send, onError) {
2222
+ this.send = send;
2223
+ this.onError = onError;
2224
+ }
2225
+ /** Nothing buffered and nothing being sent. */
2226
+ get idle() {
2227
+ return this.buffer.size === 0 && this.sending.size === 0;
2228
+ }
2229
+ /** Buffer `run`. A wake for a run already buffered replaces its start: a wake also runs a `pending` run. */
2230
+ add(run) {
2231
+ const prior = this.buffer.get(run.runId);
2232
+ if (prior && run.reason !== "wake") return;
2233
+ this.buffer.set(run.runId, run);
2234
+ if (this.buffer.size >= RUN_DISPATCH_BATCH) {
2235
+ this.flush();
2236
+ return;
2237
+ }
2238
+ if (!this.timer) {
2239
+ this.timer = setTimeout(() => this.flush(), RUN_DISPATCH_WINDOW_MS);
2240
+ this.timer.unref?.();
2241
+ }
2242
+ }
2243
+ /** Send what is buffered now. */
2244
+ flush() {
2245
+ if (this.timer) clearTimeout(this.timer);
2246
+ this.timer = void 0;
2247
+ if (this.buffer.size === 0) return;
2248
+ const runs = [
2249
+ ...this.buffer.values()
2250
+ ];
2251
+ this.buffer = /* @__PURE__ */ new Map();
2252
+ const sent = this.send(runs).catch((err) => this.onError(runs, err)).finally(() => this.sending.delete(sent));
2253
+ this.sending.add(sent);
2254
+ }
2255
+ /** Send what is buffered and wait for every send in flight. */
2256
+ async settle() {
2257
+ this.flush();
2258
+ await Promise.allSettled([
2259
+ ...this.sending
2260
+ ]);
2261
+ }
2262
+ };
2263
+
2133
2264
  // src/run-waiting.ts
2134
2265
  var WEBHOOK_PREFIX = "wh:";
2135
2266
  var CHILD_PREFIX = "child:";
@@ -2200,6 +2331,51 @@ function indexWaitersByRun(waiters) {
2200
2331
  return byRun;
2201
2332
  }
2202
2333
  __name(indexWaitersByRun, "indexWaitersByRun");
2334
+ var GET_RUN_FALLBACK_PARALLEL = 50;
2335
+ async function getRunsByIds(store, runIds) {
2336
+ const { runs, unread, error: error2 } = await readRunsByIds(store, runIds);
2337
+ if (unread.length > 0) throw error2;
2338
+ return runs;
2339
+ }
2340
+ __name(getRunsByIds, "getRunsByIds");
2341
+ async function readRunsByIds(store, runIds) {
2342
+ const unique = [
2343
+ ...new Set(runIds)
2344
+ ];
2345
+ const runs = /* @__PURE__ */ new Map();
2346
+ const unread = [];
2347
+ let error2;
2348
+ const batch = store.getRuns ? RUN_ID_BATCH : GET_RUN_FALLBACK_PARALLEL;
2349
+ for (let at = 0; at < unique.length; at += batch) {
2350
+ const ids = unique.slice(at, at + batch);
2351
+ if (store.getRuns) {
2352
+ try {
2353
+ for (const run of await store.getRuns(ids)) runs.set(run.id, run);
2354
+ } catch (err) {
2355
+ error2 ??= err;
2356
+ unread.push(...ids);
2357
+ }
2358
+ continue;
2359
+ }
2360
+ const reads = await Promise.allSettled(ids.map((id) => store.getRun(id)));
2361
+ reads.forEach((read, k) => {
2362
+ if (read.status === "fulfilled") {
2363
+ if (read.value) runs.set(read.value.id, read.value);
2364
+ return;
2365
+ }
2366
+ error2 ??= read.reason;
2367
+ unread.push(ids[k]);
2368
+ });
2369
+ }
2370
+ return {
2371
+ runs,
2372
+ unread,
2373
+ ...unread.length > 0 ? {
2374
+ error: error2
2375
+ } : {}
2376
+ };
2377
+ }
2378
+ __name(readRunsByIds, "readRunsByIds");
2203
2379
 
2204
2380
  // src/scheduler.ts
2205
2381
  import { createRequire } from "node:module";
@@ -3092,6 +3268,39 @@ var TransportPool = class {
3092
3268
  transport.onStartRun?.(handler);
3093
3269
  }
3094
3270
  }
3271
+ /** Does any transport here carry a run queue ({@link Transport.dispatchRuns} + `onDispatchedRun`)? */
3272
+ get dispatchesRuns() {
3273
+ return this.transports.some(({ transport }) => transport.dispatchRuns && transport.onDispatchedRun);
3274
+ }
3275
+ /**
3276
+ * Queue `runs` on the first transport that carries a run queue, failing over to the next on an
3277
+ * error, like {@link dispatch}. Throws if none accepted them.
3278
+ */
3279
+ async dispatchRuns(runs, opts) {
3280
+ let lastErr;
3281
+ for (const { transport } of this.transports) {
3282
+ if (!transport.dispatchRuns || !transport.onDispatchedRun) continue;
3283
+ try {
3284
+ await transport.dispatchRuns(runs, opts);
3285
+ return;
3286
+ } catch (err) {
3287
+ lastErr = err;
3288
+ }
3289
+ }
3290
+ throw lastErr ?? new Error("no transport carries a run queue");
3291
+ }
3292
+ /**
3293
+ * Consume the run queue of every transport that carries one (a run may have been queued on any of
3294
+ * them after a failover). Returns one stop function per consumer.
3295
+ */
3296
+ onDispatchedRun(handler, opts) {
3297
+ const stops = [];
3298
+ for (const { transport } of this.transports) {
3299
+ if (!transport.dispatchRuns || !transport.onDispatchedRun) continue;
3300
+ stops.push(transport.onDispatchedRun(handler, opts));
3301
+ }
3302
+ return stops;
3303
+ }
3095
3304
  /**
3096
3305
  * The live handshake descriptors advertised for `group` (design §7.2), merged across every transport
3097
3306
  * that can report them. Returns `[]` when NO transport implements
@@ -3624,21 +3833,28 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3624
3833
  return inputs.map((_, i) => i).slice(from, from + waves.waveSize);
3625
3834
  }, "waveItems");
3626
3835
  if (mode === "failFast") {
3627
- for (let i = 0; i < inputs.length; i += 1) {
3628
- const cp = existing[i];
3629
- const c = cp?.status === "completed" ? cp.output : null;
3630
- if (c && c.ok === false) {
3631
- for (let j = 0; j < inputs.length; j += 1) {
3632
- if (j !== i && existing[j]?.status !== "completed") host.cancelChild?.(id(j));
3633
- }
3634
- throw new GatherError([
3635
- {
3636
- index: i,
3637
- id: id(i),
3638
- error: c.error ?? "unknown"
3639
- }
3640
- ], inputs.length);
3836
+ const failure = /* @__PURE__ */ __name((i2) => {
3837
+ const cp = existing[i2];
3838
+ const c2 = cp?.status === "completed" ? cp.output : null;
3839
+ return c2 && c2.ok === false ? c2 : null;
3840
+ }, "failure");
3841
+ const first = /* @__PURE__ */ __name((cancelled) => inputs.findIndex((_, i2) => {
3842
+ const c2 = failure(i2);
3843
+ return c2 !== null && c2.code === "cancelled" === cancelled;
3844
+ }), "first");
3845
+ const i = first(false) !== -1 ? first(false) : first(true);
3846
+ const c = i === -1 ? null : failure(i);
3847
+ if (c) {
3848
+ for (let j = 0; j < inputs.length; j += 1) {
3849
+ if (j !== i && existing[j]?.status !== "completed") host.cancelChild?.(id(j));
3641
3850
  }
3851
+ throw new GatherError([
3852
+ {
3853
+ index: i,
3854
+ id: id(i),
3855
+ error: c.error ?? "unknown"
3856
+ }
3857
+ ], inputs.length);
3642
3858
  }
3643
3859
  }
3644
3860
  if (store.compactCheckpoints && inputs.length >= FOLD_MIN_FAN) {
@@ -3717,10 +3933,12 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3717
3933
  await registerChildWaits(entries);
3718
3934
  for (const i of chunk) host.startChild(name, inputs[i], id(i));
3719
3935
  }
3720
- for (let at = 0; at < toEnsure.length; at += FAN_CHUNK) {
3721
- await Promise.all(toEnsure.slice(at, at + FAN_CHUNK).map(async (i) => {
3722
- if (!await store.getRun(id(i))) host.startChild(name, inputs[i], id(i));
3723
- }));
3936
+ if (toEnsure.length > 0) {
3937
+ const { runs: started, unread } = await readRunsByIds(store, toEnsure.map((i) => id(i)));
3938
+ const skip = new Set(unread);
3939
+ for (const i of toEnsure) {
3940
+ if (!started.has(id(i)) && !skip.has(id(i))) host.startChild(name, inputs[i], id(i));
3941
+ }
3724
3942
  }
3725
3943
  }
3726
3944
  if (active.length > 0) {
@@ -4180,6 +4398,56 @@ var ANNOUNCED_ROUTES_TTL_MS = 5e3;
4180
4398
  var VERSION_UNDECLARED_TAG = "version:undeclared";
4181
4399
  var versionKey = /* @__PURE__ */ __name((name, version) => `${name}@${version}`, "versionKey");
4182
4400
  var STEP_FAN_CHUNK = 200;
4401
+ var PARENT_NOTIFY_ATTEMPTS = 4;
4402
+ var PARENT_NOTIFY_BACKOFF_MS = 250;
4403
+ var LEASE_RECHECK_MAX_MS = 2e3;
4404
+ var LEASE_RECHECK_SLACK_MS = 100;
4405
+ var LEASE_RECHECK_ATTEMPTS = 30;
4406
+ function deferred() {
4407
+ let resolve;
4408
+ let reject;
4409
+ const promise = new Promise((res, rej) => {
4410
+ resolve = res;
4411
+ reject = rej;
4412
+ });
4413
+ promise.catch(() => void 0);
4414
+ return {
4415
+ promise,
4416
+ resolve,
4417
+ reject
4418
+ };
4419
+ }
4420
+ __name(deferred, "deferred");
4421
+ var pendingStateKey = /* @__PURE__ */ __name((at) => String(new Date(at).getTime()), "pendingStateKey");
4422
+ var freshPendingKey = /* @__PURE__ */ __name(() => `${Date.now()}-${globalThis.crypto.randomUUID().slice(0, 8)}`, "freshPendingKey");
4423
+ var CHILD_SIGNAL_PREFIX = "signal:child:";
4424
+ var DELIVER_CHUNK = 50;
4425
+ function childCompletion(child) {
4426
+ if (child.status === "completed") return {
4427
+ ok: true,
4428
+ value: child.output
4429
+ };
4430
+ if (child.status === "failed") return failedCompletion(child.error ?? {
4431
+ message: "failed"
4432
+ });
4433
+ if (child.status === "dead") return failedCompletion(child.error ?? {
4434
+ message: "dead"
4435
+ });
4436
+ if (child.status === "cancelled") return cancelledCompletion(child.error);
4437
+ return void 0;
4438
+ }
4439
+ __name(childCompletion, "childCompletion");
4440
+ var CHILD_CANCELLED = "cancelled";
4441
+ function cancelledCompletion(error2) {
4442
+ const base = error2 ?? {
4443
+ message: "cancelled"
4444
+ };
4445
+ return failedCompletion({
4446
+ ...base,
4447
+ code: base.code ?? CHILD_CANCELLED
4448
+ });
4449
+ }
4450
+ __name(cancelledCompletion, "cancelledCompletion");
4183
4451
  var RESULT_BATCH_MAX = 256;
4184
4452
  function shuffle(items) {
4185
4453
  for (let i = items.length - 1; i > 0; i -= 1) {
@@ -4356,6 +4624,35 @@ var WorkflowEngine = class {
4356
4624
  /** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
4357
4625
  inflight = /* @__PURE__ */ new Set();
4358
4626
  /**
4627
+ * Work a settled turn leaves running in the background (a parent notification, a post-release
4628
+ * re-drive), so a graceful shutdown waits for it too: dropping one on exit strands the run until
4629
+ * its reconcile timer.
4630
+ */
4631
+ background = /* @__PURE__ */ new Set();
4632
+ /**
4633
+ * Runs with an implicit wake executing on this instance, and whether another wake arrived for the
4634
+ * run meanwhile (see {@link resumeImplicit}).
4635
+ */
4636
+ wakeTurns = /* @__PURE__ */ new Map();
4637
+ /** Re-checks scheduled for runs whose wake found another instance's lease (see {@link execute}). */
4638
+ leaseRetries = /* @__PURE__ */ new Map();
4639
+ /**
4640
+ * Every run turn and child start this instance takes on itself, under one budget of
4641
+ * {@link LOCAL_RUN_CONCURRENCY}: the runs it dispatches to itself, a fan's deferred child starts,
4642
+ * enqueued pickups and the runs it takes off the transport's run queue.
4643
+ */
4644
+ localWork;
4645
+ /** The dispatcher the caller supplied, if any; without one the engine picks (see {@link dispatchRun}). */
4646
+ customDispatcher;
4647
+ /** Runs on their way to the transport's run queue (see {@link consumeRuns}). */
4648
+ runBatcher;
4649
+ /** Set while this engine consumes a transport's run queue, and so dispatches its runs to it. */
4650
+ dispatchesOverTransport = false;
4651
+ /** Stops for the run-queue consumers started by {@link consumeRuns}. */
4652
+ runConsumerStops = [];
4653
+ /** The consumers being stopped, so {@link drain} waits for their runs in flight. */
4654
+ runConsumersStopping;
4655
+ /**
4359
4656
  * The run lease of each execution in flight on THIS instance, by run id — renewed while it runs
4360
4657
  * and checked by every settle (the zombie fence, see {@link ExecutionLease}). Also what `drain()`
4361
4658
  * releases when its budget runs out.
@@ -4411,15 +4708,27 @@ var WorkflowEngine = class {
4411
4708
  this.rehydrate = deps.rehydrate ?? ((_carrier, fn) => fn());
4412
4709
  this.compensationRetries = Math.max(1, deps.compensationRetries ?? 1);
4413
4710
  this.trackStepStart = deps.trackStepStart ?? true;
4414
- this.runDispatcher = deps.runDispatcher ?? {
4415
- dispatch: /* @__PURE__ */ __name((runId) => queueMicrotask(() => void this.runOne(runId).catch(() => {
4416
- })), "dispatch")
4711
+ this.localWork = new BoundedRunner(LOCAL_RUN_CONCURRENCY);
4712
+ this.customDispatcher = deps.runDispatcher;
4713
+ this.runBatcher = new RunDispatchBatcher((runs) => this.pool.dispatchRuns(runs, {
4714
+ namespace: this.namespace
4715
+ }), (runs, err) => {
4716
+ for (const run of runs) {
4717
+ if (run.reason === "wake" && !this.draining) {
4718
+ this.inBackground(this.resumeImplicit(run.runId));
4719
+ }
4720
+ }
4721
+ console.error(`[nestjs-durable] could not queue ${runs.length} run(s) on the transport; starts stay pending for the next poll, wakes resume here:`, err);
4722
+ });
4723
+ this.runDispatcher = {
4724
+ dispatch: /* @__PURE__ */ __name((runId) => this.dispatchRun(runId), "dispatch")
4417
4725
  };
4418
4726
  this.singletons = new SingletonGate({
4419
4727
  store: this.store,
4420
4728
  clock: this.clock,
4421
4729
  dispatch: /* @__PURE__ */ __name((runId) => {
4422
- this.runDispatcher.dispatch(runId);
4730
+ if (this.queuesRunsOverTransport) void this.wake(runId);
4731
+ else this.runDispatcher.dispatch(runId);
4423
4732
  }, "dispatch"),
4424
4733
  configFor: /* @__PURE__ */ __name((run) => (this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? this.latest.get(run.workflow))?.singleton, "configFor")
4425
4734
  });
@@ -4544,13 +4853,224 @@ var WorkflowEngine = class {
4544
4853
  }
4545
4854
  /**
4546
4855
  * Be notified when a run is enqueued on ANOTHER instance (via the control plane), so a worker can
4547
- * pick it up at once — e.g. `engine.onEnqueued((runId) => engine.runOne(runId))`. Returns an
4856
+ * pick it up at once — e.g. `engine.onEnqueued((runId) => engine.runEnqueued(runId))`. Returns an
4548
4857
  * unsubscribe function. Only wire this on instances that should execute runs (workers).
4549
4858
  */
4550
4859
  onEnqueued(listener) {
4551
4860
  this.enqueuedListeners.add(listener);
4552
4861
  return () => this.enqueuedListeners.delete(listener);
4553
4862
  }
4863
+ /**
4864
+ * Pick up a run another instance announced as enqueued: run it here if it is still `pending`.
4865
+ * Bounded by {@link LOCAL_RUN_CONCURRENCY}, and a run another instance already took is skipped
4866
+ * after one read, instead of being leased and replayed here once more.
4867
+ */
4868
+ runEnqueued(runId) {
4869
+ this.localWork.submit(`run:${runId}`, () => this.runIfPending(runId));
4870
+ }
4871
+ /**
4872
+ * Consume the run queue of every transport that carries one ({@link Transport.dispatchRuns} and
4873
+ * {@link Transport.onDispatchedRun}), and from then on dispatch the runs this engine starts there
4874
+ * rather than executing them in-process: a run, a `ctx.all` fan's children included, executes on
4875
+ * whichever consuming instance takes it, at most {@link LOCAL_RUN_CONCURRENCY} at once per
4876
+ * instance. Call it on every instance that drives runs; the Nest module does on a driving operator.
4877
+ *
4878
+ * Returns a function that stops consuming and resolves once the runs in flight settled, after
4879
+ * which runs are executed in-process again; {@link beginDrain} stops consuming too. Undefined
4880
+ * when no transport carries a run queue: runs keep executing in-process.
4881
+ */
4882
+ consumeRuns() {
4883
+ if (!this.pool.dispatchesRuns) return void 0;
4884
+ if (this.runConsumerStops.length === 0) {
4885
+ this.runConsumerStops = this.pool.onDispatchedRun((run) => this.localWork.run(() => this.runFromQueue(run)), {
4886
+ concurrency: LOCAL_RUN_CONCURRENCY,
4887
+ namespace: this.namespace
4888
+ });
4889
+ }
4890
+ this.dispatchesOverTransport = true;
4891
+ return async () => {
4892
+ this.dispatchesOverTransport = false;
4893
+ await this.runBatcher.settle();
4894
+ await this.stopRunConsumers();
4895
+ };
4896
+ }
4897
+ /** Stop every run-queue consumer; resolves once their runs in flight settled. */
4898
+ stopRunConsumers() {
4899
+ if (this.runConsumerStops.length > 0) {
4900
+ const stops = this.runConsumerStops;
4901
+ this.runConsumerStops = [];
4902
+ this.runConsumersStopping = Promise.allSettled(stops.map((stop) => stop()));
4903
+ }
4904
+ return this.runConsumersStopping ?? Promise.resolve();
4905
+ }
4906
+ /** Whether {@link dispatchRun} queues runs on the transport (see {@link consumeRuns}). */
4907
+ get queuesRunsOverTransport() {
4908
+ return this.customDispatcher === void 0 && this.dispatchesOverTransport;
4909
+ }
4910
+ /**
4911
+ * Hand a `pending` run to whatever executes it: the caller's {@link WorkflowEngineDeps.runDispatcher};
4912
+ * else the transport's run queue while this engine consumes it, batched; else this instance,
4913
+ * bounded by {@link LOCAL_RUN_CONCURRENCY}. A run whose queueing fails stays `pending` for a
4914
+ * `runPending` poll.
4915
+ */
4916
+ dispatchRun(runId, priority, key) {
4917
+ if (this.customDispatcher) return this.customDispatcher.dispatch(runId);
4918
+ if (this.dispatchesOverTransport) {
4919
+ this.runBatcher.add({
4920
+ runId,
4921
+ reason: "start",
4922
+ ...priority !== void 0 ? {
4923
+ priority
4924
+ } : {},
4925
+ ...key !== void 0 ? {
4926
+ key
4927
+ } : {}
4928
+ });
4929
+ return;
4930
+ }
4931
+ this.localWork.submit(`run:${runId}`, () => this.runOne(runId));
4932
+ }
4933
+ /**
4934
+ * Wake a suspended run that has something new to see (a delivered signal or child completion, a
4935
+ * lease that came free, a freed slot): while this engine consumes the transport's run queue, on
4936
+ * whichever instance takes the wake (the result then only says it was handed over); otherwise
4937
+ * here, now.
4938
+ */
4939
+ wake(runId) {
4940
+ if (this.queuesRunsOverTransport) {
4941
+ this.runBatcher.add({
4942
+ runId,
4943
+ reason: "wake"
4944
+ });
4945
+ return Promise.resolve({
4946
+ runId,
4947
+ status: "suspended"
4948
+ });
4949
+ }
4950
+ return this.resumeImplicit(runId);
4951
+ }
4952
+ /**
4953
+ * Take a turn of a run taken off the transport's run queue. A `start` runs only while the run is
4954
+ * still `pending` (or `cancelling`, a recovered cancel): any other state means another instance
4955
+ * already took it. A `wake` leases and resumes the run unless it is finished. The run lease admits
4956
+ * one executor either way. Never rejects.
4957
+ */
4958
+ async runFromQueue(dispatch) {
4959
+ if (this.draining) return;
4960
+ const { runId } = dispatch;
4961
+ try {
4962
+ const run = await this.store.getRun(runId);
4963
+ if (!run) return;
4964
+ if (run.status === "pending" || run.status === "cancelling") {
4965
+ await this.runOne(runId);
4966
+ return;
4967
+ }
4968
+ if (dispatch.reason !== "wake" || TERMINAL_RUN_STATUSES.includes(run.status)) return;
4969
+ const nowMs = this.clock();
4970
+ if (run.wakeAt !== void 0 && run.wakeAt <= nowMs) {
4971
+ const waiters = indexWaitersByRun(await listSignalWaitersOfRuns(this.store, [
4972
+ runId
4973
+ ]));
4974
+ const resumed = await this.resumeLeased([
4975
+ run
4976
+ ], nowMs, (locked) => this.reconcileSignalWaiter(locked, waiters.get(locked.id)));
4977
+ if (resumed.length === 0) this.recheckAfterLease(runId, run.lockedUntil);
4978
+ return;
4979
+ }
4980
+ await this.resumeImplicit(runId).catch((err) => {
4981
+ if (!(err instanceof NamespaceMismatch)) throw err;
4982
+ });
4983
+ } catch (error2) {
4984
+ console.error(`[nestjs-durable] could not run queued run ${runId}:`, error2);
4985
+ }
4986
+ }
4987
+ /**
4988
+ * Hand due runs (timers, blocked runs due for a fleet re-check) to whichever instance takes them
4989
+ * off the run queue. Each is claimed first, under its lease, by pushing its `wakeAt` one lease
4990
+ * period out, so the next poll (here or elsewhere) does not queue it again while it waits; a wake
4991
+ * that is lost brings it back due after that period. False when the transport refused the wakes,
4992
+ * so the caller resumes them itself.
4993
+ */
4994
+ async handOffDue(runs, nowMs) {
4995
+ const claimed = [];
4996
+ for (const run of runs) {
4997
+ if (this.draining) break;
4998
+ if (!await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs)) {
4999
+ continue;
5000
+ }
5001
+ try {
5002
+ const patch = {
5003
+ wakeAt: nowMs + this.leaseMs
5004
+ };
5005
+ if (this.store.updateRunIf) {
5006
+ if (await this.store.updateRunIf(run.id, [
5007
+ run.status
5008
+ ], patch)) claimed.push(run);
5009
+ } else {
5010
+ await this.store.updateRun(run.id, patch);
5011
+ claimed.push(run);
5012
+ }
5013
+ } finally {
5014
+ await this.store.releaseRunLock(run.id);
5015
+ }
5016
+ }
5017
+ try {
5018
+ for (let at = 0; at < claimed.length; at += RUN_DISPATCH_BATCH) {
5019
+ await this.pool.dispatchRuns(claimed.slice(at, at + RUN_DISPATCH_BATCH).map((run) => ({
5020
+ runId: run.id,
5021
+ reason: "wake",
5022
+ ...run.priority !== void 0 ? {
5023
+ priority: run.priority
5024
+ } : {}
5025
+ })), {
5026
+ namespace: this.namespace
5027
+ });
5028
+ }
5029
+ return true;
5030
+ } catch (error2) {
5031
+ console.error("[nestjs-durable] could not queue due runs on the transport:", error2);
5032
+ return false;
5033
+ }
5034
+ }
5035
+ /**
5036
+ * Queue `runs` on the transport for whichever consuming instance takes them, in batches. False
5037
+ * when the transport refused them, so the caller executes them itself instead.
5038
+ */
5039
+ async queueOnTransport(runs) {
5040
+ try {
5041
+ for (let at = 0; at < runs.length; at += RUN_DISPATCH_BATCH) {
5042
+ await this.pool.dispatchRuns(runs.slice(at, at + RUN_DISPATCH_BATCH).map((run) => ({
5043
+ runId: run.id,
5044
+ reason: "start",
5045
+ key: pendingStateKey(run.updatedAt),
5046
+ ...run.priority !== void 0 ? {
5047
+ priority: run.priority
5048
+ } : {}
5049
+ })), {
5050
+ namespace: this.namespace
5051
+ });
5052
+ }
5053
+ return true;
5054
+ } catch (error2) {
5055
+ console.error("[nestjs-durable] could not queue runs on the transport; running them here:", error2);
5056
+ return false;
5057
+ }
5058
+ }
5059
+ /**
5060
+ * Execute a run announced by another instance, if it is still `pending`: a
5061
+ * run another instance already took is skipped after one read instead of being leased and
5062
+ * replayed here once more. Never rejects.
5063
+ */
5064
+ async runIfPending(runId) {
5065
+ if (this.draining) return;
5066
+ try {
5067
+ const run = await this.store.getRun(runId);
5068
+ if (run?.status !== "pending") return;
5069
+ await this.runOne(runId);
5070
+ } catch (error2) {
5071
+ console.error(`[nestjs-durable] could not run enqueued run ${runId}:`, error2);
5072
+ }
5073
+ }
4554
5074
  /** Fire cooperative-cancellation listeners for `runId` (a worker bridge aborts in-flight work). */
4555
5075
  notifyCancelled(runId) {
4556
5076
  for (const fn of this.cancelListeners) {
@@ -4946,8 +5466,8 @@ var WorkflowEngine = class {
4946
5466
  status: "suspended"
4947
5467
  };
4948
5468
  }
4949
- await this.runDispatcher.dispatch(runId);
4950
- if (this.controlPlane) {
5469
+ await this.dispatchRun(runId, run.priority, pendingStateKey(run.updatedAt));
5470
+ if (this.controlPlane && !this.queuesRunsOverTransport) {
4951
5471
  void this.controlPlane.publishControl({
4952
5472
  kind: "enqueued",
4953
5473
  runId,
@@ -4986,7 +5506,43 @@ var WorkflowEngine = class {
4986
5506
  * stale waiter or a late result left behind by a failed run silently re-executes its body.
4987
5507
  */
4988
5508
  resumeImplicit(runId) {
4989
- return this.resumeRun(runId, false);
5509
+ const turn = this.wakeTurns.get(runId);
5510
+ if (turn) {
5511
+ turn.again ??= deferred();
5512
+ if (currentWorkflowCtx()?.runId === runId) {
5513
+ return Promise.resolve({
5514
+ runId,
5515
+ status: "running"
5516
+ });
5517
+ }
5518
+ return turn.again.promise;
5519
+ }
5520
+ const mine = {};
5521
+ this.wakeTurns.set(runId, mine);
5522
+ const finish = /* @__PURE__ */ __name(() => {
5523
+ if (this.wakeTurns.get(runId) === mine) this.wakeTurns.delete(runId);
5524
+ const again = mine.again;
5525
+ if (!again) return;
5526
+ if (this.draining) {
5527
+ again.resolve({
5528
+ runId,
5529
+ status: "running"
5530
+ });
5531
+ return;
5532
+ }
5533
+ const next = this.resumeImplicit(runId);
5534
+ next.then(again.resolve, again.reject);
5535
+ this.inBackground(next);
5536
+ }, "finish");
5537
+ const result = this.resumeRun(runId, false);
5538
+ void result.then(finish, finish);
5539
+ return result;
5540
+ }
5541
+ /** Keep `work` in {@link background} until it settles; its failure is swallowed. */
5542
+ inBackground(work) {
5543
+ const tracked = work.catch(() => void 0);
5544
+ this.background.add(tracked);
5545
+ void tracked.then(() => this.background.delete(tracked));
4990
5546
  }
4991
5547
  /**
4992
5548
  * One execution of a run at a time in this process. A resume that lands while the run executes
@@ -5273,6 +5829,10 @@ var WorkflowEngine = class {
5273
5829
  */
5274
5830
  beginDrain(spentMs = 0) {
5275
5831
  this.draining = true;
5832
+ if (this.runConsumerStops.length > 0) {
5833
+ this.runBatcher.flush();
5834
+ void this.stopRunConsumers();
5835
+ }
5276
5836
  if (Number.isFinite(spentMs) && spentMs > 0) {
5277
5837
  this.drainSpentMs += spentMs;
5278
5838
  for (const rearm of this.drainRearms) rearm();
@@ -5293,7 +5853,14 @@ var WorkflowEngine = class {
5293
5853
  */
5294
5854
  async drain(timeoutMs = 1e4) {
5295
5855
  this.beginDrain();
5296
- if (this.inflight.size === 0) return;
5856
+ this.localWork.clear();
5857
+ for (const timer2 of this.leaseRetries.values()) clearTimeout(timer2);
5858
+ this.leaseRetries.clear();
5859
+ const consumers = this.runConsumersStopping;
5860
+ if (this.inflight.size === 0 && this.background.size === 0 && !consumers && this.runBatcher.idle) {
5861
+ return;
5862
+ }
5863
+ const queued = this.runBatcher.settle();
5297
5864
  const start = Date.now();
5298
5865
  let rearm = /* @__PURE__ */ __name(() => {
5299
5866
  }, "rearm");
@@ -5312,7 +5879,10 @@ var WorkflowEngine = class {
5312
5879
  try {
5313
5880
  await Promise.race([
5314
5881
  Promise.allSettled([
5315
- ...this.inflight
5882
+ ...this.inflight,
5883
+ ...this.background,
5884
+ queued,
5885
+ consumers
5316
5886
  ]).then(() => {
5317
5887
  settled = true;
5318
5888
  }),
@@ -5419,6 +5989,7 @@ var WorkflowEngine = class {
5419
5989
  error: error2
5420
5990
  });
5421
5991
  await this.cancelChildren(run.id).catch(() => void 0);
5992
+ this.notifyParent(run.id, cancelledCompletion(error2));
5422
5993
  }
5423
5994
  }
5424
5995
  }
@@ -5433,7 +6004,9 @@ var WorkflowEngine = class {
5433
6004
  if (this.draining) break;
5434
6005
  const outcome = this.isOwnLiveLease(listed, nowMs) ? await this.reclaimOwnLease(listed, nowMs) : await this.recoverOrphan(listed, nowMs);
5435
6006
  if (!outcome) continue;
5436
- if (outcome.dispatch) await this.runDispatcher.dispatch(outcome.result.runId);
6007
+ if (outcome.dispatch) {
6008
+ await this.dispatchRun(outcome.result.runId, listed.priority, freshPendingKey());
6009
+ }
5437
6010
  results.push(outcome.result);
5438
6011
  }
5439
6012
  return results;
@@ -5625,6 +6198,18 @@ var WorkflowEngine = class {
5625
6198
  const due = shuffle(await this.store.listDueTimers(nowMs, this.namespace, this.timerBatchSize));
5626
6199
  const blocked = await this.dueBlockedRuns(nowMs);
5627
6200
  if (due.length === 0 && blocked.length === 0) return [];
6201
+ if (this.queuesRunsOverTransport && !this.draining) {
6202
+ const runs = [
6203
+ ...due,
6204
+ ...blocked
6205
+ ];
6206
+ if (await this.handOffDue(runs, nowMs)) {
6207
+ return runs.map((run) => ({
6208
+ runId: run.id,
6209
+ status: run.status
6210
+ }));
6211
+ }
6212
+ }
5628
6213
  const waiterByRun = indexWaitersByRun(await listSignalWaitersOfRuns(this.store, [
5629
6214
  ...due,
5630
6215
  ...blocked
@@ -5658,7 +6243,7 @@ var WorkflowEngine = class {
5658
6243
  const buffered = await this.store.takeBufferedSignal(waiter.token);
5659
6244
  if (!buffered) return void 0;
5660
6245
  await this.store.removeSignalWaiter(waiter);
5661
- const settled = await this.deliverSignal(waiter, buffered.payload);
6246
+ const settled = await this.deliverSignal(waiter, buffered.payload, true);
5662
6247
  return settled ?? {
5663
6248
  runId: run.id,
5664
6249
  status: run.status
@@ -5684,7 +6269,7 @@ var WorkflowEngine = class {
5684
6269
  if (!eventMatches(candidate.payload, match)) continue;
5685
6270
  if (!await this.store.removeBufferedEvent(candidate.id)) continue;
5686
6271
  await this.store.removeSignalWaiter(waiter);
5687
- const settled = await this.deliverSignal(waiter, candidate.payload);
6272
+ const settled = await this.deliverSignal(waiter, candidate.payload, true);
5688
6273
  return settled ?? {
5689
6274
  runId: run.id,
5690
6275
  status: run.status
@@ -5804,7 +6389,7 @@ var WorkflowEngine = class {
5804
6389
  } : {},
5805
6390
  updatedAt: /* @__PURE__ */ new Date()
5806
6391
  });
5807
- await this.runDispatcher.dispatch(runId);
6392
+ await this.dispatchRun(runId, run.priority, freshPendingKey());
5808
6393
  return {
5809
6394
  runId,
5810
6395
  status: "pending"
@@ -5879,7 +6464,16 @@ var WorkflowEngine = class {
5879
6464
  * {@link resumeDueTimers}.
5880
6465
  */
5881
6466
  async runPending(nowMs = this.clock()) {
5882
- return this.resumeLeased(await this.store.listPendingRuns(100, this.namespace), nowMs);
6467
+ const pending = await this.store.listPendingRuns(100, this.namespace);
6468
+ if (this.queuesRunsOverTransport && !this.draining && pending.length > 0) {
6469
+ if (await this.queueOnTransport(pending)) {
6470
+ return pending.map((run) => ({
6471
+ runId: run.id,
6472
+ status: run.status
6473
+ }));
6474
+ }
6475
+ }
6476
+ return this.resumeLeased(pending, nowMs);
5883
6477
  }
5884
6478
  /**
5885
6479
  * Resolve once `runId` reaches a settled state — terminal (completed/failed/cancelled/dead) or
@@ -6056,7 +6650,7 @@ var WorkflowEngine = class {
6056
6650
  * due-timer reconcile sweep ({@link reconcileSignalWaiter}) — all three resolve a waiter the exact
6057
6651
  * same way.
6058
6652
  */
6059
- async deliverSignal(waiter, payload) {
6653
+ async deliverSignal(waiter, payload, resumeHere = false) {
6060
6654
  await this.store.saveCheckpoint(instantCheckpoint({
6061
6655
  runId: waiter.runId,
6062
6656
  seq: waiter.seq,
@@ -6075,7 +6669,7 @@ var WorkflowEngine = class {
6075
6669
  status: "suspended"
6076
6670
  };
6077
6671
  }
6078
- return this.resumeImplicit(waiter.runId).catch((err) => {
6672
+ return (resumeHere ? this.resumeImplicit(waiter.runId) : this.wake(waiter.runId)).catch((err) => {
6079
6673
  if (err instanceof NamespaceMismatch) return null;
6080
6674
  throw err;
6081
6675
  });
@@ -6162,6 +6756,101 @@ var WorkflowEngine = class {
6162
6756
  return;
6163
6757
  }
6164
6758
  }
6759
+ /**
6760
+ * Deliver the outcome of every awaited child that already finished but whose completion never
6761
+ * reached this run, into `checkpoints` (this turn's snapshot) and the store.
6762
+ *
6763
+ * A child persists its terminal status first and tells its parent after ({@link notifyParent}). A
6764
+ * crash, a scale-down or a store error between the two leaves the parent's `running` placeholder
6765
+ * and `child:<id>` waiter behind for a child that is done, and nothing else would ever settle
6766
+ * them: the parent's reconcile wake replays straight back to the same wait. So each turn looks up
6767
+ * the children of its `running` placeholders (one batched read per 500) and settles the finished
6768
+ * ones exactly as the child's own notification would have: the `signal:child:<id>` checkpoint
6769
+ * with the child's completion, then the waiter removed. A notification arriving late writes the
6770
+ * same checkpoint and finds no waiter. Best effort: a store error leaves the rest to the next turn.
6771
+ */
6772
+ async deliverFinishedChildren(runId, checkpoints) {
6773
+ const awaited = [];
6774
+ for (const cp of checkpoints) {
6775
+ if (cp.kind !== "signal" || cp.status !== "running") continue;
6776
+ if (!cp.name.startsWith(CHILD_SIGNAL_PREFIX)) continue;
6777
+ awaited.push({
6778
+ childId: cp.name.slice(CHILD_SIGNAL_PREFIX.length),
6779
+ seq: cp.seq,
6780
+ parallelGroup: cp.parallelGroup
6781
+ });
6782
+ }
6783
+ await this.settleFinishedChildren(runId, awaited, checkpoints);
6784
+ }
6785
+ /**
6786
+ * {@link deliverFinishedChildren} for a remote workflow, whose awaited children (`startChild`
6787
+ * commands) leave a `child:<id>` waiter but no placeholder checkpoint: the run's own waiters say
6788
+ * which children it is waiting on. One indexed read per turn of a remote run.
6789
+ */
6790
+ async deliverFinishedChildWaiters(runId, checkpoints) {
6791
+ let waiters;
6792
+ try {
6793
+ waiters = await listSignalWaitersOfRuns(this.store, [
6794
+ runId
6795
+ ]);
6796
+ } catch {
6797
+ return;
6798
+ }
6799
+ const settled = new Set(checkpoints.filter((cp) => cp.status === "completed").map((cp) => cp.seq));
6800
+ const awaited = waiters.filter((w) => w.token.startsWith("child:") && !settled.has(w.seq)).map((w) => ({
6801
+ childId: w.token.slice("child:".length),
6802
+ seq: w.seq,
6803
+ parallelGroup: w.parallelGroup
6804
+ }));
6805
+ await this.settleFinishedChildren(runId, awaited, checkpoints);
6806
+ }
6807
+ /**
6808
+ * Settle every child in `awaited` that has finished: its `signal:child:<id>` checkpoint with the
6809
+ * child's completion, then its waiter removed, exactly as the child's own notification does, and
6810
+ * the checkpoint put into `checkpoints`. Children are read in batches of 500.
6811
+ */
6812
+ async settleFinishedChildren(runId, awaited, checkpoints) {
6813
+ if (awaited.length === 0) return;
6814
+ try {
6815
+ const { runs: children } = await readRunsByIds(this.store, awaited.map((a) => a.childId));
6816
+ const finished = awaited.flatMap((a) => {
6817
+ const child = children.get(a.childId);
6818
+ const completion = child ? childCompletion(child) : void 0;
6819
+ return completion ? [
6820
+ {
6821
+ ...a,
6822
+ completion
6823
+ }
6824
+ ] : [];
6825
+ });
6826
+ for (let at = 0; at < finished.length; at += DELIVER_CHUNK) {
6827
+ await Promise.all(finished.slice(at, at + DELIVER_CHUNK).map(async (a) => {
6828
+ const settled = instantCheckpoint({
6829
+ runId,
6830
+ seq: a.seq,
6831
+ name: `${CHILD_SIGNAL_PREFIX}${a.childId}`,
6832
+ kind: "signal",
6833
+ output: a.completion,
6834
+ parallelGroup: a.parallelGroup
6835
+ });
6836
+ await this.store.saveCheckpoint(settled);
6837
+ await this.store.removeSignalWaiter({
6838
+ token: `child:${a.childId}`,
6839
+ runId,
6840
+ seq: a.seq,
6841
+ ...a.parallelGroup !== void 0 ? {
6842
+ parallelGroup: a.parallelGroup
6843
+ } : {}
6844
+ });
6845
+ const index = checkpoints.findIndex((cp) => cp.seq === a.seq);
6846
+ if (index === -1) checkpoints.push(settled);
6847
+ else checkpoints[index] = settled;
6848
+ }));
6849
+ }
6850
+ checkpoints.sort((a, b) => a.seq - b.seq);
6851
+ } catch {
6852
+ }
6853
+ }
6165
6854
  async redriveSettledChildren(runId, fan) {
6166
6855
  if (!this.store.hasSignalWaiter) return;
6167
6856
  for (const wait of fan.waits) {
@@ -6260,13 +6949,63 @@ var WorkflowEngine = class {
6260
6949
  * knowing about the child feature.
6261
6950
  */
6262
6951
  notifyParent(runId, completion) {
6263
- void this.signal(`child:${runId}`, completion).catch(() => void 0);
6952
+ this.inBackground(this.deliverToParent(`child:${runId}`, completion));
6264
6953
  const at = runId.lastIndexOf("~retry~");
6265
6954
  if (at !== -1 && completion.ok) {
6266
- void this.signal(`child:${runId.slice(0, at)}`, completion).catch(() => void 0);
6955
+ this.inBackground(this.deliverToParent(`child:${runId.slice(0, at)}`, completion));
6267
6956
  }
6268
6957
  }
6269
6958
  /**
6959
+ * {@link signal} a child's completion to its parent, retrying a store error a few times. The child
6960
+ * is already persisted terminal, so a failure here would leave the parent waiting on a child that
6961
+ * finished; the parent's own turns reconcile that ({@link deliverFinishedChildren}), but only on
6962
+ * its next wake. A waiter already taken is delivered to directly on a retry, so the completion is
6963
+ * not buffered for a waiter that no longer exists.
6964
+ */
6965
+ async deliverToParent(token, completion) {
6966
+ let waiter;
6967
+ const cancelled = !completion.ok && completion.code === CHILD_CANCELLED;
6968
+ for (let attempt = 1; ; attempt += 1) {
6969
+ try {
6970
+ if (waiter === void 0) waiter = await this.store.takeSignalWaiter(token);
6971
+ if (waiter) {
6972
+ const parent = cancelled ? await this.store.getRun(waiter.runId) : void 0;
6973
+ if (parent && TERMINAL_RUN_STATUSES.includes(parent.status)) return;
6974
+ await this.deliverSignal(waiter, completion);
6975
+ return;
6976
+ }
6977
+ if (cancelled || await this.parentHoldsCompletion(token, completion)) return;
6978
+ await this.signal(token, completion);
6979
+ return;
6980
+ } catch (err) {
6981
+ if (err instanceof NamespaceMismatch) return;
6982
+ if (attempt >= PARENT_NOTIFY_ATTEMPTS || this.draining) return;
6983
+ await new Promise((resolve) => {
6984
+ const timer = setTimeout(resolve, PARENT_NOTIFY_BACKOFF_MS * 4 ** (attempt - 1));
6985
+ timer.unref?.();
6986
+ });
6987
+ }
6988
+ }
6989
+ }
6990
+ /**
6991
+ * Whether the parent of the child behind `token` (`child:<id>`) already holds `completion` as that
6992
+ * child's settled `signal:child:<id>` checkpoint: its own turn settled the child from the child's
6993
+ * row, so this notification is a duplicate. A different completion (a fix-and-replay run's success
6994
+ * for a child the parent saw fail) is not, and is buffered for the parent's retry. Only for a child
6995
+ * whose id the engine minted (its parent is derivable from it).
6996
+ */
6997
+ async parentHoldsCompletion(token, completion) {
6998
+ const childId = token.slice("child:".length);
6999
+ const parentId = parentRunIdOf(childId);
7000
+ if (!parentId) return false;
7001
+ const name = `${CHILD_SIGNAL_PREFIX}${childId}`;
7002
+ const checkpoints = this.store.listCheckpointsByNamePrefix ? await this.store.listCheckpointsByNamePrefix(parentId, [
7003
+ name
7004
+ ]) : await this.store.listCheckpoints(parentId);
7005
+ const expected = JSON.stringify(completion);
7006
+ return checkpoints.some((cp) => cp.name === name && cp.status === "completed" && JSON.stringify(cp.output) === expected);
7007
+ }
7008
+ /**
6270
7009
  * Deferred child start shared by the in-process ctx host and the remote `startChild` command.
6271
7010
  * Deferred (microtask) so a fast child can't reentrantly resume a still-suspending parent. A start
6272
7011
  * that THROWS (unregistered/unroutable workflow, input validation, singleton back-pressure, store
@@ -6279,13 +7018,16 @@ var WorkflowEngine = class {
6279
7018
  * correctly observes the failed start.
6280
7019
  */
6281
7020
  startChildDeferred(workflow, input, childId, opts) {
6282
- queueMicrotask(() => void this.start(workflow, input, childId, opts).catch((err) => {
6283
- const message = err instanceof Error ? err.message : String(err);
6284
- this.notifyParent(childId, {
6285
- ok: false,
6286
- error: `child workflow "${workflow}" failed to start: ${message}`
7021
+ this.localWork.submit(`start:${childId}`, async () => {
7022
+ if (this.draining) return;
7023
+ await this.start(workflow, input, childId, opts).catch((err) => {
7024
+ const message = err instanceof Error ? err.message : String(err);
7025
+ this.notifyParent(childId, {
7026
+ ok: false,
7027
+ error: `child workflow "${workflow}" failed to start: ${message}`
7028
+ });
6287
7029
  });
6288
- }));
7030
+ });
6289
7031
  }
6290
7032
  /**
6291
7033
  * Cancel a run (e.g. from the dashboard). Returns null if the run does not exist. Pass
@@ -6349,6 +7091,7 @@ var WorkflowEngine = class {
6349
7091
  error: error2
6350
7092
  });
6351
7093
  await this.cancelChildren(runId, opts);
7094
+ this.notifyParent(runId, cancelledCompletion(error2));
6352
7095
  this.notifyCancelled(runId);
6353
7096
  if (this.controlPlane) {
6354
7097
  void this.controlPlane.publishControl({
@@ -6863,6 +7606,7 @@ var WorkflowEngine = class {
6863
7606
  const lockNow = this.clock();
6864
7607
  if (run.lockedBy !== this.instanceId || (run.lockedUntil ?? 0) <= lockNow) {
6865
7608
  if (!await this.store.tryLockRun(run.id, this.instanceId, lockNow + this.leaseMs, lockNow)) {
7609
+ this.recheckAfterLease(run.id, run.lockedUntil);
6866
7610
  return {
6867
7611
  runId: run.id,
6868
7612
  status: run.status
@@ -6881,6 +7625,51 @@ var WorkflowEngine = class {
6881
7625
  }
6882
7626
  }
6883
7627
  /**
7628
+ * A wake that found the run leased by another instance is dropped, and normally that is fine: the
7629
+ * holder's turn either sees what the wake wrote, or re-checks once it releases the lease
7630
+ * ({@link redriveSettledSteps}, {@link redriveSettledChildren}). Not if the holder goes down first
7631
+ * (a scale-down mid-turn, an OOM): its lease lapses with the run `suspended` on a step that has
7632
+ * finished, and nothing wakes it before its timer.
7633
+ *
7634
+ * So the dropped wake is kept here: once the lease is free (looked at every
7635
+ * {@link LEASE_RECHECK_MAX_MS} at most, for a bounded number of times), a run still `suspended` is
7636
+ * leased and resumed. A holder that already moved the run on makes this one spare replay that
7637
+ * re-parks. Best effort; the run's timer remains the fallback if this instance goes down too.
7638
+ */
7639
+ recheckAfterLease(runId, lockedUntil, attempt = 1) {
7640
+ if (this.draining || this.leaseRetries.has(runId)) return;
7641
+ const remaining = Math.max(0, (lockedUntil ?? this.clock()) - this.clock());
7642
+ const timer = setTimeout(() => {
7643
+ this.leaseRetries.delete(runId);
7644
+ this.inBackground(this.retryDroppedWake(runId, attempt));
7645
+ }, Math.min(remaining, LEASE_RECHECK_MAX_MS) + LEASE_RECHECK_SLACK_MS);
7646
+ timer.unref?.();
7647
+ this.leaseRetries.set(runId, timer);
7648
+ }
7649
+ async retryDroppedWake(runId, attempt) {
7650
+ if (this.draining) return;
7651
+ const run = await this.store.getRun(runId);
7652
+ if (run?.status !== "suspended") return;
7653
+ const nowMs = this.clock();
7654
+ if (run.lockedUntil !== void 0 && run.lockedUntil > nowMs) {
7655
+ if (attempt < LEASE_RECHECK_ATTEMPTS) this.recheckAfterLease(runId, run.lockedUntil, attempt + 1);
7656
+ return;
7657
+ }
7658
+ if (this.queuesRunsOverTransport) {
7659
+ await this.wake(runId);
7660
+ return;
7661
+ }
7662
+ if (!await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs)) return;
7663
+ const fresh = await this.store.getRun(runId);
7664
+ if (fresh?.status !== "suspended") {
7665
+ await this.store.releaseRunLock(runId);
7666
+ return;
7667
+ }
7668
+ await this.resumeImplicit(runId).catch((err) => {
7669
+ if (!(err instanceof NamespaceMismatch)) throw err;
7670
+ });
7671
+ }
7672
+ /**
6884
7673
  * Renew `runId`'s lease for as long as this execution holds it, and register it in
6885
7674
  * {@link executionLeases} so settles can check the zombie fence and `drain()` can release it.
6886
7675
  *
@@ -7244,6 +8033,7 @@ ${run.workflowVersion}`;
7244
8033
  namespace: run.namespace,
7245
8034
  error: error2
7246
8035
  });
8036
+ this.notifyParent(run.id, cancelledCompletion(error2));
7247
8037
  this.notifyCancelled(run.id);
7248
8038
  if (this.controlPlane) {
7249
8039
  void this.controlPlane.publishControl({
@@ -7275,6 +8065,8 @@ ${run.workflowVersion}`;
7275
8065
  const wfPlan = await this.planRoute(tenantGroup(sanitizeQueueToken(remote.group), void 0), registered.requires);
7276
8066
  if (wfPlan?.status === "blocked") return this.parkBlocked(run, wfPlan);
7277
8067
  const checkpoints = await this.store.listCheckpoints(run.id);
8068
+ await this.deliverFinishedChildren(run.id, checkpoints);
8069
+ await this.deliverFinishedChildWaiters(run.id, checkpoints);
7278
8070
  const handoff = liveHandoff(run.id, checkpoints);
7279
8071
  if (handoff) {
7280
8072
  const result = await this.settleRun(run, await this.followHandoff(run, handoff));
@@ -7849,6 +8641,7 @@ ${run.workflowVersion}`;
7849
8641
  namespace: run.namespace,
7850
8642
  error: error2
7851
8643
  });
8644
+ this.notifyParent(run.id, cancelledCompletion(error2));
7852
8645
  return {
7853
8646
  runId: run.id,
7854
8647
  status: "cancelled",
@@ -7895,6 +8688,7 @@ ${run.workflowVersion}`;
7895
8688
  }
7896
8689
  const compensations = [];
7897
8690
  const snapshot = await this.store.listCheckpoints(run.id);
8691
+ await this.deliverFinishedChildren(run.id, snapshot);
7898
8692
  const replay = /* @__PURE__ */ new Map();
7899
8693
  for (const cp of snapshot) replay.set(cp.seq, cp);
7900
8694
  let suspendedOnFan;
@@ -7991,13 +8785,13 @@ ${run.workflowVersion}`;
7991
8785
  if (this.awaitedStepFans.get(run.id) === awaitedFans) this.awaitedStepFans.delete(run.id);
7992
8786
  await this.releaseLeaseBestEffort(run.id);
7993
8787
  if (suspendedOnFan) {
7994
- void this.redriveSettledChildren(run.id, suspendedOnFan).catch(() => void 0);
8788
+ this.inBackground(this.redriveSettledChildren(run.id, suspendedOnFan));
7995
8789
  }
7996
8790
  if (suspendedOnSteps && awaitedSteps.size > 0) {
7997
- void this.redriveSettledSteps(run.id, awaitedSteps).catch(() => void 0);
8791
+ this.inBackground(this.redriveSettledSteps(run.id, awaitedSteps));
7998
8792
  }
7999
8793
  if (suspendedOnSteps && awaitedFans.length > 0) {
8000
- void this.redriveSettledStepFans(run.id, awaitedFans).catch(() => void 0);
8794
+ this.inBackground(this.redriveSettledStepFans(run.id, awaitedFans));
8001
8795
  }
8002
8796
  }
8003
8797
  }
@@ -8437,9 +9231,11 @@ ${run.workflowVersion}`;
8437
9231
  })) {
8438
9232
  return this.callRemote(runId, seq, step, input, queue, transport, void 0, admission, routing);
8439
9233
  }
8440
- throw new WorkflowSuspended(wakeAt);
9234
+ throw new WorkflowSuspended(this.stepLeaseWakeAt(wakeAt));
9235
+ }
9236
+ if (this.clock() < existing.wakeAt) {
9237
+ throw new WorkflowSuspended(this.stepLeaseWakeAt(existing.wakeAt));
8441
9238
  }
8442
- if (this.clock() < existing.wakeAt) throw new WorkflowSuspended(existing.wakeAt);
8443
9239
  if (existing.attempts >= this.remoteRedispatchMax) {
8444
9240
  throw new RemoteStepError({
8445
9241
  message: `remote step "${step.name}" lost \u2014 no result after ${existing.attempts} re-dispatch(es)`,
@@ -8485,7 +9281,7 @@ ${run.workflowVersion}`;
8485
9281
  kind: "remote",
8486
9282
  redispatched: true
8487
9283
  });
8488
- throw new WorkflowSuspended(nextDeadline);
9284
+ throw new WorkflowSuspended(this.stepLeaseWakeAt(nextDeadline));
8489
9285
  }
8490
9286
  let attempt = 1;
8491
9287
  if (existing?.status === "failed") {
@@ -8848,6 +9644,7 @@ ${run.workflowVersion}`;
8848
9644
  });
8849
9645
  const reads = await Promise.allSettled(live.map((i) => this.store.getCheckpoint(runId, results[i].seq)));
8850
9646
  const pending = [];
9647
+ const resettled = [];
8851
9648
  reads.forEach((read, k) => {
8852
9649
  const index = live[k];
8853
9650
  if (read.status === "rejected") outcomes[index] = {
@@ -8857,8 +9654,17 @@ ${run.workflowVersion}`;
8857
9654
  index,
8858
9655
  cp: read.value
8859
9656
  });
9657
+ else if (read.value?.status === "completed" || read.value?.status === "failed") {
9658
+ resettled.push({
9659
+ index,
9660
+ cp: read.value
9661
+ });
9662
+ }
8860
9663
  });
8861
- if (pending.length === 0) return outcomes;
9664
+ if (pending.length === 0) {
9665
+ if (resettled.length === 0) return outcomes;
9666
+ return this.resumeForRedelivered(runId, resettled, outcomes);
9667
+ }
8862
9668
  for (const { cp } of pending) void this.releaseQueueSlot(cp.stepId).catch(() => void 0);
8863
9669
  const run = await this.store.getRun(runId);
8864
9670
  if (run && (run.status === "cancelled" || run.status === "completed")) return outcomes;
@@ -8910,6 +9716,29 @@ ${run.workflowVersion}`;
8910
9716
  return outcomes;
8911
9717
  }
8912
9718
  /**
9719
+ * The outcomes for a batch of redelivered results (their steps already settled): one of them
9720
+ * resumes the run if it is `suspended` and, for a step of a fan, the fan's barrier is open. A
9721
+ * resume of a run that had in fact moved on replays to its current wait and re-parks.
9722
+ */
9723
+ async resumeForRedelivered(runId, resettled, outcomes) {
9724
+ const run = await this.store.getRun(runId);
9725
+ if (run?.status !== "suspended") return outcomes;
9726
+ const barriers = /* @__PURE__ */ new Map();
9727
+ for (const { index, cp } of resettled) {
9728
+ const asResult = {
9729
+ runId,
9730
+ seq: cp.seq,
9731
+ stepId: cp.stepId ?? stepId(runId, cp.seq),
9732
+ status: cp.status === "failed" ? "failed" : "completed"
9733
+ };
9734
+ if (!await this.stepFanStillRunning(cp, asResult, barriers)) {
9735
+ outcomes[index] = true;
9736
+ break;
9737
+ }
9738
+ }
9739
+ return outcomes;
9740
+ }
9741
+ /**
8913
9742
  * The join barrier of a step fan: a result for one of its steps resumes the parent only once no
8914
9743
  * step of the fan is still `pending` (a settled wave), or on a failure (failFast surfaces it, and a
8915
9744
  * retry needs the parent to schedule it). Without it every result would replay the whole parent,
@@ -9033,7 +9862,7 @@ ${run.workflowVersion}`;
9033
9862
  ...waiters
9034
9863
  ];
9035
9864
  waiters.clear();
9036
- for (const runId of runIds) void this.resumeImplicit(runId).catch(() => void 0);
9865
+ for (const runId of runIds) void this.wake(runId).catch(() => void 0);
9037
9866
  }
9038
9867
  /** Release the flow-control slot a dispatched step held (if any), by its stepId. */
9039
9868
  async releaseQueueSlot(id) {
@@ -9775,6 +10604,8 @@ var CodecStateStore = class {
9775
10604
  registerChildWaits;
9776
10605
  /** The folded checkpoint carries the children's outputs, so it is encoded. */
9777
10606
  compactCheckpoints;
10607
+ /** Decodes each run like {@link getRun}. Bound only when the inner store has it. */
10608
+ getRuns;
9778
10609
  /** Forwarded verbatim — a waiter carries no payload. */
9779
10610
  listSignalWaitersByRun;
9780
10611
  /** Forwarded verbatim — a waiter carries no payload. */
@@ -9818,6 +10649,8 @@ var CodecStateStore = class {
9818
10649
  })));
9819
10650
  const compact = inner.compactCheckpoints;
9820
10651
  if (compact) this.compactCheckpoints = (runId, fromSeq, toSeq, into) => compact.call(inner, runId, fromSeq, toSeq, this.encCp(into));
10652
+ const getRuns = inner.getRuns;
10653
+ if (getRuns) this.getRuns = async (runIds) => (await getRuns.call(inner, runIds)).map((r) => this.decRun(r));
9821
10654
  const byRun = inner.listSignalWaitersByRun;
9822
10655
  if (byRun) this.listSignalWaitersByRun = (runIds) => byRun.call(inner, runIds);
9823
10656
  const hasWaiter = inner.hasSignalWaiter;
@@ -10370,6 +11203,16 @@ var InMemoryStateStore = class {
10370
11203
  ...run
10371
11204
  } : null;
10372
11205
  }
11206
+ async getRuns(runIds) {
11207
+ const out = [];
11208
+ for (const id of new Set(runIds)) {
11209
+ const run = this.runs.get(id);
11210
+ if (run) out.push({
11211
+ ...run
11212
+ });
11213
+ }
11214
+ return out;
11215
+ }
10373
11216
  async deleteRun(runId) {
10374
11217
  this.runs.delete(runId);
10375
11218
  for (const [key, cp] of this.checkpoints) {
@@ -10821,6 +11664,99 @@ var InMemoryTransport = class {
10821
11664
  this.controlHandlers.add(handler);
10822
11665
  }
10823
11666
  };
11667
+
11668
+ // src/testing/in-memory-run-queue.ts
11669
+ var InMemoryRunQueue = class {
11670
+ static {
11671
+ __name(this, "InMemoryRunQueue");
11672
+ }
11673
+ lanes = /* @__PURE__ */ new Map();
11674
+ /** A transport on this queue. */
11675
+ transport() {
11676
+ return {
11677
+ dispatch: /* @__PURE__ */ __name(async (_task) => {
11678
+ throw new Error("InMemoryRunQueue carries no remote steps");
11679
+ }, "dispatch"),
11680
+ onResult: /* @__PURE__ */ __name((_handler) => {
11681
+ }, "onResult"),
11682
+ onHeartbeat: /* @__PURE__ */ __name((_handler) => {
11683
+ }, "onHeartbeat"),
11684
+ dispatchRuns: /* @__PURE__ */ __name(async (runs, opts) => this.add(runs, opts), "dispatchRuns"),
11685
+ onDispatchedRun: /* @__PURE__ */ __name((handler, opts) => this.consume(handler, opts), "onDispatchedRun")
11686
+ };
11687
+ }
11688
+ /** Runs waiting in the queue of `namespace`. */
11689
+ waiting(namespace) {
11690
+ return (this.lanes.get(namespace ?? "")?.waiting ?? []).map((r) => r.runId);
11691
+ }
11692
+ lane(namespace) {
11693
+ const key = namespace ?? "";
11694
+ let lane = this.lanes.get(key);
11695
+ if (!lane) {
11696
+ lane = {
11697
+ waiting: [],
11698
+ queued: /* @__PURE__ */ new Map(),
11699
+ consumers: [],
11700
+ next: 0
11701
+ };
11702
+ this.lanes.set(key, lane);
11703
+ }
11704
+ return lane;
11705
+ }
11706
+ add(runs, opts) {
11707
+ const lane = this.lane(opts?.namespace);
11708
+ for (const run of runs) {
11709
+ const waiting = lane.queued.get(run.runId);
11710
+ if (waiting) {
11711
+ if (run.reason === "wake") waiting.reason = "wake";
11712
+ continue;
11713
+ }
11714
+ const entry = {
11715
+ ...run
11716
+ };
11717
+ lane.queued.set(run.runId, entry);
11718
+ lane.waiting.push(entry);
11719
+ }
11720
+ lane.waiting.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
11721
+ setImmediate(() => this.pump(lane));
11722
+ }
11723
+ consume(handler, opts) {
11724
+ const lane = this.lane(opts.namespace);
11725
+ const consumer = {
11726
+ handler,
11727
+ concurrency: Math.max(1, opts.concurrency),
11728
+ active: 0,
11729
+ stopped: false,
11730
+ idle: []
11731
+ };
11732
+ lane.consumers.push(consumer);
11733
+ setImmediate(() => this.pump(lane));
11734
+ return () => {
11735
+ consumer.stopped = true;
11736
+ lane.consumers = lane.consumers.filter((c) => c !== consumer);
11737
+ if (consumer.active === 0) return Promise.resolve();
11738
+ return new Promise((resolve) => consumer.idle.push(resolve));
11739
+ };
11740
+ }
11741
+ pump(lane) {
11742
+ while (lane.waiting.length > 0) {
11743
+ const free = lane.consumers.filter((c) => !c.stopped && c.active < c.concurrency);
11744
+ if (free.length === 0) return;
11745
+ const consumer = free[lane.next % free.length];
11746
+ lane.next += 1;
11747
+ const run = lane.waiting.shift();
11748
+ lane.queued.delete(run.runId);
11749
+ consumer.active += 1;
11750
+ void consumer.handler(run).catch(() => void 0).then(() => {
11751
+ consumer.active -= 1;
11752
+ if (consumer.active === 0 && consumer.stopped) {
11753
+ for (const resolve of consumer.idle.splice(0)) resolve();
11754
+ }
11755
+ this.pump(lane);
11756
+ });
11757
+ }
11758
+ }
11759
+ };
10824
11760
  export {
10825
11761
  CHILD_EDGE_PREFIXES,
10826
11762
  CONTROL_FLOW_SIGNAL,
@@ -10847,6 +11783,7 @@ export {
10847
11783
  GatherError,
10848
11784
  HANDOFF_SEQ_BASE,
10849
11785
  InMemoryAdmissionBackend,
11786
+ InMemoryRunQueue,
10850
11787
  InMemoryStateStore,
10851
11788
  InMemoryTransport,
10852
11789
  LEGACY_V1_CAPABILITIES,
@@ -10931,6 +11868,7 @@ export {
10931
11868
  fanPageEtag,
10932
11869
  findStalledRuns,
10933
11870
  foldedCount,
11871
+ getRunsByIds,
10934
11872
  handoffHandlerId,
10935
11873
  handoffName,
10936
11874
  hasDurableExecutionWrappers,
@@ -10968,6 +11906,7 @@ export {
10968
11906
  poolNamespace,
10969
11907
  prevCronFireMs,
10970
11908
  queryCheckpointsOf,
11909
+ readRunsByIds,
10971
11910
  readSearchAttributes,
10972
11911
  requiredCapabilities,
10973
11912
  resolveConcurrency,