@dudousxd/nestjs-durable-core 0.84.1 → 0.85.1

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";
@@ -2241,7 +2417,31 @@ __name(resolveCronParse, "resolveCronParse");
2241
2417
 
2242
2418
  // src/scheduler.ts
2243
2419
  var nodeRequire = createRequire(import.meta.url);
2244
- var defaultSleep = /* @__PURE__ */ __name((ms) => new Promise((r) => setTimeout(r, ms)), "defaultSleep");
2420
+ var jitterFireAt = /* @__PURE__ */ new WeakMap();
2421
+ function deferredWindows(engine, key) {
2422
+ return [
2423
+ ...jitterFireAt.get(engine)?.get(key)?.keys() ?? []
2424
+ ];
2425
+ }
2426
+ __name(deferredWindows, "deferredWindows");
2427
+ function jitterDue(engine, s, candidates, nowMs, random) {
2428
+ let byKey = jitterFireAt.get(engine);
2429
+ if (byKey === void 0) {
2430
+ byKey = /* @__PURE__ */ new Map();
2431
+ jitterFireAt.set(engine, byKey);
2432
+ }
2433
+ const previous = byKey.get(s.key);
2434
+ const current = /* @__PURE__ */ new Map();
2435
+ const due = [];
2436
+ for (const runId of candidates) {
2437
+ const at = previous?.get(runId) ?? nowMs + Math.floor(random() * s.jitter);
2438
+ if (nowMs >= at) due.push(runId);
2439
+ else current.set(runId, at);
2440
+ }
2441
+ byKey.set(s.key, current);
2442
+ return due;
2443
+ }
2444
+ __name(jitterDue, "jitterDue");
2245
2445
  function scheduledRunId(key, everyMs, nowMs) {
2246
2446
  return `sched:${key}:${Math.floor(nowMs / everyMs)}`;
2247
2447
  }
@@ -2311,13 +2511,19 @@ function scheduleRunIdsAt(s, nowMs) {
2311
2511
  __name(scheduleRunIdsAt, "scheduleRunIdsAt");
2312
2512
  async function runSchedules(engine, schedules, nowMs, opts) {
2313
2513
  const random = opts?.random ?? Math.random;
2314
- const sleep = opts?.sleep ?? defaultSleep;
2315
2514
  const settled = settledIdsFor(engine);
2316
2515
  const ids = [];
2317
2516
  for (const s of schedules) {
2318
2517
  if (engine.isDraining?.()) break;
2319
2518
  if (s.paused) continue;
2320
- const candidates = scheduleRunIdsAt(s, nowMs).filter((id) => !settled.get(s.key)?.has(id));
2519
+ const due = scheduleRunIdsAt(s, nowMs);
2520
+ const windows = s.jitter && s.jitter > 0 ? [
2521
+ .../* @__PURE__ */ new Set([
2522
+ ...deferredWindows(engine, s.key),
2523
+ ...due
2524
+ ])
2525
+ ] : due;
2526
+ const candidates = windows.filter((id) => !settled.get(s.key)?.has(id));
2321
2527
  if (candidates.length === 0) continue;
2322
2528
  if (s.overlap === "skip" && s.everyMs) {
2323
2529
  const prevBucket = Math.floor(nowMs / s.everyMs) - 1;
@@ -2326,8 +2532,9 @@ async function runSchedules(engine, schedules, nowMs, opts) {
2326
2532
  if (prev && (prev.status === "running" || prev.status === "suspended")) continue;
2327
2533
  }
2328
2534
  }
2329
- if (s.jitter && s.jitter > 0) await sleep(Math.floor(random() * s.jitter));
2330
- for (const runId of candidates) {
2535
+ const firing = s.jitter && s.jitter > 0 ? jitterDue(engine, s, candidates, nowMs, random) : candidates;
2536
+ if (firing.length === 0) continue;
2537
+ for (const runId of firing) {
2331
2538
  if (engine.isDraining?.()) break;
2332
2539
  const existing = await engine.getRun(runId);
2333
2540
  remember(settled, s.key, runId);
@@ -3092,6 +3299,39 @@ var TransportPool = class {
3092
3299
  transport.onStartRun?.(handler);
3093
3300
  }
3094
3301
  }
3302
+ /** Does any transport here carry a run queue ({@link Transport.dispatchRuns} + `onDispatchedRun`)? */
3303
+ get dispatchesRuns() {
3304
+ return this.transports.some(({ transport }) => transport.dispatchRuns && transport.onDispatchedRun);
3305
+ }
3306
+ /**
3307
+ * Queue `runs` on the first transport that carries a run queue, failing over to the next on an
3308
+ * error, like {@link dispatch}. Throws if none accepted them.
3309
+ */
3310
+ async dispatchRuns(runs, opts) {
3311
+ let lastErr;
3312
+ for (const { transport } of this.transports) {
3313
+ if (!transport.dispatchRuns || !transport.onDispatchedRun) continue;
3314
+ try {
3315
+ await transport.dispatchRuns(runs, opts);
3316
+ return;
3317
+ } catch (err) {
3318
+ lastErr = err;
3319
+ }
3320
+ }
3321
+ throw lastErr ?? new Error("no transport carries a run queue");
3322
+ }
3323
+ /**
3324
+ * Consume the run queue of every transport that carries one (a run may have been queued on any of
3325
+ * them after a failover). Returns one stop function per consumer.
3326
+ */
3327
+ onDispatchedRun(handler, opts) {
3328
+ const stops = [];
3329
+ for (const { transport } of this.transports) {
3330
+ if (!transport.dispatchRuns || !transport.onDispatchedRun) continue;
3331
+ stops.push(transport.onDispatchedRun(handler, opts));
3332
+ }
3333
+ return stops;
3334
+ }
3095
3335
  /**
3096
3336
  * The live handshake descriptors advertised for `group` (design §7.2), merged across every transport
3097
3337
  * that can report them. Returns `[]` when NO transport implements
@@ -3624,21 +3864,28 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3624
3864
  return inputs.map((_, i) => i).slice(from, from + waves.waveSize);
3625
3865
  }, "waveItems");
3626
3866
  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);
3867
+ const failure = /* @__PURE__ */ __name((i2) => {
3868
+ const cp = existing[i2];
3869
+ const c2 = cp?.status === "completed" ? cp.output : null;
3870
+ return c2 && c2.ok === false ? c2 : null;
3871
+ }, "failure");
3872
+ const first = /* @__PURE__ */ __name((cancelled) => inputs.findIndex((_, i2) => {
3873
+ const c2 = failure(i2);
3874
+ return c2 !== null && c2.code === "cancelled" === cancelled;
3875
+ }), "first");
3876
+ const i = first(false) !== -1 ? first(false) : first(true);
3877
+ const c = i === -1 ? null : failure(i);
3878
+ if (c) {
3879
+ for (let j = 0; j < inputs.length; j += 1) {
3880
+ if (j !== i && existing[j]?.status !== "completed") host.cancelChild?.(id(j));
3641
3881
  }
3882
+ throw new GatherError([
3883
+ {
3884
+ index: i,
3885
+ id: id(i),
3886
+ error: c.error ?? "unknown"
3887
+ }
3888
+ ], inputs.length);
3642
3889
  }
3643
3890
  }
3644
3891
  if (store.compactCheckpoints && inputs.length >= FOLD_MIN_FAN) {
@@ -3717,10 +3964,12 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3717
3964
  await registerChildWaits(entries);
3718
3965
  for (const i of chunk) host.startChild(name, inputs[i], id(i));
3719
3966
  }
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
- }));
3967
+ if (toEnsure.length > 0) {
3968
+ const { runs: started, unread } = await readRunsByIds(store, toEnsure.map((i) => id(i)));
3969
+ const skip = new Set(unread);
3970
+ for (const i of toEnsure) {
3971
+ if (!started.has(id(i)) && !skip.has(id(i))) host.startChild(name, inputs[i], id(i));
3972
+ }
3724
3973
  }
3725
3974
  }
3726
3975
  if (active.length > 0) {
@@ -4180,6 +4429,56 @@ var ANNOUNCED_ROUTES_TTL_MS = 5e3;
4180
4429
  var VERSION_UNDECLARED_TAG = "version:undeclared";
4181
4430
  var versionKey = /* @__PURE__ */ __name((name, version) => `${name}@${version}`, "versionKey");
4182
4431
  var STEP_FAN_CHUNK = 200;
4432
+ var PARENT_NOTIFY_ATTEMPTS = 4;
4433
+ var PARENT_NOTIFY_BACKOFF_MS = 250;
4434
+ var LEASE_RECHECK_MAX_MS = 2e3;
4435
+ var LEASE_RECHECK_SLACK_MS = 100;
4436
+ var LEASE_RECHECK_ATTEMPTS = 30;
4437
+ function deferred() {
4438
+ let resolve;
4439
+ let reject;
4440
+ const promise = new Promise((res, rej) => {
4441
+ resolve = res;
4442
+ reject = rej;
4443
+ });
4444
+ promise.catch(() => void 0);
4445
+ return {
4446
+ promise,
4447
+ resolve,
4448
+ reject
4449
+ };
4450
+ }
4451
+ __name(deferred, "deferred");
4452
+ var pendingStateKey = /* @__PURE__ */ __name((at) => String(new Date(at).getTime()), "pendingStateKey");
4453
+ var freshPendingKey = /* @__PURE__ */ __name(() => `${Date.now()}-${globalThis.crypto.randomUUID().slice(0, 8)}`, "freshPendingKey");
4454
+ var CHILD_SIGNAL_PREFIX = "signal:child:";
4455
+ var DELIVER_CHUNK = 50;
4456
+ function childCompletion(child) {
4457
+ if (child.status === "completed") return {
4458
+ ok: true,
4459
+ value: child.output
4460
+ };
4461
+ if (child.status === "failed") return failedCompletion(child.error ?? {
4462
+ message: "failed"
4463
+ });
4464
+ if (child.status === "dead") return failedCompletion(child.error ?? {
4465
+ message: "dead"
4466
+ });
4467
+ if (child.status === "cancelled") return cancelledCompletion(child.error);
4468
+ return void 0;
4469
+ }
4470
+ __name(childCompletion, "childCompletion");
4471
+ var CHILD_CANCELLED = "cancelled";
4472
+ function cancelledCompletion(error2) {
4473
+ const base = error2 ?? {
4474
+ message: "cancelled"
4475
+ };
4476
+ return failedCompletion({
4477
+ ...base,
4478
+ code: base.code ?? CHILD_CANCELLED
4479
+ });
4480
+ }
4481
+ __name(cancelledCompletion, "cancelledCompletion");
4183
4482
  var RESULT_BATCH_MAX = 256;
4184
4483
  function shuffle(items) {
4185
4484
  for (let i = items.length - 1; i > 0; i -= 1) {
@@ -4356,6 +4655,35 @@ var WorkflowEngine = class {
4356
4655
  /** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
4357
4656
  inflight = /* @__PURE__ */ new Set();
4358
4657
  /**
4658
+ * Work a settled turn leaves running in the background (a parent notification, a post-release
4659
+ * re-drive), so a graceful shutdown waits for it too: dropping one on exit strands the run until
4660
+ * its reconcile timer.
4661
+ */
4662
+ background = /* @__PURE__ */ new Set();
4663
+ /**
4664
+ * Runs with an implicit wake executing on this instance, and whether another wake arrived for the
4665
+ * run meanwhile (see {@link resumeImplicit}).
4666
+ */
4667
+ wakeTurns = /* @__PURE__ */ new Map();
4668
+ /** Re-checks scheduled for runs whose wake found another instance's lease (see {@link execute}). */
4669
+ leaseRetries = /* @__PURE__ */ new Map();
4670
+ /**
4671
+ * Every run turn and child start this instance takes on itself, under one budget of
4672
+ * {@link LOCAL_RUN_CONCURRENCY}: the runs it dispatches to itself, a fan's deferred child starts,
4673
+ * enqueued pickups and the runs it takes off the transport's run queue.
4674
+ */
4675
+ localWork;
4676
+ /** The dispatcher the caller supplied, if any; without one the engine picks (see {@link dispatchRun}). */
4677
+ customDispatcher;
4678
+ /** Runs on their way to the transport's run queue (see {@link consumeRuns}). */
4679
+ runBatcher;
4680
+ /** Set while this engine consumes a transport's run queue, and so dispatches its runs to it. */
4681
+ dispatchesOverTransport = false;
4682
+ /** Stops for the run-queue consumers started by {@link consumeRuns}. */
4683
+ runConsumerStops = [];
4684
+ /** The consumers being stopped, so {@link drain} waits for their runs in flight. */
4685
+ runConsumersStopping;
4686
+ /**
4359
4687
  * The run lease of each execution in flight on THIS instance, by run id — renewed while it runs
4360
4688
  * and checked by every settle (the zombie fence, see {@link ExecutionLease}). Also what `drain()`
4361
4689
  * releases when its budget runs out.
@@ -4411,15 +4739,27 @@ var WorkflowEngine = class {
4411
4739
  this.rehydrate = deps.rehydrate ?? ((_carrier, fn) => fn());
4412
4740
  this.compensationRetries = Math.max(1, deps.compensationRetries ?? 1);
4413
4741
  this.trackStepStart = deps.trackStepStart ?? true;
4414
- this.runDispatcher = deps.runDispatcher ?? {
4415
- dispatch: /* @__PURE__ */ __name((runId) => queueMicrotask(() => void this.runOne(runId).catch(() => {
4416
- })), "dispatch")
4742
+ this.localWork = new BoundedRunner(LOCAL_RUN_CONCURRENCY);
4743
+ this.customDispatcher = deps.runDispatcher;
4744
+ this.runBatcher = new RunDispatchBatcher((runs) => this.pool.dispatchRuns(runs, {
4745
+ namespace: this.namespace
4746
+ }), (runs, err) => {
4747
+ for (const run of runs) {
4748
+ if (run.reason === "wake" && !this.draining) {
4749
+ this.inBackground(this.resumeImplicit(run.runId));
4750
+ }
4751
+ }
4752
+ 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);
4753
+ });
4754
+ this.runDispatcher = {
4755
+ dispatch: /* @__PURE__ */ __name((runId) => this.dispatchRun(runId), "dispatch")
4417
4756
  };
4418
4757
  this.singletons = new SingletonGate({
4419
4758
  store: this.store,
4420
4759
  clock: this.clock,
4421
4760
  dispatch: /* @__PURE__ */ __name((runId) => {
4422
- this.runDispatcher.dispatch(runId);
4761
+ if (this.queuesRunsOverTransport) void this.wake(runId);
4762
+ else this.runDispatcher.dispatch(runId);
4423
4763
  }, "dispatch"),
4424
4764
  configFor: /* @__PURE__ */ __name((run) => (this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ?? this.latest.get(run.workflow))?.singleton, "configFor")
4425
4765
  });
@@ -4544,13 +4884,224 @@ var WorkflowEngine = class {
4544
4884
  }
4545
4885
  /**
4546
4886
  * 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
4887
+ * pick it up at once — e.g. `engine.onEnqueued((runId) => engine.runEnqueued(runId))`. Returns an
4548
4888
  * unsubscribe function. Only wire this on instances that should execute runs (workers).
4549
4889
  */
4550
4890
  onEnqueued(listener) {
4551
4891
  this.enqueuedListeners.add(listener);
4552
4892
  return () => this.enqueuedListeners.delete(listener);
4553
4893
  }
4894
+ /**
4895
+ * Pick up a run another instance announced as enqueued: run it here if it is still `pending`.
4896
+ * Bounded by {@link LOCAL_RUN_CONCURRENCY}, and a run another instance already took is skipped
4897
+ * after one read, instead of being leased and replayed here once more.
4898
+ */
4899
+ runEnqueued(runId) {
4900
+ this.localWork.submit(`run:${runId}`, () => this.runIfPending(runId));
4901
+ }
4902
+ /**
4903
+ * Consume the run queue of every transport that carries one ({@link Transport.dispatchRuns} and
4904
+ * {@link Transport.onDispatchedRun}), and from then on dispatch the runs this engine starts there
4905
+ * rather than executing them in-process: a run, a `ctx.all` fan's children included, executes on
4906
+ * whichever consuming instance takes it, at most {@link LOCAL_RUN_CONCURRENCY} at once per
4907
+ * instance. Call it on every instance that drives runs; the Nest module does on a driving operator.
4908
+ *
4909
+ * Returns a function that stops consuming and resolves once the runs in flight settled, after
4910
+ * which runs are executed in-process again; {@link beginDrain} stops consuming too. Undefined
4911
+ * when no transport carries a run queue: runs keep executing in-process.
4912
+ */
4913
+ consumeRuns() {
4914
+ if (!this.pool.dispatchesRuns) return void 0;
4915
+ if (this.runConsumerStops.length === 0) {
4916
+ this.runConsumerStops = this.pool.onDispatchedRun((run) => this.localWork.run(() => this.runFromQueue(run)), {
4917
+ concurrency: LOCAL_RUN_CONCURRENCY,
4918
+ namespace: this.namespace
4919
+ });
4920
+ }
4921
+ this.dispatchesOverTransport = true;
4922
+ return async () => {
4923
+ this.dispatchesOverTransport = false;
4924
+ await this.runBatcher.settle();
4925
+ await this.stopRunConsumers();
4926
+ };
4927
+ }
4928
+ /** Stop every run-queue consumer; resolves once their runs in flight settled. */
4929
+ stopRunConsumers() {
4930
+ if (this.runConsumerStops.length > 0) {
4931
+ const stops = this.runConsumerStops;
4932
+ this.runConsumerStops = [];
4933
+ this.runConsumersStopping = Promise.allSettled(stops.map((stop) => stop()));
4934
+ }
4935
+ return this.runConsumersStopping ?? Promise.resolve();
4936
+ }
4937
+ /** Whether {@link dispatchRun} queues runs on the transport (see {@link consumeRuns}). */
4938
+ get queuesRunsOverTransport() {
4939
+ return this.customDispatcher === void 0 && this.dispatchesOverTransport;
4940
+ }
4941
+ /**
4942
+ * Hand a `pending` run to whatever executes it: the caller's {@link WorkflowEngineDeps.runDispatcher};
4943
+ * else the transport's run queue while this engine consumes it, batched; else this instance,
4944
+ * bounded by {@link LOCAL_RUN_CONCURRENCY}. A run whose queueing fails stays `pending` for a
4945
+ * `runPending` poll.
4946
+ */
4947
+ dispatchRun(runId, priority, key) {
4948
+ if (this.customDispatcher) return this.customDispatcher.dispatch(runId);
4949
+ if (this.dispatchesOverTransport) {
4950
+ this.runBatcher.add({
4951
+ runId,
4952
+ reason: "start",
4953
+ ...priority !== void 0 ? {
4954
+ priority
4955
+ } : {},
4956
+ ...key !== void 0 ? {
4957
+ key
4958
+ } : {}
4959
+ });
4960
+ return;
4961
+ }
4962
+ this.localWork.submit(`run:${runId}`, () => this.runOne(runId));
4963
+ }
4964
+ /**
4965
+ * Wake a suspended run that has something new to see (a delivered signal or child completion, a
4966
+ * lease that came free, a freed slot): while this engine consumes the transport's run queue, on
4967
+ * whichever instance takes the wake (the result then only says it was handed over); otherwise
4968
+ * here, now.
4969
+ */
4970
+ wake(runId) {
4971
+ if (this.queuesRunsOverTransport) {
4972
+ this.runBatcher.add({
4973
+ runId,
4974
+ reason: "wake"
4975
+ });
4976
+ return Promise.resolve({
4977
+ runId,
4978
+ status: "suspended"
4979
+ });
4980
+ }
4981
+ return this.resumeImplicit(runId);
4982
+ }
4983
+ /**
4984
+ * Take a turn of a run taken off the transport's run queue. A `start` runs only while the run is
4985
+ * still `pending` (or `cancelling`, a recovered cancel): any other state means another instance
4986
+ * already took it. A `wake` leases and resumes the run unless it is finished. The run lease admits
4987
+ * one executor either way. Never rejects.
4988
+ */
4989
+ async runFromQueue(dispatch) {
4990
+ if (this.draining) return;
4991
+ const { runId } = dispatch;
4992
+ try {
4993
+ const run = await this.store.getRun(runId);
4994
+ if (!run) return;
4995
+ if (run.status === "pending" || run.status === "cancelling") {
4996
+ await this.runOne(runId);
4997
+ return;
4998
+ }
4999
+ if (dispatch.reason !== "wake" || TERMINAL_RUN_STATUSES.includes(run.status)) return;
5000
+ const nowMs = this.clock();
5001
+ if (run.wakeAt !== void 0 && run.wakeAt <= nowMs) {
5002
+ const waiters = indexWaitersByRun(await listSignalWaitersOfRuns(this.store, [
5003
+ runId
5004
+ ]));
5005
+ const resumed = await this.resumeLeased([
5006
+ run
5007
+ ], nowMs, (locked) => this.reconcileSignalWaiter(locked, waiters.get(locked.id)));
5008
+ if (resumed.length === 0) this.recheckAfterLease(runId, run.lockedUntil);
5009
+ return;
5010
+ }
5011
+ await this.resumeImplicit(runId).catch((err) => {
5012
+ if (!(err instanceof NamespaceMismatch)) throw err;
5013
+ });
5014
+ } catch (error2) {
5015
+ console.error(`[nestjs-durable] could not run queued run ${runId}:`, error2);
5016
+ }
5017
+ }
5018
+ /**
5019
+ * Hand due runs (timers, blocked runs due for a fleet re-check) to whichever instance takes them
5020
+ * off the run queue. Each is claimed first, under its lease, by pushing its `wakeAt` one lease
5021
+ * period out, so the next poll (here or elsewhere) does not queue it again while it waits; a wake
5022
+ * that is lost brings it back due after that period. False when the transport refused the wakes,
5023
+ * so the caller resumes them itself.
5024
+ */
5025
+ async handOffDue(runs, nowMs) {
5026
+ const claimed = [];
5027
+ for (const run of runs) {
5028
+ if (this.draining) break;
5029
+ if (!await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs)) {
5030
+ continue;
5031
+ }
5032
+ try {
5033
+ const patch = {
5034
+ wakeAt: nowMs + this.leaseMs
5035
+ };
5036
+ if (this.store.updateRunIf) {
5037
+ if (await this.store.updateRunIf(run.id, [
5038
+ run.status
5039
+ ], patch)) claimed.push(run);
5040
+ } else {
5041
+ await this.store.updateRun(run.id, patch);
5042
+ claimed.push(run);
5043
+ }
5044
+ } finally {
5045
+ await this.store.releaseRunLock(run.id);
5046
+ }
5047
+ }
5048
+ try {
5049
+ for (let at = 0; at < claimed.length; at += RUN_DISPATCH_BATCH) {
5050
+ await this.pool.dispatchRuns(claimed.slice(at, at + RUN_DISPATCH_BATCH).map((run) => ({
5051
+ runId: run.id,
5052
+ reason: "wake",
5053
+ ...run.priority !== void 0 ? {
5054
+ priority: run.priority
5055
+ } : {}
5056
+ })), {
5057
+ namespace: this.namespace
5058
+ });
5059
+ }
5060
+ return true;
5061
+ } catch (error2) {
5062
+ console.error("[nestjs-durable] could not queue due runs on the transport:", error2);
5063
+ return false;
5064
+ }
5065
+ }
5066
+ /**
5067
+ * Queue `runs` on the transport for whichever consuming instance takes them, in batches. False
5068
+ * when the transport refused them, so the caller executes them itself instead.
5069
+ */
5070
+ async queueOnTransport(runs) {
5071
+ try {
5072
+ for (let at = 0; at < runs.length; at += RUN_DISPATCH_BATCH) {
5073
+ await this.pool.dispatchRuns(runs.slice(at, at + RUN_DISPATCH_BATCH).map((run) => ({
5074
+ runId: run.id,
5075
+ reason: "start",
5076
+ key: pendingStateKey(run.updatedAt),
5077
+ ...run.priority !== void 0 ? {
5078
+ priority: run.priority
5079
+ } : {}
5080
+ })), {
5081
+ namespace: this.namespace
5082
+ });
5083
+ }
5084
+ return true;
5085
+ } catch (error2) {
5086
+ console.error("[nestjs-durable] could not queue runs on the transport; running them here:", error2);
5087
+ return false;
5088
+ }
5089
+ }
5090
+ /**
5091
+ * Execute a run announced by another instance, if it is still `pending`: a
5092
+ * run another instance already took is skipped after one read instead of being leased and
5093
+ * replayed here once more. Never rejects.
5094
+ */
5095
+ async runIfPending(runId) {
5096
+ if (this.draining) return;
5097
+ try {
5098
+ const run = await this.store.getRun(runId);
5099
+ if (run?.status !== "pending") return;
5100
+ await this.runOne(runId);
5101
+ } catch (error2) {
5102
+ console.error(`[nestjs-durable] could not run enqueued run ${runId}:`, error2);
5103
+ }
5104
+ }
4554
5105
  /** Fire cooperative-cancellation listeners for `runId` (a worker bridge aborts in-flight work). */
4555
5106
  notifyCancelled(runId) {
4556
5107
  for (const fn of this.cancelListeners) {
@@ -4946,8 +5497,8 @@ var WorkflowEngine = class {
4946
5497
  status: "suspended"
4947
5498
  };
4948
5499
  }
4949
- await this.runDispatcher.dispatch(runId);
4950
- if (this.controlPlane) {
5500
+ await this.dispatchRun(runId, run.priority, pendingStateKey(run.updatedAt));
5501
+ if (this.controlPlane && !this.queuesRunsOverTransport) {
4951
5502
  void this.controlPlane.publishControl({
4952
5503
  kind: "enqueued",
4953
5504
  runId,
@@ -4986,7 +5537,43 @@ var WorkflowEngine = class {
4986
5537
  * stale waiter or a late result left behind by a failed run silently re-executes its body.
4987
5538
  */
4988
5539
  resumeImplicit(runId) {
4989
- return this.resumeRun(runId, false);
5540
+ const turn = this.wakeTurns.get(runId);
5541
+ if (turn) {
5542
+ turn.again ??= deferred();
5543
+ if (currentWorkflowCtx()?.runId === runId) {
5544
+ return Promise.resolve({
5545
+ runId,
5546
+ status: "running"
5547
+ });
5548
+ }
5549
+ return turn.again.promise;
5550
+ }
5551
+ const mine = {};
5552
+ this.wakeTurns.set(runId, mine);
5553
+ const finish = /* @__PURE__ */ __name(() => {
5554
+ if (this.wakeTurns.get(runId) === mine) this.wakeTurns.delete(runId);
5555
+ const again = mine.again;
5556
+ if (!again) return;
5557
+ if (this.draining) {
5558
+ again.resolve({
5559
+ runId,
5560
+ status: "running"
5561
+ });
5562
+ return;
5563
+ }
5564
+ const next = this.resumeImplicit(runId);
5565
+ next.then(again.resolve, again.reject);
5566
+ this.inBackground(next);
5567
+ }, "finish");
5568
+ const result = this.resumeRun(runId, false);
5569
+ void result.then(finish, finish);
5570
+ return result;
5571
+ }
5572
+ /** Keep `work` in {@link background} until it settles; its failure is swallowed. */
5573
+ inBackground(work) {
5574
+ const tracked = work.catch(() => void 0);
5575
+ this.background.add(tracked);
5576
+ void tracked.then(() => this.background.delete(tracked));
4990
5577
  }
4991
5578
  /**
4992
5579
  * One execution of a run at a time in this process. A resume that lands while the run executes
@@ -5273,6 +5860,10 @@ var WorkflowEngine = class {
5273
5860
  */
5274
5861
  beginDrain(spentMs = 0) {
5275
5862
  this.draining = true;
5863
+ if (this.runConsumerStops.length > 0) {
5864
+ this.runBatcher.flush();
5865
+ void this.stopRunConsumers();
5866
+ }
5276
5867
  if (Number.isFinite(spentMs) && spentMs > 0) {
5277
5868
  this.drainSpentMs += spentMs;
5278
5869
  for (const rearm of this.drainRearms) rearm();
@@ -5293,7 +5884,14 @@ var WorkflowEngine = class {
5293
5884
  */
5294
5885
  async drain(timeoutMs = 1e4) {
5295
5886
  this.beginDrain();
5296
- if (this.inflight.size === 0) return;
5887
+ this.localWork.clear();
5888
+ for (const timer2 of this.leaseRetries.values()) clearTimeout(timer2);
5889
+ this.leaseRetries.clear();
5890
+ const consumers = this.runConsumersStopping;
5891
+ if (this.inflight.size === 0 && this.background.size === 0 && !consumers && this.runBatcher.idle) {
5892
+ return;
5893
+ }
5894
+ const queued = this.runBatcher.settle();
5297
5895
  const start = Date.now();
5298
5896
  let rearm = /* @__PURE__ */ __name(() => {
5299
5897
  }, "rearm");
@@ -5312,7 +5910,10 @@ var WorkflowEngine = class {
5312
5910
  try {
5313
5911
  await Promise.race([
5314
5912
  Promise.allSettled([
5315
- ...this.inflight
5913
+ ...this.inflight,
5914
+ ...this.background,
5915
+ queued,
5916
+ consumers
5316
5917
  ]).then(() => {
5317
5918
  settled = true;
5318
5919
  }),
@@ -5419,6 +6020,7 @@ var WorkflowEngine = class {
5419
6020
  error: error2
5420
6021
  });
5421
6022
  await this.cancelChildren(run.id).catch(() => void 0);
6023
+ this.notifyParent(run.id, cancelledCompletion(error2));
5422
6024
  }
5423
6025
  }
5424
6026
  }
@@ -5433,7 +6035,9 @@ var WorkflowEngine = class {
5433
6035
  if (this.draining) break;
5434
6036
  const outcome = this.isOwnLiveLease(listed, nowMs) ? await this.reclaimOwnLease(listed, nowMs) : await this.recoverOrphan(listed, nowMs);
5435
6037
  if (!outcome) continue;
5436
- if (outcome.dispatch) await this.runDispatcher.dispatch(outcome.result.runId);
6038
+ if (outcome.dispatch) {
6039
+ await this.dispatchRun(outcome.result.runId, listed.priority, freshPendingKey());
6040
+ }
5437
6041
  results.push(outcome.result);
5438
6042
  }
5439
6043
  return results;
@@ -5625,6 +6229,18 @@ var WorkflowEngine = class {
5625
6229
  const due = shuffle(await this.store.listDueTimers(nowMs, this.namespace, this.timerBatchSize));
5626
6230
  const blocked = await this.dueBlockedRuns(nowMs);
5627
6231
  if (due.length === 0 && blocked.length === 0) return [];
6232
+ if (this.queuesRunsOverTransport && !this.draining) {
6233
+ const runs = [
6234
+ ...due,
6235
+ ...blocked
6236
+ ];
6237
+ if (await this.handOffDue(runs, nowMs)) {
6238
+ return runs.map((run) => ({
6239
+ runId: run.id,
6240
+ status: run.status
6241
+ }));
6242
+ }
6243
+ }
5628
6244
  const waiterByRun = indexWaitersByRun(await listSignalWaitersOfRuns(this.store, [
5629
6245
  ...due,
5630
6246
  ...blocked
@@ -5658,7 +6274,7 @@ var WorkflowEngine = class {
5658
6274
  const buffered = await this.store.takeBufferedSignal(waiter.token);
5659
6275
  if (!buffered) return void 0;
5660
6276
  await this.store.removeSignalWaiter(waiter);
5661
- const settled = await this.deliverSignal(waiter, buffered.payload);
6277
+ const settled = await this.deliverSignal(waiter, buffered.payload, true);
5662
6278
  return settled ?? {
5663
6279
  runId: run.id,
5664
6280
  status: run.status
@@ -5684,7 +6300,7 @@ var WorkflowEngine = class {
5684
6300
  if (!eventMatches(candidate.payload, match)) continue;
5685
6301
  if (!await this.store.removeBufferedEvent(candidate.id)) continue;
5686
6302
  await this.store.removeSignalWaiter(waiter);
5687
- const settled = await this.deliverSignal(waiter, candidate.payload);
6303
+ const settled = await this.deliverSignal(waiter, candidate.payload, true);
5688
6304
  return settled ?? {
5689
6305
  runId: run.id,
5690
6306
  status: run.status
@@ -5804,7 +6420,7 @@ var WorkflowEngine = class {
5804
6420
  } : {},
5805
6421
  updatedAt: /* @__PURE__ */ new Date()
5806
6422
  });
5807
- await this.runDispatcher.dispatch(runId);
6423
+ await this.dispatchRun(runId, run.priority, freshPendingKey());
5808
6424
  return {
5809
6425
  runId,
5810
6426
  status: "pending"
@@ -5879,7 +6495,16 @@ var WorkflowEngine = class {
5879
6495
  * {@link resumeDueTimers}.
5880
6496
  */
5881
6497
  async runPending(nowMs = this.clock()) {
5882
- return this.resumeLeased(await this.store.listPendingRuns(100, this.namespace), nowMs);
6498
+ const pending = await this.store.listPendingRuns(100, this.namespace);
6499
+ if (this.queuesRunsOverTransport && !this.draining && pending.length > 0) {
6500
+ if (await this.queueOnTransport(pending)) {
6501
+ return pending.map((run) => ({
6502
+ runId: run.id,
6503
+ status: run.status
6504
+ }));
6505
+ }
6506
+ }
6507
+ return this.resumeLeased(pending, nowMs);
5883
6508
  }
5884
6509
  /**
5885
6510
  * Resolve once `runId` reaches a settled state — terminal (completed/failed/cancelled/dead) or
@@ -6056,7 +6681,7 @@ var WorkflowEngine = class {
6056
6681
  * due-timer reconcile sweep ({@link reconcileSignalWaiter}) — all three resolve a waiter the exact
6057
6682
  * same way.
6058
6683
  */
6059
- async deliverSignal(waiter, payload) {
6684
+ async deliverSignal(waiter, payload, resumeHere = false) {
6060
6685
  await this.store.saveCheckpoint(instantCheckpoint({
6061
6686
  runId: waiter.runId,
6062
6687
  seq: waiter.seq,
@@ -6075,7 +6700,7 @@ var WorkflowEngine = class {
6075
6700
  status: "suspended"
6076
6701
  };
6077
6702
  }
6078
- return this.resumeImplicit(waiter.runId).catch((err) => {
6703
+ return (resumeHere ? this.resumeImplicit(waiter.runId) : this.wake(waiter.runId)).catch((err) => {
6079
6704
  if (err instanceof NamespaceMismatch) return null;
6080
6705
  throw err;
6081
6706
  });
@@ -6162,6 +6787,101 @@ var WorkflowEngine = class {
6162
6787
  return;
6163
6788
  }
6164
6789
  }
6790
+ /**
6791
+ * Deliver the outcome of every awaited child that already finished but whose completion never
6792
+ * reached this run, into `checkpoints` (this turn's snapshot) and the store.
6793
+ *
6794
+ * A child persists its terminal status first and tells its parent after ({@link notifyParent}). A
6795
+ * crash, a scale-down or a store error between the two leaves the parent's `running` placeholder
6796
+ * and `child:<id>` waiter behind for a child that is done, and nothing else would ever settle
6797
+ * them: the parent's reconcile wake replays straight back to the same wait. So each turn looks up
6798
+ * the children of its `running` placeholders (one batched read per 500) and settles the finished
6799
+ * ones exactly as the child's own notification would have: the `signal:child:<id>` checkpoint
6800
+ * with the child's completion, then the waiter removed. A notification arriving late writes the
6801
+ * same checkpoint and finds no waiter. Best effort: a store error leaves the rest to the next turn.
6802
+ */
6803
+ async deliverFinishedChildren(runId, checkpoints) {
6804
+ const awaited = [];
6805
+ for (const cp of checkpoints) {
6806
+ if (cp.kind !== "signal" || cp.status !== "running") continue;
6807
+ if (!cp.name.startsWith(CHILD_SIGNAL_PREFIX)) continue;
6808
+ awaited.push({
6809
+ childId: cp.name.slice(CHILD_SIGNAL_PREFIX.length),
6810
+ seq: cp.seq,
6811
+ parallelGroup: cp.parallelGroup
6812
+ });
6813
+ }
6814
+ await this.settleFinishedChildren(runId, awaited, checkpoints);
6815
+ }
6816
+ /**
6817
+ * {@link deliverFinishedChildren} for a remote workflow, whose awaited children (`startChild`
6818
+ * commands) leave a `child:<id>` waiter but no placeholder checkpoint: the run's own waiters say
6819
+ * which children it is waiting on. One indexed read per turn of a remote run.
6820
+ */
6821
+ async deliverFinishedChildWaiters(runId, checkpoints) {
6822
+ let waiters;
6823
+ try {
6824
+ waiters = await listSignalWaitersOfRuns(this.store, [
6825
+ runId
6826
+ ]);
6827
+ } catch {
6828
+ return;
6829
+ }
6830
+ const settled = new Set(checkpoints.filter((cp) => cp.status === "completed").map((cp) => cp.seq));
6831
+ const awaited = waiters.filter((w) => w.token.startsWith("child:") && !settled.has(w.seq)).map((w) => ({
6832
+ childId: w.token.slice("child:".length),
6833
+ seq: w.seq,
6834
+ parallelGroup: w.parallelGroup
6835
+ }));
6836
+ await this.settleFinishedChildren(runId, awaited, checkpoints);
6837
+ }
6838
+ /**
6839
+ * Settle every child in `awaited` that has finished: its `signal:child:<id>` checkpoint with the
6840
+ * child's completion, then its waiter removed, exactly as the child's own notification does, and
6841
+ * the checkpoint put into `checkpoints`. Children are read in batches of 500.
6842
+ */
6843
+ async settleFinishedChildren(runId, awaited, checkpoints) {
6844
+ if (awaited.length === 0) return;
6845
+ try {
6846
+ const { runs: children } = await readRunsByIds(this.store, awaited.map((a) => a.childId));
6847
+ const finished = awaited.flatMap((a) => {
6848
+ const child = children.get(a.childId);
6849
+ const completion = child ? childCompletion(child) : void 0;
6850
+ return completion ? [
6851
+ {
6852
+ ...a,
6853
+ completion
6854
+ }
6855
+ ] : [];
6856
+ });
6857
+ for (let at = 0; at < finished.length; at += DELIVER_CHUNK) {
6858
+ await Promise.all(finished.slice(at, at + DELIVER_CHUNK).map(async (a) => {
6859
+ const settled = instantCheckpoint({
6860
+ runId,
6861
+ seq: a.seq,
6862
+ name: `${CHILD_SIGNAL_PREFIX}${a.childId}`,
6863
+ kind: "signal",
6864
+ output: a.completion,
6865
+ parallelGroup: a.parallelGroup
6866
+ });
6867
+ await this.store.saveCheckpoint(settled);
6868
+ await this.store.removeSignalWaiter({
6869
+ token: `child:${a.childId}`,
6870
+ runId,
6871
+ seq: a.seq,
6872
+ ...a.parallelGroup !== void 0 ? {
6873
+ parallelGroup: a.parallelGroup
6874
+ } : {}
6875
+ });
6876
+ const index = checkpoints.findIndex((cp) => cp.seq === a.seq);
6877
+ if (index === -1) checkpoints.push(settled);
6878
+ else checkpoints[index] = settled;
6879
+ }));
6880
+ }
6881
+ checkpoints.sort((a, b) => a.seq - b.seq);
6882
+ } catch {
6883
+ }
6884
+ }
6165
6885
  async redriveSettledChildren(runId, fan) {
6166
6886
  if (!this.store.hasSignalWaiter) return;
6167
6887
  for (const wait of fan.waits) {
@@ -6260,13 +6980,63 @@ var WorkflowEngine = class {
6260
6980
  * knowing about the child feature.
6261
6981
  */
6262
6982
  notifyParent(runId, completion) {
6263
- void this.signal(`child:${runId}`, completion).catch(() => void 0);
6983
+ this.inBackground(this.deliverToParent(`child:${runId}`, completion));
6264
6984
  const at = runId.lastIndexOf("~retry~");
6265
6985
  if (at !== -1 && completion.ok) {
6266
- void this.signal(`child:${runId.slice(0, at)}`, completion).catch(() => void 0);
6986
+ this.inBackground(this.deliverToParent(`child:${runId.slice(0, at)}`, completion));
6267
6987
  }
6268
6988
  }
6269
6989
  /**
6990
+ * {@link signal} a child's completion to its parent, retrying a store error a few times. The child
6991
+ * is already persisted terminal, so a failure here would leave the parent waiting on a child that
6992
+ * finished; the parent's own turns reconcile that ({@link deliverFinishedChildren}), but only on
6993
+ * its next wake. A waiter already taken is delivered to directly on a retry, so the completion is
6994
+ * not buffered for a waiter that no longer exists.
6995
+ */
6996
+ async deliverToParent(token, completion) {
6997
+ let waiter;
6998
+ const cancelled = !completion.ok && completion.code === CHILD_CANCELLED;
6999
+ for (let attempt = 1; ; attempt += 1) {
7000
+ try {
7001
+ if (waiter === void 0) waiter = await this.store.takeSignalWaiter(token);
7002
+ if (waiter) {
7003
+ const parent = cancelled ? await this.store.getRun(waiter.runId) : void 0;
7004
+ if (parent && TERMINAL_RUN_STATUSES.includes(parent.status)) return;
7005
+ await this.deliverSignal(waiter, completion);
7006
+ return;
7007
+ }
7008
+ if (cancelled || await this.parentHoldsCompletion(token, completion)) return;
7009
+ await this.signal(token, completion);
7010
+ return;
7011
+ } catch (err) {
7012
+ if (err instanceof NamespaceMismatch) return;
7013
+ if (attempt >= PARENT_NOTIFY_ATTEMPTS || this.draining) return;
7014
+ await new Promise((resolve) => {
7015
+ const timer = setTimeout(resolve, PARENT_NOTIFY_BACKOFF_MS * 4 ** (attempt - 1));
7016
+ timer.unref?.();
7017
+ });
7018
+ }
7019
+ }
7020
+ }
7021
+ /**
7022
+ * Whether the parent of the child behind `token` (`child:<id>`) already holds `completion` as that
7023
+ * child's settled `signal:child:<id>` checkpoint: its own turn settled the child from the child's
7024
+ * row, so this notification is a duplicate. A different completion (a fix-and-replay run's success
7025
+ * for a child the parent saw fail) is not, and is buffered for the parent's retry. Only for a child
7026
+ * whose id the engine minted (its parent is derivable from it).
7027
+ */
7028
+ async parentHoldsCompletion(token, completion) {
7029
+ const childId = token.slice("child:".length);
7030
+ const parentId = parentRunIdOf(childId);
7031
+ if (!parentId) return false;
7032
+ const name = `${CHILD_SIGNAL_PREFIX}${childId}`;
7033
+ const checkpoints = this.store.listCheckpointsByNamePrefix ? await this.store.listCheckpointsByNamePrefix(parentId, [
7034
+ name
7035
+ ]) : await this.store.listCheckpoints(parentId);
7036
+ const expected = JSON.stringify(completion);
7037
+ return checkpoints.some((cp) => cp.name === name && cp.status === "completed" && JSON.stringify(cp.output) === expected);
7038
+ }
7039
+ /**
6270
7040
  * Deferred child start shared by the in-process ctx host and the remote `startChild` command.
6271
7041
  * Deferred (microtask) so a fast child can't reentrantly resume a still-suspending parent. A start
6272
7042
  * that THROWS (unregistered/unroutable workflow, input validation, singleton back-pressure, store
@@ -6279,13 +7049,16 @@ var WorkflowEngine = class {
6279
7049
  * correctly observes the failed start.
6280
7050
  */
6281
7051
  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}`
7052
+ this.localWork.submit(`start:${childId}`, async () => {
7053
+ if (this.draining) return;
7054
+ await this.start(workflow, input, childId, opts).catch((err) => {
7055
+ const message = err instanceof Error ? err.message : String(err);
7056
+ this.notifyParent(childId, {
7057
+ ok: false,
7058
+ error: `child workflow "${workflow}" failed to start: ${message}`
7059
+ });
6287
7060
  });
6288
- }));
7061
+ });
6289
7062
  }
6290
7063
  /**
6291
7064
  * Cancel a run (e.g. from the dashboard). Returns null if the run does not exist. Pass
@@ -6349,6 +7122,7 @@ var WorkflowEngine = class {
6349
7122
  error: error2
6350
7123
  });
6351
7124
  await this.cancelChildren(runId, opts);
7125
+ this.notifyParent(runId, cancelledCompletion(error2));
6352
7126
  this.notifyCancelled(runId);
6353
7127
  if (this.controlPlane) {
6354
7128
  void this.controlPlane.publishControl({
@@ -6863,6 +7637,7 @@ var WorkflowEngine = class {
6863
7637
  const lockNow = this.clock();
6864
7638
  if (run.lockedBy !== this.instanceId || (run.lockedUntil ?? 0) <= lockNow) {
6865
7639
  if (!await this.store.tryLockRun(run.id, this.instanceId, lockNow + this.leaseMs, lockNow)) {
7640
+ this.recheckAfterLease(run.id, run.lockedUntil);
6866
7641
  return {
6867
7642
  runId: run.id,
6868
7643
  status: run.status
@@ -6881,6 +7656,51 @@ var WorkflowEngine = class {
6881
7656
  }
6882
7657
  }
6883
7658
  /**
7659
+ * A wake that found the run leased by another instance is dropped, and normally that is fine: the
7660
+ * holder's turn either sees what the wake wrote, or re-checks once it releases the lease
7661
+ * ({@link redriveSettledSteps}, {@link redriveSettledChildren}). Not if the holder goes down first
7662
+ * (a scale-down mid-turn, an OOM): its lease lapses with the run `suspended` on a step that has
7663
+ * finished, and nothing wakes it before its timer.
7664
+ *
7665
+ * So the dropped wake is kept here: once the lease is free (looked at every
7666
+ * {@link LEASE_RECHECK_MAX_MS} at most, for a bounded number of times), a run still `suspended` is
7667
+ * leased and resumed. A holder that already moved the run on makes this one spare replay that
7668
+ * re-parks. Best effort; the run's timer remains the fallback if this instance goes down too.
7669
+ */
7670
+ recheckAfterLease(runId, lockedUntil, attempt = 1) {
7671
+ if (this.draining || this.leaseRetries.has(runId)) return;
7672
+ const remaining = Math.max(0, (lockedUntil ?? this.clock()) - this.clock());
7673
+ const timer = setTimeout(() => {
7674
+ this.leaseRetries.delete(runId);
7675
+ this.inBackground(this.retryDroppedWake(runId, attempt));
7676
+ }, Math.min(remaining, LEASE_RECHECK_MAX_MS) + LEASE_RECHECK_SLACK_MS);
7677
+ timer.unref?.();
7678
+ this.leaseRetries.set(runId, timer);
7679
+ }
7680
+ async retryDroppedWake(runId, attempt) {
7681
+ if (this.draining) return;
7682
+ const run = await this.store.getRun(runId);
7683
+ if (run?.status !== "suspended") return;
7684
+ const nowMs = this.clock();
7685
+ if (run.lockedUntil !== void 0 && run.lockedUntil > nowMs) {
7686
+ if (attempt < LEASE_RECHECK_ATTEMPTS) this.recheckAfterLease(runId, run.lockedUntil, attempt + 1);
7687
+ return;
7688
+ }
7689
+ if (this.queuesRunsOverTransport) {
7690
+ await this.wake(runId);
7691
+ return;
7692
+ }
7693
+ if (!await this.store.tryLockRun(runId, this.instanceId, nowMs + this.leaseMs, nowMs)) return;
7694
+ const fresh = await this.store.getRun(runId);
7695
+ if (fresh?.status !== "suspended") {
7696
+ await this.store.releaseRunLock(runId);
7697
+ return;
7698
+ }
7699
+ await this.resumeImplicit(runId).catch((err) => {
7700
+ if (!(err instanceof NamespaceMismatch)) throw err;
7701
+ });
7702
+ }
7703
+ /**
6884
7704
  * Renew `runId`'s lease for as long as this execution holds it, and register it in
6885
7705
  * {@link executionLeases} so settles can check the zombie fence and `drain()` can release it.
6886
7706
  *
@@ -7244,6 +8064,7 @@ ${run.workflowVersion}`;
7244
8064
  namespace: run.namespace,
7245
8065
  error: error2
7246
8066
  });
8067
+ this.notifyParent(run.id, cancelledCompletion(error2));
7247
8068
  this.notifyCancelled(run.id);
7248
8069
  if (this.controlPlane) {
7249
8070
  void this.controlPlane.publishControl({
@@ -7275,6 +8096,8 @@ ${run.workflowVersion}`;
7275
8096
  const wfPlan = await this.planRoute(tenantGroup(sanitizeQueueToken(remote.group), void 0), registered.requires);
7276
8097
  if (wfPlan?.status === "blocked") return this.parkBlocked(run, wfPlan);
7277
8098
  const checkpoints = await this.store.listCheckpoints(run.id);
8099
+ await this.deliverFinishedChildren(run.id, checkpoints);
8100
+ await this.deliverFinishedChildWaiters(run.id, checkpoints);
7278
8101
  const handoff = liveHandoff(run.id, checkpoints);
7279
8102
  if (handoff) {
7280
8103
  const result = await this.settleRun(run, await this.followHandoff(run, handoff));
@@ -7849,6 +8672,7 @@ ${run.workflowVersion}`;
7849
8672
  namespace: run.namespace,
7850
8673
  error: error2
7851
8674
  });
8675
+ this.notifyParent(run.id, cancelledCompletion(error2));
7852
8676
  return {
7853
8677
  runId: run.id,
7854
8678
  status: "cancelled",
@@ -7895,6 +8719,7 @@ ${run.workflowVersion}`;
7895
8719
  }
7896
8720
  const compensations = [];
7897
8721
  const snapshot = await this.store.listCheckpoints(run.id);
8722
+ await this.deliverFinishedChildren(run.id, snapshot);
7898
8723
  const replay = /* @__PURE__ */ new Map();
7899
8724
  for (const cp of snapshot) replay.set(cp.seq, cp);
7900
8725
  let suspendedOnFan;
@@ -7991,13 +8816,13 @@ ${run.workflowVersion}`;
7991
8816
  if (this.awaitedStepFans.get(run.id) === awaitedFans) this.awaitedStepFans.delete(run.id);
7992
8817
  await this.releaseLeaseBestEffort(run.id);
7993
8818
  if (suspendedOnFan) {
7994
- void this.redriveSettledChildren(run.id, suspendedOnFan).catch(() => void 0);
8819
+ this.inBackground(this.redriveSettledChildren(run.id, suspendedOnFan));
7995
8820
  }
7996
8821
  if (suspendedOnSteps && awaitedSteps.size > 0) {
7997
- void this.redriveSettledSteps(run.id, awaitedSteps).catch(() => void 0);
8822
+ this.inBackground(this.redriveSettledSteps(run.id, awaitedSteps));
7998
8823
  }
7999
8824
  if (suspendedOnSteps && awaitedFans.length > 0) {
8000
- void this.redriveSettledStepFans(run.id, awaitedFans).catch(() => void 0);
8825
+ this.inBackground(this.redriveSettledStepFans(run.id, awaitedFans));
8001
8826
  }
8002
8827
  }
8003
8828
  }
@@ -8437,9 +9262,11 @@ ${run.workflowVersion}`;
8437
9262
  })) {
8438
9263
  return this.callRemote(runId, seq, step, input, queue, transport, void 0, admission, routing);
8439
9264
  }
8440
- throw new WorkflowSuspended(wakeAt);
9265
+ throw new WorkflowSuspended(this.stepLeaseWakeAt(wakeAt));
9266
+ }
9267
+ if (this.clock() < existing.wakeAt) {
9268
+ throw new WorkflowSuspended(this.stepLeaseWakeAt(existing.wakeAt));
8441
9269
  }
8442
- if (this.clock() < existing.wakeAt) throw new WorkflowSuspended(existing.wakeAt);
8443
9270
  if (existing.attempts >= this.remoteRedispatchMax) {
8444
9271
  throw new RemoteStepError({
8445
9272
  message: `remote step "${step.name}" lost \u2014 no result after ${existing.attempts} re-dispatch(es)`,
@@ -8485,7 +9312,7 @@ ${run.workflowVersion}`;
8485
9312
  kind: "remote",
8486
9313
  redispatched: true
8487
9314
  });
8488
- throw new WorkflowSuspended(nextDeadline);
9315
+ throw new WorkflowSuspended(this.stepLeaseWakeAt(nextDeadline));
8489
9316
  }
8490
9317
  let attempt = 1;
8491
9318
  if (existing?.status === "failed") {
@@ -8848,6 +9675,7 @@ ${run.workflowVersion}`;
8848
9675
  });
8849
9676
  const reads = await Promise.allSettled(live.map((i) => this.store.getCheckpoint(runId, results[i].seq)));
8850
9677
  const pending = [];
9678
+ const resettled = [];
8851
9679
  reads.forEach((read, k) => {
8852
9680
  const index = live[k];
8853
9681
  if (read.status === "rejected") outcomes[index] = {
@@ -8857,8 +9685,17 @@ ${run.workflowVersion}`;
8857
9685
  index,
8858
9686
  cp: read.value
8859
9687
  });
9688
+ else if (read.value?.status === "completed" || read.value?.status === "failed") {
9689
+ resettled.push({
9690
+ index,
9691
+ cp: read.value
9692
+ });
9693
+ }
8860
9694
  });
8861
- if (pending.length === 0) return outcomes;
9695
+ if (pending.length === 0) {
9696
+ if (resettled.length === 0) return outcomes;
9697
+ return this.resumeForRedelivered(runId, resettled, outcomes);
9698
+ }
8862
9699
  for (const { cp } of pending) void this.releaseQueueSlot(cp.stepId).catch(() => void 0);
8863
9700
  const run = await this.store.getRun(runId);
8864
9701
  if (run && (run.status === "cancelled" || run.status === "completed")) return outcomes;
@@ -8910,6 +9747,29 @@ ${run.workflowVersion}`;
8910
9747
  return outcomes;
8911
9748
  }
8912
9749
  /**
9750
+ * The outcomes for a batch of redelivered results (their steps already settled): one of them
9751
+ * resumes the run if it is `suspended` and, for a step of a fan, the fan's barrier is open. A
9752
+ * resume of a run that had in fact moved on replays to its current wait and re-parks.
9753
+ */
9754
+ async resumeForRedelivered(runId, resettled, outcomes) {
9755
+ const run = await this.store.getRun(runId);
9756
+ if (run?.status !== "suspended") return outcomes;
9757
+ const barriers = /* @__PURE__ */ new Map();
9758
+ for (const { index, cp } of resettled) {
9759
+ const asResult = {
9760
+ runId,
9761
+ seq: cp.seq,
9762
+ stepId: cp.stepId ?? stepId(runId, cp.seq),
9763
+ status: cp.status === "failed" ? "failed" : "completed"
9764
+ };
9765
+ if (!await this.stepFanStillRunning(cp, asResult, barriers)) {
9766
+ outcomes[index] = true;
9767
+ break;
9768
+ }
9769
+ }
9770
+ return outcomes;
9771
+ }
9772
+ /**
8913
9773
  * The join barrier of a step fan: a result for one of its steps resumes the parent only once no
8914
9774
  * step of the fan is still `pending` (a settled wave), or on a failure (failFast surfaces it, and a
8915
9775
  * retry needs the parent to schedule it). Without it every result would replay the whole parent,
@@ -9033,7 +9893,7 @@ ${run.workflowVersion}`;
9033
9893
  ...waiters
9034
9894
  ];
9035
9895
  waiters.clear();
9036
- for (const runId of runIds) void this.resumeImplicit(runId).catch(() => void 0);
9896
+ for (const runId of runIds) void this.wake(runId).catch(() => void 0);
9037
9897
  }
9038
9898
  /** Release the flow-control slot a dispatched step held (if any), by its stepId. */
9039
9899
  async releaseQueueSlot(id) {
@@ -9775,6 +10635,8 @@ var CodecStateStore = class {
9775
10635
  registerChildWaits;
9776
10636
  /** The folded checkpoint carries the children's outputs, so it is encoded. */
9777
10637
  compactCheckpoints;
10638
+ /** Decodes each run like {@link getRun}. Bound only when the inner store has it. */
10639
+ getRuns;
9778
10640
  /** Forwarded verbatim — a waiter carries no payload. */
9779
10641
  listSignalWaitersByRun;
9780
10642
  /** Forwarded verbatim — a waiter carries no payload. */
@@ -9818,6 +10680,8 @@ var CodecStateStore = class {
9818
10680
  })));
9819
10681
  const compact = inner.compactCheckpoints;
9820
10682
  if (compact) this.compactCheckpoints = (runId, fromSeq, toSeq, into) => compact.call(inner, runId, fromSeq, toSeq, this.encCp(into));
10683
+ const getRuns = inner.getRuns;
10684
+ if (getRuns) this.getRuns = async (runIds) => (await getRuns.call(inner, runIds)).map((r) => this.decRun(r));
9821
10685
  const byRun = inner.listSignalWaitersByRun;
9822
10686
  if (byRun) this.listSignalWaitersByRun = (runIds) => byRun.call(inner, runIds);
9823
10687
  const hasWaiter = inner.hasSignalWaiter;
@@ -10370,6 +11234,16 @@ var InMemoryStateStore = class {
10370
11234
  ...run
10371
11235
  } : null;
10372
11236
  }
11237
+ async getRuns(runIds) {
11238
+ const out = [];
11239
+ for (const id of new Set(runIds)) {
11240
+ const run = this.runs.get(id);
11241
+ if (run) out.push({
11242
+ ...run
11243
+ });
11244
+ }
11245
+ return out;
11246
+ }
10373
11247
  async deleteRun(runId) {
10374
11248
  this.runs.delete(runId);
10375
11249
  for (const [key, cp] of this.checkpoints) {
@@ -10821,6 +11695,99 @@ var InMemoryTransport = class {
10821
11695
  this.controlHandlers.add(handler);
10822
11696
  }
10823
11697
  };
11698
+
11699
+ // src/testing/in-memory-run-queue.ts
11700
+ var InMemoryRunQueue = class {
11701
+ static {
11702
+ __name(this, "InMemoryRunQueue");
11703
+ }
11704
+ lanes = /* @__PURE__ */ new Map();
11705
+ /** A transport on this queue. */
11706
+ transport() {
11707
+ return {
11708
+ dispatch: /* @__PURE__ */ __name(async (_task) => {
11709
+ throw new Error("InMemoryRunQueue carries no remote steps");
11710
+ }, "dispatch"),
11711
+ onResult: /* @__PURE__ */ __name((_handler) => {
11712
+ }, "onResult"),
11713
+ onHeartbeat: /* @__PURE__ */ __name((_handler) => {
11714
+ }, "onHeartbeat"),
11715
+ dispatchRuns: /* @__PURE__ */ __name(async (runs, opts) => this.add(runs, opts), "dispatchRuns"),
11716
+ onDispatchedRun: /* @__PURE__ */ __name((handler, opts) => this.consume(handler, opts), "onDispatchedRun")
11717
+ };
11718
+ }
11719
+ /** Runs waiting in the queue of `namespace`. */
11720
+ waiting(namespace) {
11721
+ return (this.lanes.get(namespace ?? "")?.waiting ?? []).map((r) => r.runId);
11722
+ }
11723
+ lane(namespace) {
11724
+ const key = namespace ?? "";
11725
+ let lane = this.lanes.get(key);
11726
+ if (!lane) {
11727
+ lane = {
11728
+ waiting: [],
11729
+ queued: /* @__PURE__ */ new Map(),
11730
+ consumers: [],
11731
+ next: 0
11732
+ };
11733
+ this.lanes.set(key, lane);
11734
+ }
11735
+ return lane;
11736
+ }
11737
+ add(runs, opts) {
11738
+ const lane = this.lane(opts?.namespace);
11739
+ for (const run of runs) {
11740
+ const waiting = lane.queued.get(run.runId);
11741
+ if (waiting) {
11742
+ if (run.reason === "wake") waiting.reason = "wake";
11743
+ continue;
11744
+ }
11745
+ const entry = {
11746
+ ...run
11747
+ };
11748
+ lane.queued.set(run.runId, entry);
11749
+ lane.waiting.push(entry);
11750
+ }
11751
+ lane.waiting.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
11752
+ setImmediate(() => this.pump(lane));
11753
+ }
11754
+ consume(handler, opts) {
11755
+ const lane = this.lane(opts.namespace);
11756
+ const consumer = {
11757
+ handler,
11758
+ concurrency: Math.max(1, opts.concurrency),
11759
+ active: 0,
11760
+ stopped: false,
11761
+ idle: []
11762
+ };
11763
+ lane.consumers.push(consumer);
11764
+ setImmediate(() => this.pump(lane));
11765
+ return () => {
11766
+ consumer.stopped = true;
11767
+ lane.consumers = lane.consumers.filter((c) => c !== consumer);
11768
+ if (consumer.active === 0) return Promise.resolve();
11769
+ return new Promise((resolve) => consumer.idle.push(resolve));
11770
+ };
11771
+ }
11772
+ pump(lane) {
11773
+ while (lane.waiting.length > 0) {
11774
+ const free = lane.consumers.filter((c) => !c.stopped && c.active < c.concurrency);
11775
+ if (free.length === 0) return;
11776
+ const consumer = free[lane.next % free.length];
11777
+ lane.next += 1;
11778
+ const run = lane.waiting.shift();
11779
+ lane.queued.delete(run.runId);
11780
+ consumer.active += 1;
11781
+ void consumer.handler(run).catch(() => void 0).then(() => {
11782
+ consumer.active -= 1;
11783
+ if (consumer.active === 0 && consumer.stopped) {
11784
+ for (const resolve of consumer.idle.splice(0)) resolve();
11785
+ }
11786
+ this.pump(lane);
11787
+ });
11788
+ }
11789
+ }
11790
+ };
10824
11791
  export {
10825
11792
  CHILD_EDGE_PREFIXES,
10826
11793
  CONTROL_FLOW_SIGNAL,
@@ -10847,6 +11814,7 @@ export {
10847
11814
  GatherError,
10848
11815
  HANDOFF_SEQ_BASE,
10849
11816
  InMemoryAdmissionBackend,
11817
+ InMemoryRunQueue,
10850
11818
  InMemoryStateStore,
10851
11819
  InMemoryTransport,
10852
11820
  LEGACY_V1_CAPABILITIES,
@@ -10931,6 +11899,7 @@ export {
10931
11899
  fanPageEtag,
10932
11900
  findStalledRuns,
10933
11901
  foldedCount,
11902
+ getRunsByIds,
10934
11903
  handoffHandlerId,
10935
11904
  handoffName,
10936
11905
  hasDurableExecutionWrappers,
@@ -10968,6 +11937,7 @@ export {
10968
11937
  poolNamespace,
10969
11938
  prevCronFireMs,
10970
11939
  queryCheckpointsOf,
11940
+ readRunsByIds,
10971
11941
  readSearchAttributes,
10972
11942
  requiredCapabilities,
10973
11943
  resolveConcurrency,