@dudousxd/nestjs-durable-core 0.73.0 → 0.74.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);
@@ -2611,16 +2721,6 @@ var TransportPool = class {
2611
2721
  }
2612
2722
  };
2613
2723
 
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
2724
  // src/step-name-symbol.ts
2625
2725
  var DURABLE_STEP_NAME = Symbol.for("durable.step.name");
2626
2726
  var DURABLE_STEP_CONFIG = Symbol.for("durable.step.config");
@@ -2642,6 +2742,8 @@ function stepConfigOf(ref) {
2642
2742
  __name(stepConfigOf, "stepConfigOf");
2643
2743
 
2644
2744
  // src/workflow-ctx.ts
2745
+ var FAN_CHUNK = 500;
2746
+ var FOLD_MIN_FAN = 100;
2645
2747
  function normalizeChildOptions(options) {
2646
2748
  return typeof options === "string" ? {
2647
2749
  childId: options
@@ -2984,7 +3086,7 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
2984
3086
  if (existing && existing.status === "completed") {
2985
3087
  return unwrapCompletion(existing.output, `child "${id}"`);
2986
3088
  }
2987
- await store.putSignalWaiter({
3089
+ if (!existing) await store.putSignalWaiter({
2988
3090
  token: `child:${id}`,
2989
3091
  runId,
2990
3092
  seq: current
@@ -3015,16 +3117,33 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3015
3117
  status: "running"
3016
3118
  }));
3017
3119
  }
3018
- throw new WorkflowSuspended();
3120
+ throw new WorkflowSuspended(void 0, fanJoin([
3121
+ {
3122
+ waiterPrefix: `child:${id}`,
3123
+ checkpointPrefix: `signal:child:${id}`
3124
+ }
3125
+ ]));
3019
3126
  }, "child");
3020
3127
  const all = /* @__PURE__ */ __name(async (workflow2, inputs, opts) => {
3021
3128
  if (inputs.length === 0) return [];
3022
3129
  const mode = opts?.mode ?? "waitAll";
3023
3130
  const name = workflowName(workflow2);
3131
+ const waves = allWaves(inputs.length, opts?.concurrency);
3024
3132
  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)));
3133
+ const firstSeq = positions[0];
3134
+ const group = allGroup(firstSeq, mode);
3135
+ const waveOf = /* @__PURE__ */ __name((i) => waves ? Math.floor(i / waves.waveSize) : void 0, "waveOf");
3136
+ const id = /* @__PURE__ */ __name((i) => allChildId(runId, firstSeq, i, waveOf(i)), "id");
3137
+ const { checkpoints: existing, folded } = await readFan(positions, [
3138
+ allWait(runId, firstSeq).checkpointPrefix,
3139
+ allFoldedName(runId, firstSeq)
3140
+ ]);
3141
+ const waveCount = waves ? Math.ceil(inputs.length / waves.waveSize) : 1;
3142
+ const waveItems = /* @__PURE__ */ __name((k) => {
3143
+ if (!waves) return inputs.map((_, i) => i);
3144
+ const from = k * waves.waveSize;
3145
+ return inputs.map((_, i) => i).slice(from, from + waves.waveSize);
3146
+ }, "waveItems");
3028
3147
  if (mode === "failFast") {
3029
3148
  for (let i = 0; i < inputs.length; i += 1) {
3030
3149
  const cp = existing[i];
@@ -3043,32 +3162,91 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3043
3162
  }
3044
3163
  }
3045
3164
  }
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({
3165
+ if (store.compactCheckpoints && inputs.length >= FOLD_MIN_FAN) {
3166
+ for (let k = 0; k < waveCount; k += 1) {
3167
+ const items = waveItems(k);
3168
+ const first = items[0];
3169
+ const last = items[items.length - 1];
3170
+ if (folded[first]) continue;
3171
+ const values = [];
3172
+ for (const i of items) {
3173
+ const c = existing[i]?.status === "completed" ? existing[i]?.output : void 0;
3174
+ if (!c || typeof c !== "object" || c.ok !== true) break;
3175
+ values.push(c.value);
3176
+ }
3177
+ if (values.length < items.length) continue;
3178
+ const into = instantCheckpoint({
3062
3179
  runId,
3063
- seq,
3064
- name: `signal:child:${childId}`,
3180
+ seq: positions[first],
3181
+ name: allFoldedName(runId, firstSeq, waves ? k : void 0),
3065
3182
  kind: "signal",
3066
- status: "running",
3183
+ output: {
3184
+ from: first,
3185
+ values
3186
+ },
3067
3187
  parallelGroup: group
3188
+ });
3189
+ await store.compactCheckpoints(runId, positions[first], positions[last], into);
3190
+ for (const i of items) replay?.delete(positions[i]);
3191
+ replay?.set(into.seq, into);
3192
+ }
3193
+ }
3194
+ const sent = /* @__PURE__ */ __name((k) => waveItems(k).some((i) => existing[i]), "sent");
3195
+ const settled = /* @__PURE__ */ __name((k) => waveItems(k).every((i) => existing[i]?.status === "completed"), "settled");
3196
+ const active = [];
3197
+ let unsettled = 0;
3198
+ for (let k = 0; k < waveCount; k += 1) {
3199
+ if (settled(k)) continue;
3200
+ if (sent(k) || unsettled < (waves?.maxWaves ?? 1)) {
3201
+ active.push(k);
3202
+ unsettled += 1;
3203
+ }
3204
+ }
3205
+ if (active.length > 0) {
3206
+ const toRegister = [];
3207
+ const toEnsure = [];
3208
+ for (const k of active) {
3209
+ for (const i of waveItems(k)) {
3210
+ const cp = existing[i];
3211
+ if (cp?.status === "completed") continue;
3212
+ (cp ? toEnsure : toRegister).push(i);
3213
+ }
3214
+ }
3215
+ for (let at = 0; at < toRegister.length; at += FAN_CHUNK) {
3216
+ const chunk = toRegister.slice(at, at + FAN_CHUNK);
3217
+ const entries = chunk.map((i) => ({
3218
+ // Carry the fan `group` onto the waiter too: the child's terminal `signal:child:`
3219
+ // checkpoint is (re)written by engine.signal when the child notifies the parent,
3220
+ // OVERWRITING the running placeholder at the same seq — so without this the resolved
3221
+ // checkpoint would lose the group and the dashboard would render the resolved fan as a
3222
+ // sequential chain.
3223
+ waiter: {
3224
+ token: `child:${id(i)}`,
3225
+ runId,
3226
+ seq: positions[i],
3227
+ parallelGroup: group
3228
+ },
3229
+ checkpoint: instantCheckpoint({
3230
+ runId,
3231
+ seq: positions[i],
3232
+ name: `signal:child:${id(i)}`,
3233
+ kind: "signal",
3234
+ status: "running",
3235
+ parallelGroup: group
3236
+ })
3237
+ }));
3238
+ await registerChildWaits(entries);
3239
+ for (const i of chunk) host.startChild(name, inputs[i], id(i));
3240
+ }
3241
+ for (let at = 0; at < toEnsure.length; at += FAN_CHUNK) {
3242
+ await Promise.all(toEnsure.slice(at, at + FAN_CHUNK).map(async (i) => {
3243
+ if (!await store.getRun(id(i))) host.startChild(name, inputs[i], id(i));
3068
3244
  }));
3069
3245
  }
3070
3246
  }
3071
- if (pending) throw new WorkflowSuspended();
3247
+ if (active.length > 0) {
3248
+ throw new WorkflowSuspended(void 0, fanJoin(active.map((k) => allWait(runId, firstSeq, waves ? k : void 0))));
3249
+ }
3072
3250
  const outputs = [];
3073
3251
  const failures = [];
3074
3252
  for (let i = 0; i < inputs.length; i += 1) {
@@ -3087,6 +3265,67 @@ function createWorkflowCtx(host, runId, compensations, workflow = "", searchAttr
3087
3265
  if (failures.length > 0) throw new GatherError(failures, inputs.length);
3088
3266
  return outputs;
3089
3267
  }, "all");
3268
+ const readFan = /* @__PURE__ */ __name(async (positions, prefixes) => {
3269
+ const checkpoints = positions.map((seq) => replay?.get(seq) ?? null);
3270
+ const folded = positions.map(() => false);
3271
+ const expand = /* @__PURE__ */ __name(() => {
3272
+ for (let i = 0; i < checkpoints.length; i += 1) {
3273
+ const cp = checkpoints[i];
3274
+ if (!cp?.name.startsWith(FOLDED_PREFIX) || folded[i]) continue;
3275
+ const fold = cp.output;
3276
+ if (!fold || typeof fold.from !== "number" || !Array.isArray(fold.values)) {
3277
+ throw new Error(`ctx.all: folded checkpoint "${cp.name}" at seq ${cp.seq} is malformed`);
3278
+ }
3279
+ const { from, values } = fold;
3280
+ values.forEach((value, j) => {
3281
+ checkpoints[from + j] = {
3282
+ ...cp,
3283
+ seq: positions[from + j],
3284
+ output: {
3285
+ ok: true,
3286
+ value
3287
+ }
3288
+ };
3289
+ folded[from + j] = true;
3290
+ });
3291
+ }
3292
+ }, "expand");
3293
+ expand();
3294
+ const missing = positions.filter((_, i) => !checkpoints[i]);
3295
+ if (missing.length === 0) return {
3296
+ checkpoints,
3297
+ folded
3298
+ };
3299
+ if (missing.length > 1 && store.listCheckpointsByNamePrefix) {
3300
+ const bySeq = /* @__PURE__ */ new Map();
3301
+ for (const cp of await store.listCheckpointsByNamePrefix(runId, prefixes)) {
3302
+ bySeq.set(cp.seq, cp);
3303
+ }
3304
+ positions.forEach((seq, i) => {
3305
+ checkpoints[i] ??= bySeq.get(seq) ?? null;
3306
+ });
3307
+ } else {
3308
+ await Promise.all(positions.map(async (seq, i) => {
3309
+ checkpoints[i] ??= await readCheckpoint(seq);
3310
+ }));
3311
+ }
3312
+ expand();
3313
+ return {
3314
+ checkpoints,
3315
+ folded
3316
+ };
3317
+ }, "readFan");
3318
+ const registerChildWaits = /* @__PURE__ */ __name(async (entries) => {
3319
+ if (store.registerChildWaits) {
3320
+ await store.registerChildWaits(entries);
3321
+ } else {
3322
+ await Promise.all(entries.map(async (e) => {
3323
+ await store.putSignalWaiter(e.waiter);
3324
+ await store.saveCheckpoint(e.checkpoint);
3325
+ }));
3326
+ }
3327
+ for (const e of entries) replay?.set(e.checkpoint.seq, e.checkpoint);
3328
+ }, "registerChildWaits");
3090
3329
  const startChild = /* @__PURE__ */ __name(async (workflow2, input, options) => {
3091
3330
  const { childId, priority, version } = normalizeChildOptions(options);
3092
3331
  const current = pos.next();
@@ -4126,10 +4365,8 @@ var WorkflowEngine = class {
4126
4365
  */
4127
4366
  async findParentRunId(childRunId) {
4128
4367
  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;
4368
+ const waiters = await this.store.listSignalWaiters(token);
4369
+ return waiters.find((waiter) => waiter.token === token)?.runId;
4133
4370
  }
4134
4371
  /** Track an in-flight execution so {@link drain} can wait for it. */
4135
4372
  track(p) {
@@ -4350,7 +4587,10 @@ var WorkflowEngine = class {
4350
4587
  const due = await this.store.listDueTimers(nowMs, this.namespace);
4351
4588
  const blocked = await this.dueBlockedRuns(nowMs);
4352
4589
  if (due.length === 0 && blocked.length === 0) return [];
4353
- const waiterByRun = indexWaitersByRun(await this.store.listSignalWaiters(""));
4590
+ const waiterByRun = indexWaitersByRun(await listSignalWaitersOfRuns(this.store, [
4591
+ ...due,
4592
+ ...blocked
4593
+ ].map((run) => run.id)));
4354
4594
  return this.resumeLeased([
4355
4595
  ...due,
4356
4596
  ...blocked
@@ -4460,11 +4700,6 @@ var WorkflowEngine = class {
4460
4700
  } : {}
4461
4701
  });
4462
4702
  } 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
4703
  const childId = cp.name.slice("signal:child:".length);
4469
4704
  const parked = [];
4470
4705
  for (; ; ) {
@@ -4472,8 +4707,28 @@ var WorkflowEngine = class {
4472
4707
  if (!buffered) break;
4473
4708
  parked.push(buffered.payload);
4474
4709
  }
4710
+ const successAt = parked.findIndex((payload) => typeof payload === "object" && payload !== null && payload.ok === true);
4711
+ const success = successAt === -1 ? void 0 : parked.splice(successAt, 1)[0];
4475
4712
  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);
4713
+ const bufferedSuccess = success !== void 0;
4714
+ if (bufferedSuccess) {
4715
+ await this.store.saveCheckpoint({
4716
+ ...cp,
4717
+ output: success
4718
+ });
4719
+ } else {
4720
+ await this.store.putSignalWaiter({
4721
+ token: `child:${childId}`,
4722
+ runId,
4723
+ seq: cp.seq,
4724
+ parallelGroup: cp.parallelGroup
4725
+ });
4726
+ await this.store.saveCheckpoint({
4727
+ ...cp,
4728
+ status: "running",
4729
+ output: void 0
4730
+ });
4731
+ }
4477
4732
  if (!bufferedSuccess) {
4478
4733
  const child = await this.store.getRun(childId);
4479
4734
  if (child && (child.status === "failed" || child.status === "dead")) {
@@ -4753,12 +5008,62 @@ var WorkflowEngine = class {
4753
5008
  // for an ordinary (non-fan) signal/child await.
4754
5009
  parallelGroup: waiter.parallelGroup
4755
5010
  }));
5011
+ if (await this.fanStillWaiting(waiter, payload)) {
5012
+ return {
5013
+ runId: waiter.runId,
5014
+ status: "suspended"
5015
+ };
5016
+ }
4756
5017
  return this.resumeImplicit(waiter.runId).catch((err) => {
4757
5018
  if (err instanceof NamespaceMismatch) return null;
4758
5019
  throw err;
4759
5020
  });
4760
5021
  }
4761
5022
  /**
5023
+ * The `ctx.all` join barrier: true when `waiter` was one child of a fan (or of one wave of it)
5024
+ * whose siblings are still out, so waking the parent now would only replay the fan and re-suspend.
5025
+ * Skipping that wake is what keeps a fan of N children at O(N) work instead of N replays of N items.
5026
+ *
5027
+ * Safe because the LAST sibling to settle always wakes the parent: each child consumes its own
5028
+ * waiter before it asks, so whichever asks last sees none left. A failed child of a failFast fan
5029
+ * always wakes it. Anything that is not a `ctx.all` waiter, or a store that cannot answer the
5030
+ * question cheaply, wakes the parent as before.
5031
+ */
5032
+ async fanStillWaiting(waiter, payload) {
5033
+ if (!this.store.hasSignalWaiter) return false;
5034
+ const fan = fanOfWaiter(waiter);
5035
+ if (!fan) return false;
5036
+ if (fan.failFast && isFailedCompletion(payload)) return false;
5037
+ return this.store.hasSignalWaiter(waiter.runId, fan.waiterPrefix);
5038
+ }
5039
+ /**
5040
+ * Re-drive a run that just suspended on awaited children (a `ctx.all` fan or wave, a `ctx.child`)
5041
+ * which, by now, have settled.
5042
+ *
5043
+ * {@link execute} drops a wake that finds the run's lease held. So if a child settles while the
5044
+ * parent is still executing (the turn that dispatched it, or one woken by an earlier child), that
5045
+ * wake is spent, and the turn suspends on a snapshot that predates the child. With the `ctx.all`
5046
+ * barrier that wake was the only one the fan would get. Nothing would wake the run until the
5047
+ * `reconcileMs` sweep, minutes later.
5048
+ *
5049
+ * Called after the turn released its lease, so any child that settles from here on wakes the run
5050
+ * itself. Only re-drives for a group with no waiter left whose checkpoints are all settled, which
5051
+ * the next turn is then guaranteed to see — so it cannot loop.
5052
+ */
5053
+ async redriveSettledChildren(runId, fan) {
5054
+ if (!this.store.hasSignalWaiter) return;
5055
+ for (const wait of fan.waits) {
5056
+ if (await this.store.hasSignalWaiter(runId, wait.waiterPrefix)) continue;
5057
+ const checkpoints = this.store.listCheckpointsByNamePrefix ? await this.store.listCheckpointsByNamePrefix(runId, [
5058
+ wait.checkpointPrefix
5059
+ ]) : (await this.store.listCheckpoints(runId)).filter((cp) => cp.name.startsWith(wait.checkpointPrefix));
5060
+ if (checkpoints.length > 0 && checkpoints.every((cp) => cp.status === "completed")) {
5061
+ await this.resumeImplicit(runId);
5062
+ return;
5063
+ }
5064
+ }
5065
+ }
5066
+ /**
4762
5067
  * Deliver an external signal to the run waiting on `token`, resume it with `payload`, and return
4763
5068
  * the run result — or null if no run is (or ends up) waiting for it.
4764
5069
  *
@@ -5051,23 +5356,11 @@ var WorkflowEngine = class {
5051
5356
  }
5052
5357
  /** The given runs plus every descendant, children before parents. One waiter scan for the batch. */
5053
5358
  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
- ];
5359
+ const prefixes = CHILD_EDGE_PREFIXES;
5064
5360
  const childrenOf = /* @__PURE__ */ __name(async (parent) => {
5065
- const ids = new Set(waitersByParent.get(parent) ?? []);
5361
+ const ids = new Set(await this.awaitedChildIds(parent));
5066
5362
  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
- }
5363
+ for (const id of childIdsOf(cps)) ids.add(id);
5071
5364
  return [
5072
5365
  ...ids
5073
5366
  ];
@@ -5117,22 +5410,20 @@ var WorkflowEngine = class {
5117
5410
  * so a completed parent (or a completed awaited child) would otherwise drop out of the tree. The
5118
5411
  * `signal:child:<id>` checkpoint (the placeholder written when the parent first awaits the child,
5119
5412
  * 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.
5413
+ * dedupe — the edge stays stable for a finished run, not just a live one. A settled `ctx.all`
5414
+ * group folded into one `fan:` checkpoint stands for every child in it.
5121
5415
  */
5416
+ /** The children `parentRunId` is awaiting right now, read off its own live `child:` waiters. */
5417
+ async awaitedChildIds(parentRunId) {
5418
+ return (await listSignalWaitersOfRuns(this.store, [
5419
+ parentRunId
5420
+ ])).filter((w) => w.token.startsWith("child:")).map((w) => w.token.slice("child:".length));
5421
+ }
5122
5422
  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
- ];
5423
+ const childIds = new Set(await this.awaitedChildIds(parentRunId));
5424
+ const prefixes = CHILD_EDGE_PREFIXES;
5131
5425
  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
- }
5426
+ for (const id of childIdsOf(childCheckpoints)) childIds.add(id);
5136
5427
  return [
5137
5428
  ...childIds
5138
5429
  ];
@@ -6212,6 +6503,7 @@ var WorkflowEngine = class {
6212
6503
  const snapshot = await this.store.listCheckpoints(run.id);
6213
6504
  const replay = /* @__PURE__ */ new Map();
6214
6505
  for (const cp of snapshot) replay.set(cp.seq, cp);
6506
+ let suspendedOnFan;
6215
6507
  const ctx = createWorkflowCtx(this.ctxHostFor(replay, run.namespace), run.id, compensations, run.workflow, registered?.searchAttributesSchema);
6216
6508
  try {
6217
6509
  const output = await runInWorkflowCtx(ctx, () => fn(ctx, run.input));
@@ -6263,10 +6555,12 @@ var WorkflowEngine = class {
6263
6555
  if (this.cancelRequested.has(run.id)) {
6264
6556
  return await this.compensateAndCancel(run, compensations, replay);
6265
6557
  }
6266
- return await this.settleRun(run, {
6558
+ const suspended = await this.settleRun(run, {
6267
6559
  kind: "suspended",
6268
6560
  wakeAt: err.wakeAt
6269
6561
  });
6562
+ suspendedOnFan = err.fan;
6563
+ return suspended;
6270
6564
  }
6271
6565
  if (this.cancelRequested.has(run.id)) {
6272
6566
  return await this.compensateAndCancel(run, compensations, replay);
@@ -6286,6 +6580,9 @@ var WorkflowEngine = class {
6286
6580
  });
6287
6581
  } finally {
6288
6582
  await this.store.releaseRunLock(run.id);
6583
+ if (suspendedOnFan) {
6584
+ void this.redriveSettledChildren(run.id, suspendedOnFan).catch(() => void 0);
6585
+ }
6289
6586
  }
6290
6587
  }
6291
6588
  /**
@@ -7100,6 +7397,21 @@ var RemoteStepError = class extends Error {
7100
7397
  this.stepError = stepError;
7101
7398
  }
7102
7399
  };
7400
+ var CHILD_EDGE_PREFIXES = [
7401
+ "signal:child:",
7402
+ FOLDED_PREFIX,
7403
+ "spawn:"
7404
+ ];
7405
+ function childIdsOf(checkpoints) {
7406
+ const ids = [];
7407
+ for (const cp of checkpoints) {
7408
+ if (cp.name.startsWith("signal:child:")) ids.push(cp.name.slice("signal:child:".length));
7409
+ if (cp.name.startsWith(FOLDED_PREFIX)) ids.push(...foldedChildIds(cp));
7410
+ if (cp.name.startsWith("spawn:") && typeof cp.output === "string") ids.push(cp.output);
7411
+ }
7412
+ return ids;
7413
+ }
7414
+ __name(childIdsOf, "childIdsOf");
7103
7415
 
7104
7416
  // src/run-facets.ts
7105
7417
  function facetOrigin(origin) {
@@ -7249,6 +7561,14 @@ var CodecStateStore = class {
7249
7561
  /** Forwarded verbatim — a count reads no payload. Bound only when the inner store counts, so the
7250
7562
  * engine's facet/listing fallback still applies through the wrapper. */
7251
7563
  countRuns;
7564
+ /** The placeholder checkpoints are encoded like any other checkpoint. */
7565
+ registerChildWaits;
7566
+ /** The folded checkpoint carries the children's outputs, so it is encoded. */
7567
+ compactCheckpoints;
7568
+ /** Forwarded verbatim — a waiter carries no payload. */
7569
+ listSignalWaitersByRun;
7570
+ /** Forwarded verbatim — a waiter carries no payload. */
7571
+ hasSignalWaiter;
7252
7572
  /** Schedules persist their run `input` inside `spec` — encoded like any other payload. Bound only
7253
7573
  * when the inner store persists schedules, so `engine.schedules` reports absence faithfully. */
7254
7574
  saveSchedule;
@@ -7266,6 +7586,17 @@ var CodecStateStore = class {
7266
7586
  if (prune) this.pruneTerminalRuns = (policy, now, limit) => prune.call(inner, policy, now, limit);
7267
7587
  const count = inner.countRuns;
7268
7588
  if (count) this.countRuns = (query) => count.call(inner, query);
7589
+ const registerChildWaits = inner.registerChildWaits;
7590
+ if (registerChildWaits) this.registerChildWaits = (entries) => registerChildWaits.call(inner, entries.map((e) => ({
7591
+ waiter: e.waiter,
7592
+ checkpoint: this.encCp(e.checkpoint)
7593
+ })));
7594
+ const compact = inner.compactCheckpoints;
7595
+ if (compact) this.compactCheckpoints = (runId, fromSeq, toSeq, into) => compact.call(inner, runId, fromSeq, toSeq, this.encCp(into));
7596
+ const byRun = inner.listSignalWaitersByRun;
7597
+ if (byRun) this.listSignalWaitersByRun = (runIds) => byRun.call(inner, runIds);
7598
+ const hasWaiter = inner.hasSignalWaiter;
7599
+ if (hasWaiter) this.hasSignalWaiter = (runId, tokenPrefix) => hasWaiter.call(inner, runId, tokenPrefix);
7269
7600
  const { saveSchedule, getSchedule, updateSchedule, deleteSchedule, listSchedules } = inner;
7270
7601
  if (saveSchedule && getSchedule && updateSchedule && deleteSchedule && listSchedules) {
7271
7602
  this.saveSchedule = (r) => saveSchedule.call(inner, this.encSchedule(r));
@@ -7876,6 +8207,33 @@ var InMemoryStateStore = class {
7876
8207
  ...w
7877
8208
  }));
7878
8209
  }
8210
+ async registerChildWaits(entries) {
8211
+ for (const { waiter } of entries) this.signalWaiters.set(waiter.token, {
8212
+ ...waiter
8213
+ });
8214
+ for (const { checkpoint } of entries) await this.saveCheckpoint(checkpoint);
8215
+ }
8216
+ async compactCheckpoints(runId, fromSeq, toSeq, into) {
8217
+ for (let seq = fromSeq; seq <= toSeq; seq += 1) this.checkpoints.delete(this.key(runId, seq));
8218
+ this.checkpoints.set(this.key(runId, into.seq), {
8219
+ ...into,
8220
+ runId
8221
+ });
8222
+ }
8223
+ async listSignalWaitersByRun(runIds) {
8224
+ const ids = new Set(runIds);
8225
+ return [
8226
+ ...this.signalWaiters.values()
8227
+ ].filter((w) => ids.has(w.runId)).map((w) => ({
8228
+ ...w
8229
+ }));
8230
+ }
8231
+ async hasSignalWaiter(runId, tokenPrefix) {
8232
+ for (const w of this.signalWaiters.values()) {
8233
+ if (w.runId === runId && w.token.startsWith(tokenPrefix)) return true;
8234
+ }
8235
+ return false;
8236
+ }
7879
8237
  async removeSignalWaiter(waiter) {
7880
8238
  const current = this.signalWaiters.get(waiter.token);
7881
8239
  if (current && current.runId === waiter.runId && current.seq === waiter.seq) {
@@ -8230,6 +8588,7 @@ export {
8230
8588
  isLegacyDescriptor,
8231
8589
  isNonEmptyRunScope,
8232
8590
  isWorkflowControlFlowSignal,
8591
+ listSignalWaitersOfRuns,
8233
8592
  log,
8234
8593
  matchesAttributes,
8235
8594
  mergeRunFacetRows,
@@ -8240,6 +8599,7 @@ export {
8240
8599
  normalizeAttributeRows,
8241
8600
  normalizeDescriptor,
8242
8601
  observedAnnouncements,
8602
+ parentRunIdOf,
8243
8603
  parseDuration,
8244
8604
  partitionOfToken,
8245
8605
  planDispatch,