effect-mq 0.5.0 → 0.7.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.
Files changed (61) hide show
  1. package/README.md +85 -17
  2. package/dist/Flow.d.ts +381 -0
  3. package/dist/Flow.d.ts.map +1 -0
  4. package/dist/Flow.js +340 -0
  5. package/dist/Flow.js.map +1 -0
  6. package/dist/Job.d.ts +31 -6
  7. package/dist/Job.d.ts.map +1 -1
  8. package/dist/Job.js +16 -2
  9. package/dist/Job.js.map +1 -1
  10. package/dist/JobStore.d.ts +353 -13
  11. package/dist/JobStore.d.ts.map +1 -1
  12. package/dist/JobStore.js +10 -0
  13. package/dist/JobStore.js.map +1 -1
  14. package/dist/MemoryJobStore.d.ts.map +1 -1
  15. package/dist/MemoryJobStore.js +361 -21
  16. package/dist/MemoryJobStore.js.map +1 -1
  17. package/dist/Metrics.d.ts +31 -0
  18. package/dist/Metrics.d.ts.map +1 -1
  19. package/dist/Metrics.js +39 -0
  20. package/dist/Metrics.js.map +1 -1
  21. package/dist/Worker.d.ts +120 -11
  22. package/dist/Worker.d.ts.map +1 -1
  23. package/dist/Worker.js +452 -26
  24. package/dist/Worker.js.map +1 -1
  25. package/dist/drizzle-postgres/DrizzleJobStore.d.ts +19 -1
  26. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  27. package/dist/drizzle-postgres/DrizzleJobStore.js +678 -80
  28. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  29. package/dist/drizzle-postgres/schema.d.ts +293 -3
  30. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  31. package/dist/drizzle-postgres/schema.js +66 -1
  32. package/dist/drizzle-postgres/schema.js.map +1 -1
  33. package/dist/index.d.ts +7 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +7 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/redis/RedisJobStore.d.ts +53 -0
  38. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  39. package/dist/redis/RedisJobStore.js +402 -50
  40. package/dist/redis/RedisJobStore.js.map +1 -1
  41. package/dist/redis/scripts.d.ts +213 -35
  42. package/dist/redis/scripts.d.ts.map +1 -1
  43. package/dist/redis/scripts.js +689 -73
  44. package/dist/redis/scripts.js.map +1 -1
  45. package/dist/testing/conformance.d.ts +6 -0
  46. package/dist/testing/conformance.d.ts.map +1 -1
  47. package/dist/testing/conformance.js +855 -12
  48. package/dist/testing/conformance.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/Flow.ts +778 -0
  51. package/src/Job.ts +35 -11
  52. package/src/JobStore.ts +377 -12
  53. package/src/MemoryJobStore.ts +396 -25
  54. package/src/Metrics.ts +43 -0
  55. package/src/Worker.ts +726 -37
  56. package/src/drizzle-postgres/DrizzleJobStore.ts +844 -81
  57. package/src/drizzle-postgres/schema.ts +92 -0
  58. package/src/index.ts +8 -0
  59. package/src/redis/RedisJobStore.ts +540 -39
  60. package/src/redis/scripts.ts +751 -78
  61. package/src/testing/conformance.ts +1088 -12
@@ -30,6 +30,8 @@ const snapshot = (job) => ({
30
30
  cancelRequested: job.cancelRequested,
31
31
  dedupeKey: job.dedupeKey,
32
32
  trace: job.trace,
33
+ parent: job.parent,
34
+ flow: job.flow,
33
35
  runAt: job.runAt,
34
36
  enqueuedAt: job.enqueuedAt,
35
37
  processedAt: job.processedAt,
@@ -48,6 +50,29 @@ const makeStoreUnsafe = (options) => {
48
50
  const jobs = new Map();
49
51
  const schedules = new Map();
50
52
  const paused = new Set();
53
+ // Flow dependency rows, keyed by parent job id then child key. Insertion
54
+ // order is FanOut spec order; listChildResults sorts by child key.
55
+ const flowChildren = new Map();
56
+ // Undelivered child-result reports, appended atomically with every
57
+ // terminal transition of an envelope-carrying job (see OutboxEntry).
58
+ const outbox = [];
59
+ let outboxSeq = 0;
60
+ const appendOutbox = (job, outcome) => {
61
+ if (job.parent === undefined)
62
+ return;
63
+ outbox.push({
64
+ id: `ob-${++outboxSeq}`,
65
+ flowName: job.parent.flowName,
66
+ parentStoreKey: job.parent.parentStoreKey,
67
+ report: {
68
+ flowId: job.parent.flowId,
69
+ childKey: job.parent.childKey,
70
+ outcome,
71
+ exit: job.exit,
72
+ failedReason: job.failedReason
73
+ }
74
+ });
75
+ };
51
76
  // Dedup registry: one entry per (name, key). `expiresAt` is set for
52
77
  // ttl/throttle windows; pending-mode entries live as long as their job.
53
78
  const dedupes = new Map();
@@ -117,12 +142,67 @@ const makeStoreUnsafe = (options) => {
117
142
  dedupes.delete(key);
118
143
  }
119
144
  };
145
+ // A pruned/removed flow parent takes its dependency rows with it.
146
+ const deleteJob = (id) => {
147
+ jobs.delete(id);
148
+ // SAFETY: flowChildren keys are JobIds; a plain string that is not one
149
+ // simply misses.
150
+ flowChildren.delete(id);
151
+ };
152
+ // A settled flow parent whose rows still owe cascade cancels is exempt
153
+ // from automatic retention (keep policies, the history sweep): deleting
154
+ // it would delete the only record that the child stores are still owed
155
+ // real cancels, leaving marked children running. Once the sweeper marks
156
+ // the rows cascaded, retention applies normally. The explicit `remove`
157
+ // verb is NOT exempted — it is an operator override.
158
+ const owesCascades = (id) => {
159
+ // SAFETY: flowChildren keys are JobIds; a plain string that is not one
160
+ // simply misses.
161
+ const rows = flowChildren.get(id);
162
+ if (rows === undefined)
163
+ return false;
164
+ for (const row of rows.values()) {
165
+ if (row.status === "cancelled" && !row.cascaded)
166
+ return true;
167
+ }
168
+ return false;
169
+ };
170
+ // Settle-time marking: remaining pending rows flip to cancelled (NOT
171
+ // cascaded — the sweeper still owes the child stores real cancels), so
172
+ // late reports find their row terminal and drop, and `listChildResults`
173
+ // stays truthful. Returns how many rows flipped, for the flow counters.
174
+ // Lock order note: in this driver everything is one synchronous block,
175
+ // but the row-then-parent order is still observed.
176
+ const markPendingRowsCancelled = (flowId) => {
177
+ const rows = flowChildren.get(flowId);
178
+ if (rows === undefined)
179
+ return 0;
180
+ let marked = 0;
181
+ for (const row of rows.values()) {
182
+ if (row.status === "pending") {
183
+ row.status = "cancelled";
184
+ row.cascaded = false;
185
+ marked += 1;
186
+ }
187
+ }
188
+ return marked;
189
+ };
190
+ const settleMarkRows = (job) => {
191
+ const marked = markPendingRowsCancelled(job.id);
192
+ if (job.flow !== undefined) {
193
+ job.flow = { ...job.flow, pending: 0, cancelled: job.flow.cancelled + marked };
194
+ }
195
+ };
120
196
  const markCancelled = (job, now) => {
121
197
  clearLock(job);
122
198
  job.cancelRequested = false;
199
+ if (job.state === "waiting-children") {
200
+ settleMarkRows(job);
201
+ }
123
202
  job.state = "cancelled";
124
203
  job.finishedAt = now;
125
204
  recordAttempt(job, "cancelled", now, undefined);
205
+ appendOutbox(job, "cancelled");
126
206
  releaseDedupe(job, now);
127
207
  applyKeep(job, now);
128
208
  };
@@ -171,7 +251,9 @@ const makeStoreUnsafe = (options) => {
171
251
  }
172
252
  }
173
253
  for (const id of remove) {
174
- jobs.delete(id);
254
+ if (owesCascades(id))
255
+ continue;
256
+ deleteJob(id);
175
257
  }
176
258
  };
177
259
  const sweepHistory = (now, ttlByState) => {
@@ -185,8 +267,8 @@ const makeStoreUnsafe = (options) => {
185
267
  // The sweep honours min(per-row keep age, store ceiling) — a quiet job
186
268
  // name is pruned on the timer, not only when its group is acked.
187
269
  const effective = keepAge !== undefined && (ttl === undefined || keepAge < ttl) ? keepAge : ttl;
188
- if (effective !== undefined && job.finishedAt <= now - effective) {
189
- jobs.delete(job.id);
270
+ if (effective !== undefined && job.finishedAt <= now - effective && !owesCascades(job.id)) {
271
+ deleteJob(job.id);
190
272
  }
191
273
  }
192
274
  // Dead dedup entries: expired window, or a pointer at a vanished job.
@@ -206,7 +288,8 @@ const makeStoreUnsafe = (options) => {
206
288
  }
207
289
  switch (job.state) {
208
290
  case "waiting":
209
- case "delayed": {
291
+ case "delayed":
292
+ case "waiting-children": {
210
293
  markCancelled(job, now);
211
294
  return;
212
295
  }
@@ -237,6 +320,8 @@ const makeStoreUnsafe = (options) => {
237
320
  cancelRequested: false,
238
321
  dedupeKey: request.dedupe?.key,
239
322
  trace: request.trace,
323
+ parent: request.parent,
324
+ flow: undefined,
240
325
  runAt: now + Math.max(0, request.delayMs),
241
326
  enqueuedAt: now,
242
327
  processedAt: undefined,
@@ -375,15 +460,78 @@ const makeStoreUnsafe = (options) => {
375
460
  if (job.state !== "active" || job.lockToken !== token) {
376
461
  return yield* new LockLostError({ jobId: id });
377
462
  }
463
+ if (outcome._tag === "FanOut" &&
464
+ outcome.children.some((child) => child.request.id === undefined)) {
465
+ // Validate BEFORE any mutation, so a bad spec cannot leave the job
466
+ // half-acked (lock cleared, ledger written, still active).
467
+ return yield* new JobStoreError({
468
+ message: "FanOut child specs require an explicit request.id"
469
+ });
470
+ }
378
471
  clearLock(job);
379
- job.attemptsMade += 1;
472
+ // A fan-out is a phase transition, not a completed run — the attempt
473
+ // budget spans both phases.
474
+ if (outcome._tag !== "FanOut") {
475
+ job.attemptsMade += 1;
476
+ }
380
477
  switch (outcome._tag) {
478
+ case "FanOut": {
479
+ recordAttempt(job, "fanned-out", now, undefined);
480
+ if (job.flow === undefined) {
481
+ job.flow = {
482
+ failFast: outcome.failFast,
483
+ pending: outcome.children.length,
484
+ completed: 0,
485
+ failed: 0,
486
+ cancelled: 0
487
+ };
488
+ const rows = new Map();
489
+ for (const child of outcome.children) {
490
+ rows.set(child.childKey, {
491
+ flowId: job.id,
492
+ childKey: child.childKey,
493
+ storeKey: child.storeKey,
494
+ spec: child.request,
495
+ status: "pending",
496
+ exit: undefined,
497
+ failedReason: undefined,
498
+ cascaded: false,
499
+ pendingSince: now
500
+ });
501
+ }
502
+ flowChildren.set(job.id, rows);
503
+ }
504
+ // A manifest that was already present is kept untouched (double
505
+ // fan-out converges on the persisted children); the state
506
+ // transition follows the persisted pending count either way.
507
+ if (job.cancelRequested) {
508
+ // A cancel raced the fan-out: cancellation wins. Mark the rows
509
+ // here — the job is still `active`, so markCancelled's own
510
+ // waiting-children marking does not apply — and the sweeper
511
+ // cascades (mostly no-op cancels for never-enqueued children).
512
+ settleMarkRows(job);
513
+ markCancelled(job, now);
514
+ break;
515
+ }
516
+ if (job.flow.pending > 0) {
517
+ job.state = "waiting-children";
518
+ }
519
+ else {
520
+ // Empty spec: settle straight to runnable `collect`.
521
+ job.state = "waiting";
522
+ job.runAt = now;
523
+ job.seq = ++seq;
524
+ signalWake(job.queue);
525
+ }
526
+ break;
527
+ }
381
528
  case "Complete": {
382
529
  job.cancelRequested = false;
383
530
  job.state = "completed";
384
531
  job.exit = outcome.exit;
385
532
  job.finishedAt = now;
386
533
  recordAttempt(job, "completed", now, outcome.exit);
534
+ appendOutbox(job, "completed");
387
535
  releaseDedupe(job, now);
388
536
  applyKeep(job, now);
389
537
  break;
@@ -394,6 +542,7 @@ const makeStoreUnsafe = (options) => {
394
542
  job.exit = outcome.exit;
395
543
  job.finishedAt = now;
396
544
  recordAttempt(job, "failed", now, outcome.exit);
545
+ appendOutbox(job, "failed");
397
546
  releaseDedupe(job, now);
398
547
  applyKeep(job, now);
399
548
  break;
@@ -403,6 +552,7 @@ const makeStoreUnsafe = (options) => {
403
552
  job.state = "cancelled";
404
553
  job.finishedAt = now;
405
554
  recordAttempt(job, "cancelled", now, undefined);
555
+ appendOutbox(job, "cancelled");
406
556
  releaseDedupe(job, now);
407
557
  applyKeep(job, now);
408
558
  break;
@@ -485,6 +635,7 @@ const makeStoreUnsafe = (options) => {
485
635
  job.state = "failed";
486
636
  job.finishedAt = now;
487
637
  job.failedReason = "job stalled more than allowable limit";
638
+ appendOutbox(job, "failed");
488
639
  releaseDedupe(job, now);
489
640
  recovered.push({ id: job.id, failed: true });
490
641
  }
@@ -516,7 +667,13 @@ const makeStoreUnsafe = (options) => {
516
667
  list: (options) => Effect.sync(() => {
517
668
  const limit = Math.max(1, options.limit ?? 50);
518
669
  const states = options.states === undefined ? undefined : new Set(options.states);
519
- // Newest first, stable across retries: (enqueuedAt desc, id desc).
670
+ const orderBy = options.orderBy ?? "enqueuedAt";
671
+ const descending = (options.order ?? "desc") === "desc";
672
+ // Jobs missing the field (finishedAt on non-terminal rows) sort as 0.
673
+ const orderValue = (job) => orderBy === "enqueuedAt" ? job.enqueuedAt : orderBy === "runAt" ? job.runAt : job.finishedAt ?? 0;
674
+ const compareIds = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
675
+ // Stable across retries: the id tiebreak follows the direction, and
676
+ // the cursor excludes everything at or before its (value, id).
520
677
  let cursor;
521
678
  if (options.cursor !== undefined) {
522
679
  const split = options.cursor.indexOf(":");
@@ -530,22 +687,29 @@ const makeStoreUnsafe = (options) => {
530
687
  (options.name === undefined || job.name === options.name) &&
531
688
  (states === undefined || states.has(job.state)) &&
532
689
  (options.metadata === undefined || metadataMatches(job.metadata, options.metadata)))
533
- .toSorted((a, b) => b.enqueuedAt !== a.enqueuedAt
534
- ? b.enqueuedAt - a.enqueuedAt
535
- : b.id < a.id
536
- ? -1
537
- : b.id > a.id
538
- ? 1
539
- : 0)
540
- .filter((job) => cursor === undefined ||
541
- job.enqueuedAt < cursor.at ||
542
- (job.enqueuedAt === cursor.at && job.id < cursor.id));
690
+ .toSorted((a, b) => {
691
+ const byValue = descending
692
+ ? orderValue(b) - orderValue(a)
693
+ : orderValue(a) - orderValue(b);
694
+ if (byValue !== 0)
695
+ return byValue;
696
+ return descending ? compareIds(b.id, a.id) : compareIds(a.id, b.id);
697
+ })
698
+ .filter((job) => {
699
+ if (cursor === undefined)
700
+ return true;
701
+ const value = orderValue(job);
702
+ if (descending) {
703
+ return value < cursor.at || (value === cursor.at && job.id < cursor.id);
704
+ }
705
+ return value > cursor.at || (value === cursor.at && job.id > cursor.id);
706
+ });
543
707
  const items = matches.slice(0, limit).map(snapshot);
544
- const last = items[items.length - 1];
708
+ const lastJob = matches[Math.min(limit, matches.length) - 1];
545
709
  const result = {
546
710
  items,
547
- cursor: matches.length > limit && last !== undefined
548
- ? `${last.enqueuedAt}:${last.id}`
711
+ cursor: matches.length > limit && lastJob !== undefined
712
+ ? `${orderValue(lastJob)}:${lastJob.id}`
549
713
  : undefined
550
714
  };
551
715
  return result;
@@ -653,11 +817,186 @@ const makeStoreUnsafe = (options) => {
653
817
  insertJobRecord(id, request, now);
654
818
  return true;
655
819
  }),
820
+ recordChildResults: (reports) => Effect.gen(function* () {
821
+ const now = yield* Clock.currentTimeMillis;
822
+ const results = reports.map(() => ({ applied: false, parentSettled: false }));
823
+ // Phase 1 — apply every row update (lock order: rows before
824
+ // parents), tracking per flow which report would decide a settle.
825
+ const touched = new Map();
826
+ for (const [index, report] of reports.entries()) {
827
+ const row = flowChildren.get(report.flowId)?.get(report.childKey);
828
+ if (row === undefined || row.status !== "pending")
829
+ continue;
830
+ row.status = report.outcome;
831
+ row.exit = report.exit;
832
+ row.failedReason = report.failedReason;
833
+ // The outcome came from the child's store: no cancel to deliver.
834
+ row.cascaded = true;
835
+ results[index] = { applied: true, parentSettled: false };
836
+ const parent = jobs.get(report.flowId);
837
+ if (parent?.flow !== undefined) {
838
+ const flow = parent.flow;
839
+ parent.flow = {
840
+ ...flow,
841
+ pending: Math.max(0, flow.pending - 1),
842
+ completed: flow.completed + (report.outcome === "completed" ? 1 : 0),
843
+ failed: flow.failed + (report.outcome === "failed" ? 1 : 0),
844
+ cancelled: flow.cancelled + (report.outcome === "cancelled" ? 1 : 0)
845
+ };
846
+ }
847
+ const touch = touched.get(report.flowId) ?? { lastApplied: index, firstAppliedFailed: undefined };
848
+ touch.lastApplied = index;
849
+ if (report.outcome === "failed" && touch.firstAppliedFailed === undefined) {
850
+ touch.firstAppliedFailed = index;
851
+ }
852
+ touched.set(report.flowId, touch);
853
+ }
854
+ // Phase 2 — at most one settle decision per touched flow. Fail-fast
855
+ // wins ties (a batch whose failure also empties `pending` settles
856
+ // terminally, never into collect).
857
+ for (const [flowId, touch] of touched) {
858
+ const parent = jobs.get(flowId);
859
+ if (parent === undefined || parent.flow === undefined)
860
+ continue;
861
+ if (parent.state !== "waiting-children")
862
+ continue;
863
+ const failedIndex = parent.flow.failFast ? touch.firstAppliedFailed : undefined;
864
+ const failedReport = failedIndex !== undefined ? reports[failedIndex] : undefined;
865
+ if (failedIndex !== undefined && failedReport !== undefined) {
866
+ // First applied failure settles the parent terminally
867
+ // (store-side, like stall exhaustion) and marks the remaining
868
+ // rows in the same op. A nested parent's own report goes to the
869
+ // outbox here — this settle IS its terminal transition.
870
+ settleMarkRows(parent);
871
+ parent.cancelRequested = false;
872
+ parent.state = "failed";
873
+ parent.finishedAt = now;
874
+ parent.failedReason = `effect-mq: flow child "${failedReport.childKey}" failed`;
875
+ recordAttempt(parent, "failed", now, undefined);
876
+ appendOutbox(parent, "failed");
877
+ releaseDedupe(parent, now);
878
+ applyKeep(parent, now);
879
+ results[failedIndex] = { applied: true, parentSettled: true };
880
+ continue;
881
+ }
882
+ if (parent.flow.pending === 0) {
883
+ // All children settled: the parent resumes runnable, phase
884
+ // collect.
885
+ parent.state = "waiting";
886
+ parent.runAt = now;
887
+ parent.seq = ++seq;
888
+ signalWake(parent.queue);
889
+ results[touch.lastApplied] = { applied: true, parentSettled: true };
890
+ }
891
+ }
892
+ return results;
893
+ }),
894
+ peekOutbox: (options) => Effect.sync(() => {
895
+ // `after` compares by the id's embedded sequence so the cursor
896
+ // works whether or not the named entry still exists.
897
+ const afterSeq = options.after !== undefined && options.after.startsWith("ob-")
898
+ ? Number(options.after.slice(3))
899
+ : undefined;
900
+ const eligible = afterSeq === undefined || Number.isNaN(afterSeq)
901
+ ? outbox
902
+ : outbox.filter((entry) => Number(entry.id.slice(3)) > afterSeq);
903
+ return eligible.slice(0, Math.max(0, options.limit));
904
+ }),
905
+ deleteOutbox: (ids) => Effect.sync(() => {
906
+ const drop = new Set(ids);
907
+ let write = 0;
908
+ for (const entry of outbox) {
909
+ if (!drop.has(entry.id)) {
910
+ outbox[write] = entry;
911
+ write += 1;
912
+ }
913
+ }
914
+ outbox.length = write;
915
+ }),
916
+ listChildResults: (flowId, options) => Effect.sync(() => {
917
+ const limit = Math.max(1, options?.limit ?? 1000);
918
+ const rows = Array.from(flowChildren.get(flowId)?.values() ?? [])
919
+ .toSorted((a, b) => (a.childKey < b.childKey ? -1 : a.childKey > b.childKey ? 1 : 0))
920
+ .filter((row) => options?.cursor === undefined || row.childKey > options.cursor);
921
+ const page = rows.slice(0, limit);
922
+ const items = page.map((row) => ({
923
+ flowId: row.flowId,
924
+ childKey: row.childKey,
925
+ name: row.spec.name,
926
+ storeKey: row.storeKey,
927
+ // SAFETY: FanOut validated every spec id at ack time.
928
+ childJobId: row.spec.id,
929
+ status: row.status,
930
+ exit: row.exit,
931
+ failedReason: row.failedReason,
932
+ cascaded: row.cascaded
933
+ }));
934
+ const last = page[page.length - 1];
935
+ return {
936
+ items,
937
+ cursor: rows.length > limit && last !== undefined ? last.childKey : undefined
938
+ };
939
+ }),
940
+ flowSweepWork: (options) => Effect.gen(function* () {
941
+ const now = yield* Clock.currentTimeMillis;
942
+ const limit = Math.max(1, options.limit ?? 1000);
943
+ const threshold = now - options.pendingAgeMs;
944
+ const reconcile = [];
945
+ const cascade = [];
946
+ let reconcileCount = 0;
947
+ let cascadeCount = 0;
948
+ for (const [flowId, rows] of flowChildren) {
949
+ const parent = jobs.get(flowId);
950
+ const reconciling = parent !== undefined && parent.state === "waiting-children";
951
+ let reconcileGroup;
952
+ let cascadeGroup;
953
+ for (const row of rows.values()) {
954
+ if (reconciling && row.status === "pending" &&
955
+ row.pendingSince <= threshold && reconcileCount < limit) {
956
+ reconcileGroup ??= [];
957
+ reconcileGroup.push({ childKey: row.childKey, storeKey: row.storeKey, request: row.spec });
958
+ reconcileCount += 1;
959
+ // Re-arm eligibility: returned work waits another pendingAgeMs
960
+ // before it can come back, so a full page rotates (see the
961
+ // interface's rotation note).
962
+ row.pendingSince = now;
963
+ }
964
+ if (row.status === "cancelled" && !row.cascaded && cascadeCount < limit) {
965
+ cascadeGroup ??= [];
966
+ cascadeGroup.push({
967
+ childKey: row.childKey,
968
+ storeKey: row.storeKey,
969
+ // SAFETY: FanOut validated every spec id at ack time.
970
+ childJobId: row.spec.id
971
+ });
972
+ cascadeCount += 1;
973
+ }
974
+ }
975
+ if (reconcileGroup !== undefined)
976
+ reconcile.push({ flowId, children: reconcileGroup });
977
+ if (cascadeGroup !== undefined)
978
+ cascade.push({ flowId, children: cascadeGroup });
979
+ }
980
+ const work = { reconcile, cascade };
981
+ return work;
982
+ }),
983
+ markChildrenCascaded: (flowId, childKeys) => Effect.sync(() => {
984
+ const rows = flowChildren.get(flowId);
985
+ if (rows === undefined)
986
+ return;
987
+ for (const key of childKeys) {
988
+ const row = rows.get(key);
989
+ if (row !== undefined) {
990
+ row.cascaded = true;
991
+ }
992
+ }
993
+ }),
656
994
  counts: (queue) => Effect.sync(() => {
657
995
  const counts = {
658
996
  waiting: 0,
659
997
  delayed: 0,
660
998
  active: 0,
999
+ "waiting-children": 0,
661
1000
  completed: 0,
662
1001
  failed: 0,
663
1002
  cancelled: 0
@@ -671,9 +1010,10 @@ const makeStoreUnsafe = (options) => {
671
1010
  }),
672
1011
  remove: (id) => Effect.sync(() => {
673
1012
  const job = jobs.get(id);
674
- if (job === undefined || job.state === "active")
1013
+ if (job === undefined || job.state === "active" || job.state === "waiting-children") {
675
1014
  return false;
676
- jobs.delete(id);
1015
+ }
1016
+ deleteJob(id);
677
1017
  return true;
678
1018
  })
679
1019
  });