@dudousxd/nestjs-durable-core 0.73.0 → 0.75.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
@@ -389,12 +389,15 @@ var WorkflowSuspended = class extends Error {
389
389
  }
390
390
  /** Epoch ms to auto-resume (durable sleep), or undefined when waiting on an external signal. */
391
391
  wakeAt;
392
+ /** Set when awaited children are what the run is waiting on — see {@link FanJoin}. */
393
+ fan;
392
394
  /** Marks this as a control-flow signal — see {@link isWorkflowControlFlowSignal}. */
393
395
  [CONTROL_FLOW_SIGNAL] = true;
394
- constructor(wakeAt) {
396
+ constructor(wakeAt, fan) {
395
397
  super("workflow suspended");
396
398
  this.name = "WorkflowSuspended";
397
399
  this.wakeAt = wakeAt;
400
+ this.fan = fan;
398
401
  }
399
402
  };
400
403
  var ContinueAsNew = class extends Error {
@@ -821,6 +824,21 @@ function instantCheckpoint(p) {
821
824
  }
822
825
  __name(instantCheckpoint, "instantCheckpoint");
823
826
 
827
+ // src/completion.ts
828
+ function isFailedCompletion(payload) {
829
+ const c = payload;
830
+ return !!c && typeof c === "object" && "ok" in c && c.ok === false;
831
+ }
832
+ __name(isFailedCompletion, "isFailedCompletion");
833
+ function unwrapCompletion(payload, label) {
834
+ const c = payload;
835
+ if (c && typeof c === "object" && "ok" in c && c.ok === false) {
836
+ throw new FatalError(`${label} failed: ${c.error}`);
837
+ }
838
+ return c?.value;
839
+ }
840
+ __name(unwrapCompletion, "unwrapCompletion");
841
+
824
842
  // src/concurrency.ts
825
843
  var DEFAULT_CONCURRENCY_STATUSES = [
826
844
  "pending",
@@ -1060,6 +1078,81 @@ function eventMatches(payload, match) {
1060
1078
  }
1061
1079
  __name(eventMatches, "eventMatches");
1062
1080
 
1081
+ // src/fan-join.ts
1082
+ function allChildId(runId, firstSeq, index, wave) {
1083
+ return `${allBase(runId, firstSeq, wave)}${index}`;
1084
+ }
1085
+ __name(allChildId, "allChildId");
1086
+ function allGroup(firstSeq, mode) {
1087
+ return mode === "failFast" ? `all:${firstSeq}:failFast` : `all:${firstSeq}`;
1088
+ }
1089
+ __name(allGroup, "allGroup");
1090
+ function allWait(runId, firstSeq, wave) {
1091
+ const base = allBase(runId, firstSeq, wave);
1092
+ return {
1093
+ waiterPrefix: `child:${base}`,
1094
+ checkpointPrefix: `signal:child:${base}`
1095
+ };
1096
+ }
1097
+ __name(allWait, "allWait");
1098
+ function fanJoin(waits) {
1099
+ return {
1100
+ waits
1101
+ };
1102
+ }
1103
+ __name(fanJoin, "fanJoin");
1104
+ var FOLDED_PREFIX = "fan:";
1105
+ function allFoldedName(runId, firstSeq, wave) {
1106
+ return `${FOLDED_PREFIX}${allBase(runId, firstSeq, wave)}`;
1107
+ }
1108
+ __name(allFoldedName, "allFoldedName");
1109
+ function foldedChildIds(checkpoint) {
1110
+ const base = checkpoint.name.slice(FOLDED_PREFIX.length);
1111
+ const { from, values } = checkpoint.output;
1112
+ return values.map((_, j) => `${base}${from + j}`);
1113
+ }
1114
+ __name(foldedChildIds, "foldedChildIds");
1115
+ function allBase(runId, firstSeq, wave) {
1116
+ return wave === void 0 ? `${runId}.all.${firstSeq}.` : `${runId}.all.${firstSeq}.w${wave}.`;
1117
+ }
1118
+ __name(allBase, "allBase");
1119
+ function allWaves(size, concurrency) {
1120
+ if (concurrency === void 0 || concurrency >= size) return void 0;
1121
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
1122
+ throw new Error(`ctx.all: concurrency must be a positive integer, got ${concurrency}`);
1123
+ }
1124
+ if (concurrency === 1) return {
1125
+ waveSize: 1,
1126
+ maxWaves: 1
1127
+ };
1128
+ return {
1129
+ waveSize: Math.floor(concurrency / 2),
1130
+ maxWaves: 2
1131
+ };
1132
+ }
1133
+ __name(allWaves, "allWaves");
1134
+ var ENGINE_CHILD_ID = /^(.+)\.(?:all\.\d+\.(?:w\d+\.)?\d+|child\.\d+)$/;
1135
+ function parentRunIdOf(runId) {
1136
+ return ENGINE_CHILD_ID.exec(runId)?.[1];
1137
+ }
1138
+ __name(parentRunIdOf, "parentRunIdOf");
1139
+ var GROUP = /^all:(\d+)(:failFast)?$/;
1140
+ var REST = /^(?:w(\d+)\.)?\d+$/;
1141
+ function fanOfWaiter(waiter) {
1142
+ const group = waiter.parallelGroup ? GROUP.exec(waiter.parallelGroup) : null;
1143
+ if (!group) return void 0;
1144
+ const base = `child:${waiter.runId}.all.${group[1]}.`;
1145
+ if (!waiter.token.startsWith(base)) return void 0;
1146
+ const rest = REST.exec(waiter.token.slice(base.length));
1147
+ if (!rest) return void 0;
1148
+ const waiterPrefix = rest[1] === void 0 ? base : `${base}w${rest[1]}.`;
1149
+ return {
1150
+ waiterPrefix,
1151
+ failFast: group[2] !== void 0
1152
+ };
1153
+ }
1154
+ __name(fanOfWaiter, "fanOfWaiter");
1155
+
1063
1156
  // src/handshake/descriptor.ts
1064
1157
  var CURRENT_PROTOCOL_VERSION = 1;
1065
1158
  var LEGACY_V1_PROTOCOL = {
@@ -1801,6 +1894,23 @@ function resolveRunWaiting(run, waiterByRun) {
1801
1894
  return waiter ? classifyWaiterToken(waiter.token) : void 0;
1802
1895
  }
1803
1896
  __name(resolveRunWaiting, "resolveRunWaiting");
1897
+ var RUN_ID_BATCH = 500;
1898
+ async function listSignalWaitersOfRuns(store, runIds) {
1899
+ const unique = [
1900
+ ...new Set(runIds)
1901
+ ];
1902
+ if (unique.length === 0) return [];
1903
+ if (!store.listSignalWaitersByRun) {
1904
+ const ids = new Set(unique);
1905
+ return (await store.listSignalWaiters("")).filter((w) => ids.has(w.runId));
1906
+ }
1907
+ const out = [];
1908
+ for (let at = 0; at < unique.length; at += RUN_ID_BATCH) {
1909
+ out.push(...await store.listSignalWaitersByRun(unique.slice(at, at + RUN_ID_BATCH)));
1910
+ }
1911
+ return out;
1912
+ }
1913
+ __name(listSignalWaitersOfRuns, "listSignalWaitersOfRuns");
1804
1914
  function indexWaitersByRun(waiters) {
1805
1915
  const byRun = /* @__PURE__ */ new Map();
1806
1916
  for (const w of waiters) if (!byRun.has(w.runId)) byRun.set(w.runId, w);
@@ -2077,12 +2187,14 @@ var ScheduleClient = class {
2077
2187
  const store = this.requireStore();
2078
2188
  const started = [];
2079
2189
  for (let round = 0; round < 50; round++) {
2190
+ if (this.host.isDraining?.()) break;
2080
2191
  const due = await store.listSchedules({
2081
2192
  dueBy: nowMs,
2082
2193
  namespace: this.options.namespace,
2083
2194
  limit: TICK_BATCH
2084
2195
  });
2085
2196
  for (const record of due) {
2197
+ if (this.host.isDraining?.()) return started;
2086
2198
  const runId = await this.fire(record, nowMs);
2087
2199
  if (runId) started.push(runId);
2088
2200
  }
@@ -2611,16 +2723,6 @@ var TransportPool = class {
2611
2723
  }
2612
2724
  };
2613
2725
 
2614
- // src/completion.ts
2615
- function unwrapCompletion(payload, label) {
2616
- const c = payload;
2617
- if (c && typeof c === "object" && "ok" in c && c.ok === false) {
2618
- throw new FatalError(`${label} failed: ${c.error}`);
2619
- }
2620
- return c?.value;
2621
- }
2622
- __name(unwrapCompletion, "unwrapCompletion");
2623
-
2624
2726
  // src/step-name-symbol.ts
2625
2727
  var DURABLE_STEP_NAME = Symbol.for("durable.step.name");
2626
2728
  var DURABLE_STEP_CONFIG = Symbol.for("durable.step.config");
@@ -2642,6 +2744,8 @@ function stepConfigOf(ref) {
2642
2744
  __name(stepConfigOf, "stepConfigOf");
2643
2745
 
2644
2746
  // src/workflow-ctx.ts
2747
+ var FAN_CHUNK = 500;
2748
+ var FOLD_MIN_FAN = 100;
2645
2749
  function normalizeChildOptions(options) {
2646
2750
  return typeof options === "string" ? {
2647
2751
  childId: options
@@ -2984,7 +3088,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
2984
3088
  if (existing && existing.status === "completed") {
2985
3089
  return unwrapCompletion(existing.output, `child "${id}"`);
2986
3090
  }
2987
- await store.putSignalWaiter({
3091
+ if (!existing) await store.putSignalWaiter({
2988
3092
  token: `child:${id}`,
2989
3093
  runId,
2990
3094
  seq: current
@@ -3015,16 +3119,33 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3015
3119
  status: "running"
3016
3120
  }));
3017
3121
  }
3018
- throw new WorkflowSuspended();
3122
+ throw new WorkflowSuspended(void 0, fanJoin([
3123
+ {
3124
+ waiterPrefix: `child:${id}`,
3125
+ checkpointPrefix: `signal:child:${id}`
3126
+ }
3127
+ ]));
3019
3128
  }, "child");
3020
3129
  const all = /* @__PURE__ */ __name(async (workflow2, inputs, opts) => {
3021
3130
  if (inputs.length === 0) return [];
3022
3131
  const mode = opts?.mode ?? "waitAll";
3023
3132
  const name = workflowName(workflow2);
3133
+ const waves = allWaves(inputs.length, opts?.concurrency);
3024
3134
  const positions = inputs.map(() => pos.next());
3025
- const group = `all:${positions[0]}`;
3026
- const id = /* @__PURE__ */ __name((i) => `${runId}.all.${positions[0]}.${i}`, "id");
3027
- const existing = await Promise.all(positions.map((seq) => readCheckpoint(seq)));
3135
+ const firstSeq = positions[0];
3136
+ const group = allGroup(firstSeq, mode);
3137
+ const waveOf = /* @__PURE__ */ __name((i) => waves ? Math.floor(i / waves.waveSize) : void 0, "waveOf");
3138
+ const id = /* @__PURE__ */ __name((i) => allChildId(runId, firstSeq, i, waveOf(i)), "id");
3139
+ const { checkpoints: existing, folded } = await readFan(positions, [
3140
+ allWait(runId, firstSeq).checkpointPrefix,
3141
+ allFoldedName(runId, firstSeq)
3142
+ ]);
3143
+ const waveCount = waves ? Math.ceil(inputs.length / waves.waveSize) : 1;
3144
+ const waveItems = /* @__PURE__ */ __name((k) => {
3145
+ if (!waves) return inputs.map((_, i) => i);
3146
+ const from = k * waves.waveSize;
3147
+ return inputs.map((_, i) => i).slice(from, from + waves.waveSize);
3148
+ }, "waveItems");
3028
3149
  if (mode === "failFast") {
3029
3150
  for (let i = 0; i < inputs.length; i += 1) {
3030
3151
  const cp = existing[i];
@@ -3043,32 +3164,91 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3043
3164
  }
3044
3165
  }
3045
3166
  }
3046
- let pending = false;
3047
- for (let i = 0; i < inputs.length; i += 1) {
3048
- const cp = existing[i];
3049
- if (cp?.status === "completed") continue;
3050
- pending = true;
3051
- const seq = positions[i];
3052
- const childId = id(i);
3053
- await store.putSignalWaiter({
3054
- token: `child:${childId}`,
3055
- runId,
3056
- seq,
3057
- parallelGroup: group
3058
- });
3059
- if (!await store.getRun(childId)) host.startChild(name, inputs[i], childId);
3060
- if (!cp) {
3061
- await writeCheckpoint(instantCheckpoint({
3167
+ if (store.compactCheckpoints && inputs.length >= FOLD_MIN_FAN) {
3168
+ for (let k = 0; k < waveCount; k += 1) {
3169
+ const items = waveItems(k);
3170
+ const first = items[0];
3171
+ const last = items[items.length - 1];
3172
+ if (folded[first]) continue;
3173
+ const values = [];
3174
+ for (const i of items) {
3175
+ const c = existing[i]?.status === "completed" ? existing[i]?.output : void 0;
3176
+ if (!c || typeof c !== "object" || c.ok !== true) break;
3177
+ values.push(c.value);
3178
+ }
3179
+ if (values.length < items.length) continue;
3180
+ const into = instantCheckpoint({
3062
3181
  runId,
3063
- seq,
3064
- name: `signal:child:${childId}`,
3182
+ seq: positions[first],
3183
+ name: allFoldedName(runId, firstSeq, waves ? k : void 0),
3065
3184
  kind: "signal",
3066
- status: "running",
3185
+ output: {
3186
+ from: first,
3187
+ values
3188
+ },
3067
3189
  parallelGroup: group
3190
+ });
3191
+ await store.compactCheckpoints(runId, positions[first], positions[last], into);
3192
+ for (const i of items) replay?.delete(positions[i]);
3193
+ replay?.set(into.seq, into);
3194
+ }
3195
+ }
3196
+ const sent = /* @__PURE__ */ __name((k) => waveItems(k).some((i) => existing[i]), "sent");
3197
+ const settled = /* @__PURE__ */ __name((k) => waveItems(k).every((i) => existing[i]?.status === "completed"), "settled");
3198
+ const active = [];
3199
+ let unsettled = 0;
3200
+ for (let k = 0; k < waveCount; k += 1) {
3201
+ if (settled(k)) continue;
3202
+ if (sent(k) || unsettled < (waves?.maxWaves ?? 1)) {
3203
+ active.push(k);
3204
+ unsettled += 1;
3205
+ }
3206
+ }
3207
+ if (active.length > 0) {
3208
+ const toRegister = [];
3209
+ const toEnsure = [];
3210
+ for (const k of active) {
3211
+ for (const i of waveItems(k)) {
3212
+ const cp = existing[i];
3213
+ if (cp?.status === "completed") continue;
3214
+ (cp ? toEnsure : toRegister).push(i);
3215
+ }
3216
+ }
3217
+ for (let at = 0; at < toRegister.length; at += FAN_CHUNK) {
3218
+ const chunk = toRegister.slice(at, at + FAN_CHUNK);
3219
+ const entries = chunk.map((i) => ({
3220
+ // Carry the fan `group` onto the waiter too: the child's terminal `signal:child:`
3221
+ // checkpoint is (re)written by engine.signal when the child notifies the parent,
3222
+ // OVERWRITING the running placeholder at the same seq — so without this the resolved
3223
+ // checkpoint would lose the group and the dashboard would render the resolved fan as a
3224
+ // sequential chain.
3225
+ waiter: {
3226
+ token: `child:${id(i)}`,
3227
+ runId,
3228
+ seq: positions[i],
3229
+ parallelGroup: group
3230
+ },
3231
+ checkpoint: instantCheckpoint({
3232
+ runId,
3233
+ seq: positions[i],
3234
+ name: `signal:child:${id(i)}`,
3235
+ kind: "signal",
3236
+ status: "running",
3237
+ parallelGroup: group
3238
+ })
3239
+ }));
3240
+ await registerChildWaits(entries);
3241
+ for (const i of chunk) host.startChild(name, inputs[i], id(i));
3242
+ }
3243
+ for (let at = 0; at < toEnsure.length; at += FAN_CHUNK) {
3244
+ await Promise.all(toEnsure.slice(at, at + FAN_CHUNK).map(async (i) => {
3245
+ if (!await store.getRun(id(i))) host.startChild(name, inputs[i], id(i));
3068
3246
  }));
3069
3247
  }
3070
3248
  }
3071
- if (pending) throw new WorkflowSuspended();
3249
+ if (active.length > 0) {
3250
+ throw new WorkflowSuspended(void 0, fanJoin(active.map((k) => allWait(runId, firstSeq, waves ? k : void 0))));
3251
+ }
3072
3252
  const outputs = [];
3073
3253
  const failures = [];
3074
3254
  for (let i = 0; i < inputs.length; i += 1) {
@@ -3087,6 +3267,67 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3087
3267
  if (failures.length > 0) throw new GatherError(failures, inputs.length);
3088
3268
  return outputs;
3089
3269
  }, "all");
3270
+ const readFan = /* @__PURE__ */ __name(async (positions, prefixes) => {
3271
+ const checkpoints = positions.map((seq) => replay?.get(seq) ?? null);
3272
+ const folded = positions.map(() => false);
3273
+ const expand = /* @__PURE__ */ __name(() => {
3274
+ for (let i = 0; i < checkpoints.length; i += 1) {
3275
+ const cp = checkpoints[i];
3276
+ if (!cp?.name.startsWith(FOLDED_PREFIX) || folded[i]) continue;
3277
+ const fold = cp.output;
3278
+ if (!fold || typeof fold.from !== "number" || !Array.isArray(fold.values)) {
3279
+ throw new Error(`ctx.all: folded checkpoint "${cp.name}" at seq ${cp.seq} is malformed`);
3280
+ }
3281
+ const { from, values } = fold;
3282
+ values.forEach((value, j) => {
3283
+ checkpoints[from + j] = {
3284
+ ...cp,
3285
+ seq: positions[from + j],
3286
+ output: {
3287
+ ok: true,
3288
+ value
3289
+ }
3290
+ };
3291
+ folded[from + j] = true;
3292
+ });
3293
+ }
3294
+ }, "expand");
3295
+ expand();
3296
+ const missing = positions.filter((_, i) => !checkpoints[i]);
3297
+ if (missing.length === 0) return {
3298
+ checkpoints,
3299
+ folded
3300
+ };
3301
+ if (missing.length > 1 && store.listCheckpointsByNamePrefix) {
3302
+ const bySeq = /* @__PURE__ */ new Map();
3303
+ for (const cp of await store.listCheckpointsByNamePrefix(runId, prefixes)) {
3304
+ bySeq.set(cp.seq, cp);
3305
+ }
3306
+ positions.forEach((seq, i) => {
3307
+ checkpoints[i] ??= bySeq.get(seq) ?? null;
3308
+ });
3309
+ } else {
3310
+ await Promise.all(positions.map(async (seq, i) => {
3311
+ checkpoints[i] ??= await readCheckpoint(seq);
3312
+ }));
3313
+ }
3314
+ expand();
3315
+ return {
3316
+ checkpoints,
3317
+ folded
3318
+ };
3319
+ }, "readFan");
3320
+ const registerChildWaits = /* @__PURE__ */ __name(async (entries) => {
3321
+ if (store.registerChildWaits) {
3322
+ await store.registerChildWaits(entries);
3323
+ } else {
3324
+ await Promise.all(entries.map(async (e) => {
3325
+ await store.putSignalWaiter(e.waiter);
3326
+ await store.saveCheckpoint(e.checkpoint);
3327
+ }));
3328
+ }
3329
+ for (const e of entries) replay?.set(e.checkpoint.seq, e.checkpoint);
3330
+ }, "registerChildWaits");
3090
3331
  const startChild = /* @__PURE__ */ __name(async (workflow2, input, options) => {
3091
3332
  const { childId, priority, version } = normalizeChildOptions(options);
3092
3333
  const current = pos.next();
@@ -3466,6 +3707,8 @@ var WorkflowEngine = class {
3466
3707
  /** Executions currently in flight, so a graceful shutdown can wait for them to settle. */
3467
3708
  inflight = /* @__PURE__ */ new Set();
3468
3709
  draining = false;
3710
+ /** Wait already spent on this shutdown before `drain()` (see `beginDrain`). */
3711
+ drainSpentMs = 0;
3469
3712
  constructor(deps) {
3470
3713
  this.store = deps.store;
3471
3714
  this.pool = new TransportPool(deps.transports ?? (deps.transport ? [
@@ -3514,7 +3757,8 @@ var WorkflowEngine = class {
3514
3757
  });
3515
3758
  this.schedules = new ScheduleClient(this.store, {
3516
3759
  start: /* @__PURE__ */ __name((workflow, input, runId, opts) => this.start(workflow, input, runId, opts), "start"),
3517
- getRun: /* @__PURE__ */ __name((runId) => this.store.getRun(runId), "getRun")
3760
+ getRun: /* @__PURE__ */ __name((runId) => this.store.getRun(runId), "getRun"),
3761
+ isDraining: /* @__PURE__ */ __name(() => this.draining, "isDraining")
3518
3762
  }, {
3519
3763
  namespace: this.namespace,
3520
3764
  clock: this.clock
@@ -4126,10 +4370,8 @@ var WorkflowEngine = class {
4126
4370
  */
4127
4371
  async findParentRunId(childRunId) {
4128
4372
  const token = `child:${childRunId}`;
4129
- for (const waiter of await this.store.listSignalWaiters("child:")) {
4130
- if (waiter.token === token) return waiter.runId;
4131
- }
4132
- return void 0;
4373
+ const waiters = await this.store.listSignalWaiters(token);
4374
+ return waiters.find((waiter) => waiter.token === token)?.runId;
4133
4375
  }
4134
4376
  /** Track an in-flight execution so {@link drain} can wait for it. */
4135
4377
  track(p) {
@@ -4141,15 +4383,34 @@ var WorkflowEngine = class {
4141
4383
  return p;
4142
4384
  }
4143
4385
  /**
4386
+ * Stop picking up new work without waiting: recovery, timer, schedule and timeout sweeps (and
4387
+ * their per-run lock loops) start nothing once this is called, and {@link runOne} returns null. A
4388
+ * poller calls it on its own shutdown so an in-flight sweep stops locking runs before it is awaited.
4389
+ *
4390
+ * `spentMs` is wait time ALREADY spent on this shutdown (the poller awaiting its in-flight sweep).
4391
+ * It is deducted from the budget of a later {@link drain}, so that wait plus the drain stay within
4392
+ * one `shutdownTimeoutMs`. Only time actually spent waiting is charged: time that merely passes
4393
+ * between this call and `drain()` (other hooks, the HTTP server closing) never is. Calls add up.
4394
+ */
4395
+ beginDrain(spentMs = 0) {
4396
+ this.draining = true;
4397
+ this.drainSpentMs += Math.max(0, spentMs);
4398
+ }
4399
+ /**
4144
4400
  * Graceful shutdown: stop picking up new runs (recovery/timer become no-ops) and wait for
4145
4401
  * in-flight executions to settle, up to `timeoutMs`. Call from your app's shutdown hook so a
4146
4402
  * deploy hands off cleanly instead of leaving runs to the lease timeout.
4403
+ *
4404
+ * `timeoutMs` is the total budget for waiting on in-flight runs; any wait already reported through
4405
+ * {@link beginDrain} (`spentMs`) is deducted, so a shutdown that already waited on the timer poll
4406
+ * for part of it waits only for the remainder. With nothing reported, the full `timeoutMs` applies.
4147
4407
  */
4148
4408
  async drain(timeoutMs = 1e4) {
4149
- this.draining = true;
4409
+ this.beginDrain();
4410
+ const budget = Math.max(0, timeoutMs - this.drainSpentMs);
4150
4411
  if (this.inflight.size === 0) return;
4151
4412
  const timer = new Promise((resolve) => {
4152
- const t = setTimeout(resolve, timeoutMs);
4413
+ const t = setTimeout(resolve, budget);
4153
4414
  t.unref?.();
4154
4415
  });
4155
4416
  await Promise.race([
@@ -4191,6 +4452,7 @@ var WorkflowEngine = class {
4191
4452
  */
4192
4453
  async sweepTimeouts(now = this.clock()) {
4193
4454
  for (const reg of new Set(this.latest.values())) {
4455
+ if (this.draining) return;
4194
4456
  if (reg.executionTimeoutMs == null) continue;
4195
4457
  const deadline = now - reg.executionTimeoutMs;
4196
4458
  const inflight = [
@@ -4206,6 +4468,7 @@ var WorkflowEngine = class {
4206
4468
  })
4207
4469
  ];
4208
4470
  for (const run of inflight) {
4471
+ if (this.draining) return;
4209
4472
  if (run.createdAt.getTime() > deadline) continue;
4210
4473
  const error2 = {
4211
4474
  message: "execution timeout",
@@ -4235,6 +4498,7 @@ var WorkflowEngine = class {
4235
4498
  if (this.draining) return [];
4236
4499
  const results = [];
4237
4500
  for (const listed of await this.store.listIncompleteRuns(this.namespace)) {
4501
+ if (this.draining) break;
4238
4502
  const acquired = await this.store.tryLockRun(listed.id, this.instanceId, nowMs + this.leaseMs, nowMs);
4239
4503
  if (!acquired) continue;
4240
4504
  const run = await this.store.getRun(listed.id);
@@ -4350,7 +4614,10 @@ var WorkflowEngine = class {
4350
4614
  const due = await this.store.listDueTimers(nowMs, this.namespace);
4351
4615
  const blocked = await this.dueBlockedRuns(nowMs);
4352
4616
  if (due.length === 0 && blocked.length === 0) return [];
4353
- const waiterByRun = indexWaitersByRun(await this.store.listSignalWaiters(""));
4617
+ const waiterByRun = indexWaitersByRun(await listSignalWaitersOfRuns(this.store, [
4618
+ ...due,
4619
+ ...blocked
4620
+ ].map((run) => run.id)));
4354
4621
  return this.resumeLeased([
4355
4622
  ...due,
4356
4623
  ...blocked
@@ -4460,11 +4727,6 @@ var WorkflowEngine = class {
4460
4727
  } : {}
4461
4728
  });
4462
4729
  } else if (cp.kind === "signal" && cp.name.startsWith("signal:child:") && cp.status === "completed" && isFailureCompletion(cp.output)) {
4463
- await this.store.saveCheckpoint({
4464
- ...cp,
4465
- status: "running",
4466
- output: void 0
4467
- });
4468
4730
  const childId = cp.name.slice("signal:child:".length);
4469
4731
  const parked = [];
4470
4732
  for (; ; ) {
@@ -4472,8 +4734,28 @@ var WorkflowEngine = class {
4472
4734
  if (!buffered) break;
4473
4735
  parked.push(buffered.payload);
4474
4736
  }
4737
+ const successAt = parked.findIndex((payload) => typeof payload === "object" && payload !== null && payload.ok === true);
4738
+ const success = successAt === -1 ? void 0 : parked.splice(successAt, 1)[0];
4475
4739
  for (const payload of parked) await this.store.bufferSignal(`child:${childId}`, payload);
4476
- const bufferedSuccess = parked.some((payload) => typeof payload === "object" && payload !== null && payload.ok === true);
4740
+ const bufferedSuccess = success !== void 0;
4741
+ if (bufferedSuccess) {
4742
+ await this.store.saveCheckpoint({
4743
+ ...cp,
4744
+ output: success
4745
+ });
4746
+ } else {
4747
+ await this.store.putSignalWaiter({
4748
+ token: `child:${childId}`,
4749
+ runId,
4750
+ seq: cp.seq,
4751
+ parallelGroup: cp.parallelGroup
4752
+ });
4753
+ await this.store.saveCheckpoint({
4754
+ ...cp,
4755
+ status: "running",
4756
+ output: void 0
4757
+ });
4758
+ }
4477
4759
  if (!bufferedSuccess) {
4478
4760
  const child = await this.store.getRun(childId);
4479
4761
  if (child && (child.status === "failed" || child.status === "dead")) {
@@ -4652,8 +4934,13 @@ var WorkflowEngine = class {
4652
4934
  if (this.draining) return [];
4653
4935
  const results = [];
4654
4936
  for (const run of runs) {
4937
+ if (this.draining) break;
4655
4938
  const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
4656
4939
  if (!acquired) continue;
4940
+ if (this.draining) {
4941
+ await this.store.releaseRunLock(run.id);
4942
+ break;
4943
+ }
4657
4944
  try {
4658
4945
  const settled = onLocked ? await onLocked(run) : void 0;
4659
4946
  results.push(settled ?? await this.resumeImplicit(run.id));
@@ -4753,12 +5040,62 @@ var WorkflowEngine = class {
4753
5040
  // for an ordinary (non-fan) signal/child await.
4754
5041
  parallelGroup: waiter.parallelGroup
4755
5042
  }));
5043
+ if (await this.fanStillWaiting(waiter, payload)) {
5044
+ return {
5045
+ runId: waiter.runId,
5046
+ status: "suspended"
5047
+ };
5048
+ }
4756
5049
  return this.resumeImplicit(waiter.runId).catch((err) => {
4757
5050
  if (err instanceof NamespaceMismatch) return null;
4758
5051
  throw err;
4759
5052
  });
4760
5053
  }
4761
5054
  /**
5055
+ * The `ctx.all` join barrier: true when `waiter` was one child of a fan (or of one wave of it)
5056
+ * whose siblings are still out, so waking the parent now would only replay the fan and re-suspend.
5057
+ * Skipping that wake is what keeps a fan of N children at O(N) work instead of N replays of N items.
5058
+ *
5059
+ * Safe because the LAST sibling to settle always wakes the parent: each child consumes its own
5060
+ * waiter before it asks, so whichever asks last sees none left. A failed child of a failFast fan
5061
+ * always wakes it. Anything that is not a `ctx.all` waiter, or a store that cannot answer the
5062
+ * question cheaply, wakes the parent as before.
5063
+ */
5064
+ async fanStillWaiting(waiter, payload) {
5065
+ if (!this.store.hasSignalWaiter) return false;
5066
+ const fan = fanOfWaiter(waiter);
5067
+ if (!fan) return false;
5068
+ if (fan.failFast && isFailedCompletion(payload)) return false;
5069
+ return this.store.hasSignalWaiter(waiter.runId, fan.waiterPrefix);
5070
+ }
5071
+ /**
5072
+ * Re-drive a run that just suspended on awaited children (a `ctx.all` fan or wave, a `ctx.child`)
5073
+ * which, by now, have settled.
5074
+ *
5075
+ * {@link execute} drops a wake that finds the run's lease held. So if a child settles while the
5076
+ * parent is still executing (the turn that dispatched it, or one woken by an earlier child), that
5077
+ * wake is spent, and the turn suspends on a snapshot that predates the child. With the `ctx.all`
5078
+ * barrier that wake was the only one the fan would get. Nothing would wake the run until the
5079
+ * `reconcileMs` sweep, minutes later.
5080
+ *
5081
+ * Called after the turn released its lease, so any child that settles from here on wakes the run
5082
+ * itself. Only re-drives for a group with no waiter left whose checkpoints are all settled, which
5083
+ * the next turn is then guaranteed to see — so it cannot loop.
5084
+ */
5085
+ async redriveSettledChildren(runId, fan) {
5086
+ if (!this.store.hasSignalWaiter) return;
5087
+ for (const wait of fan.waits) {
5088
+ if (await this.store.hasSignalWaiter(runId, wait.waiterPrefix)) continue;
5089
+ const checkpoints = this.store.listCheckpointsByNamePrefix ? await this.store.listCheckpointsByNamePrefix(runId, [
5090
+ wait.checkpointPrefix
5091
+ ]) : (await this.store.listCheckpoints(runId)).filter((cp) => cp.name.startsWith(wait.checkpointPrefix));
5092
+ if (checkpoints.length > 0 && checkpoints.every((cp) => cp.status === "completed")) {
5093
+ await this.resumeImplicit(runId);
5094
+ return;
5095
+ }
5096
+ }
5097
+ }
5098
+ /**
4762
5099
  * Deliver an external signal to the run waiting on `token`, resume it with `payload`, and return
4763
5100
  * the run result — or null if no run is (or ends up) waiting for it.
4764
5101
  *
@@ -5051,23 +5388,11 @@ var WorkflowEngine = class {
5051
5388
  }
5052
5389
  /** The given runs plus every descendant, children before parents. One waiter scan for the batch. */
5053
5390
  async collectSubtrees(rootIds) {
5054
- const waitersByParent = /* @__PURE__ */ new Map();
5055
- for (const w of await this.store.listSignalWaiters("child:")) {
5056
- const list = waitersByParent.get(w.runId) ?? [];
5057
- list.push(w.token.slice("child:".length));
5058
- waitersByParent.set(w.runId, list);
5059
- }
5060
- const prefixes = [
5061
- "signal:child:",
5062
- "spawn:"
5063
- ];
5391
+ const prefixes = CHILD_EDGE_PREFIXES;
5064
5392
  const childrenOf = /* @__PURE__ */ __name(async (parent) => {
5065
- const ids = new Set(waitersByParent.get(parent) ?? []);
5393
+ const ids = new Set(await this.awaitedChildIds(parent));
5066
5394
  const cps = this.store.listCheckpointsByNamePrefix ? await this.store.listCheckpointsByNamePrefix(parent, prefixes) : (await this.store.listCheckpoints(parent)).filter((cp) => prefixes.some((p) => cp.name.startsWith(p)));
5067
- for (const cp of cps) {
5068
- if (cp.name.startsWith("signal:child:")) ids.add(cp.name.slice("signal:child:".length));
5069
- if (cp.name.startsWith("spawn:") && typeof cp.output === "string") ids.add(cp.output);
5070
- }
5395
+ for (const id of childIdsOf(cps)) ids.add(id);
5071
5396
  return [
5072
5397
  ...ids
5073
5398
  ];
@@ -5117,22 +5442,20 @@ var WorkflowEngine = class {
5117
5442
  * so a completed parent (or a completed awaited child) would otherwise drop out of the tree. The
5118
5443
  * `signal:child:<id>` checkpoint (the placeholder written when the parent first awaits the child,
5119
5444
  * overwritten as completed/failed when it settles) persists across completion, so we read both and
5120
- * dedupe — the edge stays stable for a finished run, not just a live one.
5445
+ * dedupe — the edge stays stable for a finished run, not just a live one. A settled `ctx.all`
5446
+ * group folded into one `fan:` checkpoint stands for every child in it.
5121
5447
  */
5448
+ /** The children `parentRunId` is awaiting right now, read off its own live `child:` waiters. */
5449
+ async awaitedChildIds(parentRunId) {
5450
+ return (await listSignalWaitersOfRuns(this.store, [
5451
+ parentRunId
5452
+ ])).filter((w) => w.token.startsWith("child:")).map((w) => w.token.slice("child:".length));
5453
+ }
5122
5454
  async getRunChildren(parentRunId) {
5123
- const childIds = /* @__PURE__ */ new Set();
5124
- for (const w of await this.store.listSignalWaiters("child:")) {
5125
- if (w.runId === parentRunId) childIds.add(w.token.slice("child:".length));
5126
- }
5127
- const prefixes = [
5128
- "signal:child:",
5129
- "spawn:"
5130
- ];
5455
+ const childIds = new Set(await this.awaitedChildIds(parentRunId));
5456
+ const prefixes = CHILD_EDGE_PREFIXES;
5131
5457
  const childCheckpoints = this.store.listCheckpointsByNamePrefix ? await this.store.listCheckpointsByNamePrefix(parentRunId, prefixes) : (await this.store.listCheckpoints(parentRunId)).filter((cp) => prefixes.some((p) => cp.name.startsWith(p)));
5132
- for (const cp of childCheckpoints) {
5133
- if (cp.name.startsWith("signal:child:")) childIds.add(cp.name.slice("signal:child:".length));
5134
- if (cp.name.startsWith("spawn:") && typeof cp.output === "string") childIds.add(cp.output);
5135
- }
5458
+ for (const id of childIdsOf(childCheckpoints)) childIds.add(id);
5136
5459
  return [
5137
5460
  ...childIds
5138
5461
  ];
@@ -6212,6 +6535,7 @@ var WorkflowEngine = class {
6212
6535
  const snapshot = await this.store.listCheckpoints(run.id);
6213
6536
  const replay = /* @__PURE__ */ new Map();
6214
6537
  for (const cp of snapshot) replay.set(cp.seq, cp);
6538
+ let suspendedOnFan;
6215
6539
  const ctx = createWorkflowCtx(this.ctxHostFor(replay, run.namespace), run.id, compensations, run.workflow, registered?.searchAttributesSchema);
6216
6540
  try {
6217
6541
  const output = await runInWorkflowCtx(ctx, () => fn(ctx, run.input));
@@ -6263,10 +6587,12 @@ var WorkflowEngine = class {
6263
6587
  if (this.cancelRequested.has(run.id)) {
6264
6588
  return await this.compensateAndCancel(run, compensations, replay);
6265
6589
  }
6266
- return await this.settleRun(run, {
6590
+ const suspended = await this.settleRun(run, {
6267
6591
  kind: "suspended",
6268
6592
  wakeAt: err.wakeAt
6269
6593
  });
6594
+ suspendedOnFan = err.fan;
6595
+ return suspended;
6270
6596
  }
6271
6597
  if (this.cancelRequested.has(run.id)) {
6272
6598
  return await this.compensateAndCancel(run, compensations, replay);
@@ -6286,6 +6612,9 @@ var WorkflowEngine = class {
6286
6612
  });
6287
6613
  } finally {
6288
6614
  await this.store.releaseRunLock(run.id);
6615
+ if (suspendedOnFan) {
6616
+ void this.redriveSettledChildren(run.id, suspendedOnFan).catch(() => void 0);
6617
+ }
6289
6618
  }
6290
6619
  }
6291
6620
  /**
@@ -7100,6 +7429,21 @@ var RemoteStepError = class extends Error {
7100
7429
  this.stepError = stepError;
7101
7430
  }
7102
7431
  };
7432
+ var CHILD_EDGE_PREFIXES = [
7433
+ "signal:child:",
7434
+ FOLDED_PREFIX,
7435
+ "spawn:"
7436
+ ];
7437
+ function childIdsOf(checkpoints) {
7438
+ const ids = [];
7439
+ for (const cp of checkpoints) {
7440
+ if (cp.name.startsWith("signal:child:")) ids.push(cp.name.slice("signal:child:".length));
7441
+ if (cp.name.startsWith(FOLDED_PREFIX)) ids.push(...foldedChildIds(cp));
7442
+ if (cp.name.startsWith("spawn:") && typeof cp.output === "string") ids.push(cp.output);
7443
+ }
7444
+ return ids;
7445
+ }
7446
+ __name(childIdsOf, "childIdsOf");
7103
7447
 
7104
7448
  // src/run-facets.ts
7105
7449
  function facetOrigin(origin) {
@@ -7249,6 +7593,14 @@ var CodecStateStore = class {
7249
7593
  /** Forwarded verbatim — a count reads no payload. Bound only when the inner store counts, so the
7250
7594
  * engine's facet/listing fallback still applies through the wrapper. */
7251
7595
  countRuns;
7596
+ /** The placeholder checkpoints are encoded like any other checkpoint. */
7597
+ registerChildWaits;
7598
+ /** The folded checkpoint carries the children's outputs, so it is encoded. */
7599
+ compactCheckpoints;
7600
+ /** Forwarded verbatim — a waiter carries no payload. */
7601
+ listSignalWaitersByRun;
7602
+ /** Forwarded verbatim — a waiter carries no payload. */
7603
+ hasSignalWaiter;
7252
7604
  /** Schedules persist their run `input` inside `spec` — encoded like any other payload. Bound only
7253
7605
  * when the inner store persists schedules, so `engine.schedules` reports absence faithfully. */
7254
7606
  saveSchedule;
@@ -7266,6 +7618,17 @@ var CodecStateStore = class {
7266
7618
  if (prune) this.pruneTerminalRuns = (policy, now, limit) => prune.call(inner, policy, now, limit);
7267
7619
  const count = inner.countRuns;
7268
7620
  if (count) this.countRuns = (query) => count.call(inner, query);
7621
+ const registerChildWaits = inner.registerChildWaits;
7622
+ if (registerChildWaits) this.registerChildWaits = (entries) => registerChildWaits.call(inner, entries.map((e) => ({
7623
+ waiter: e.waiter,
7624
+ checkpoint: this.encCp(e.checkpoint)
7625
+ })));
7626
+ const compact = inner.compactCheckpoints;
7627
+ if (compact) this.compactCheckpoints = (runId, fromSeq, toSeq, into) => compact.call(inner, runId, fromSeq, toSeq, this.encCp(into));
7628
+ const byRun = inner.listSignalWaitersByRun;
7629
+ if (byRun) this.listSignalWaitersByRun = (runIds) => byRun.call(inner, runIds);
7630
+ const hasWaiter = inner.hasSignalWaiter;
7631
+ if (hasWaiter) this.hasSignalWaiter = (runId, tokenPrefix) => hasWaiter.call(inner, runId, tokenPrefix);
7269
7632
  const { saveSchedule, getSchedule, updateSchedule, deleteSchedule, listSchedules } = inner;
7270
7633
  if (saveSchedule && getSchedule && updateSchedule && deleteSchedule && listSchedules) {
7271
7634
  this.saveSchedule = (r) => saveSchedule.call(inner, this.encSchedule(r));
@@ -7876,6 +8239,33 @@ var InMemoryStateStore = class {
7876
8239
  ...w
7877
8240
  }));
7878
8241
  }
8242
+ async registerChildWaits(entries) {
8243
+ for (const { waiter } of entries) this.signalWaiters.set(waiter.token, {
8244
+ ...waiter
8245
+ });
8246
+ for (const { checkpoint } of entries) await this.saveCheckpoint(checkpoint);
8247
+ }
8248
+ async compactCheckpoints(runId, fromSeq, toSeq, into) {
8249
+ for (let seq = fromSeq; seq <= toSeq; seq += 1) this.checkpoints.delete(this.key(runId, seq));
8250
+ this.checkpoints.set(this.key(runId, into.seq), {
8251
+ ...into,
8252
+ runId
8253
+ });
8254
+ }
8255
+ async listSignalWaitersByRun(runIds) {
8256
+ const ids = new Set(runIds);
8257
+ return [
8258
+ ...this.signalWaiters.values()
8259
+ ].filter((w) => ids.has(w.runId)).map((w) => ({
8260
+ ...w
8261
+ }));
8262
+ }
8263
+ async hasSignalWaiter(runId, tokenPrefix) {
8264
+ for (const w of this.signalWaiters.values()) {
8265
+ if (w.runId === runId && w.token.startsWith(tokenPrefix)) return true;
8266
+ }
8267
+ return false;
8268
+ }
7879
8269
  async removeSignalWaiter(waiter) {
7880
8270
  const current = this.signalWaiters.get(waiter.token);
7881
8271
  if (current && current.runId === waiter.runId && current.seq === waiter.seq) {
@@ -8230,6 +8620,7 @@ export {
8230
8620
  isLegacyDescriptor,
8231
8621
  isNonEmptyRunScope,
8232
8622
  isWorkflowControlFlowSignal,
8623
+ listSignalWaitersOfRuns,
8233
8624
  log,
8234
8625
  matchesAttributes,
8235
8626
  mergeRunFacetRows,
@@ -8240,6 +8631,7 @@ export {
8240
8631
  normalizeAttributeRows,
8241
8632
  normalizeDescriptor,
8242
8633
  observedAnnouncements,
8634
+ parentRunIdOf,
8243
8635
  parseDuration,
8244
8636
  partitionOfToken,
8245
8637
  planDispatch,