effect-mq 0.5.0 → 0.6.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 (59) 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 +312 -10
  11. package/dist/JobStore.d.ts.map +1 -1
  12. package/dist/JobStore.js.map +1 -1
  13. package/dist/MemoryJobStore.d.ts.map +1 -1
  14. package/dist/MemoryJobStore.js +334 -7
  15. package/dist/MemoryJobStore.js.map +1 -1
  16. package/dist/Metrics.d.ts +31 -0
  17. package/dist/Metrics.d.ts.map +1 -1
  18. package/dist/Metrics.js +39 -0
  19. package/dist/Metrics.js.map +1 -1
  20. package/dist/Worker.d.ts +120 -11
  21. package/dist/Worker.d.ts.map +1 -1
  22. package/dist/Worker.js +452 -26
  23. package/dist/Worker.js.map +1 -1
  24. package/dist/drizzle-postgres/DrizzleJobStore.d.ts +19 -1
  25. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  26. package/dist/drizzle-postgres/DrizzleJobStore.js +653 -77
  27. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  28. package/dist/drizzle-postgres/schema.d.ts +293 -3
  29. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  30. package/dist/drizzle-postgres/schema.js +66 -1
  31. package/dist/drizzle-postgres/schema.js.map +1 -1
  32. package/dist/index.d.ts +7 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +7 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  37. package/dist/redis/RedisJobStore.js +219 -18
  38. package/dist/redis/RedisJobStore.js.map +1 -1
  39. package/dist/redis/scripts.d.ts +117 -10
  40. package/dist/redis/scripts.d.ts.map +1 -1
  41. package/dist/redis/scripts.js +492 -25
  42. package/dist/redis/scripts.js.map +1 -1
  43. package/dist/testing/conformance.d.ts +6 -0
  44. package/dist/testing/conformance.d.ts.map +1 -1
  45. package/dist/testing/conformance.js +728 -1
  46. package/dist/testing/conformance.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/Flow.ts +778 -0
  49. package/src/Job.ts +35 -11
  50. package/src/JobStore.ts +339 -9
  51. package/src/MemoryJobStore.ts +370 -7
  52. package/src/Metrics.ts +43 -0
  53. package/src/Worker.ts +726 -37
  54. package/src/drizzle-postgres/DrizzleJobStore.ts +817 -78
  55. package/src/drizzle-postgres/schema.ts +92 -0
  56. package/src/index.ts +8 -0
  57. package/src/redis/RedisJobStore.ts +289 -8
  58. package/src/redis/scripts.ts +524 -24
  59. package/src/testing/conformance.ts +945 -1
@@ -16,9 +16,14 @@ import {
16
16
  type ClaimResult,
17
17
  type EnqueueRequest,
18
18
  type ExtendLocksResult,
19
+ type FlowChildRecord,
20
+ type FlowChildReport,
21
+ type FlowChildSpec,
22
+ type FlowSweepWork,
19
23
  type HistoryTtlByState,
20
24
  type HistoryTtlInput,
21
25
  type IdGenerator,
26
+ type OutboxEntry,
22
27
  JobId,
23
28
  JobNotCancellableError,
24
29
  JobNotFoundError,
@@ -56,6 +61,8 @@ interface MemJob {
56
61
  cancelRequested: boolean
57
62
  readonly dedupeKey: string | undefined
58
63
  trace: JobRecord["trace"]
64
+ readonly parent: JobRecord["parent"]
65
+ flow: JobRecord["flow"]
59
66
  runAt: number
60
67
  readonly enqueuedAt: number
61
68
  processedAt: number | undefined
@@ -87,6 +94,8 @@ const snapshot = (job: MemJob): JobRecord => ({
87
94
  cancelRequested: job.cancelRequested,
88
95
  dedupeKey: job.dedupeKey,
89
96
  trace: job.trace,
97
+ parent: job.parent,
98
+ flow: job.flow,
90
99
  runAt: job.runAt,
91
100
  enqueuedAt: job.enqueuedAt,
92
101
  processedAt: job.processedAt,
@@ -110,10 +119,46 @@ interface MemoryStore {
110
119
  readonly sweepHistory: (now: number, ttlByState: HistoryTtlByState) => void
111
120
  }
112
121
 
122
+ interface MemFlowChild {
123
+ readonly flowId: JobId
124
+ readonly childKey: string
125
+ readonly storeKey: string
126
+ readonly spec: EnqueueRequest
127
+ status: FlowChildRecord["status"]
128
+ exit: unknown
129
+ failedReason: string | undefined
130
+ cascaded: boolean
131
+ /** Sweep-eligibility timestamp: set at FanOut, re-armed on each return. */
132
+ pendingSince: number
133
+ }
134
+
113
135
  const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemoryStore => {
114
136
  const jobs = new Map<string, MemJob>()
115
137
  const schedules = new Map<ScheduleKey, ScheduleRecord>()
116
138
  const paused = new Set<QueueName>()
139
+ // Flow dependency rows, keyed by parent job id then child key. Insertion
140
+ // order is FanOut spec order; listChildResults sorts by child key.
141
+ const flowChildren = new Map<JobId, Map<string, MemFlowChild>>()
142
+ // Undelivered child-result reports, appended atomically with every
143
+ // terminal transition of an envelope-carrying job (see OutboxEntry).
144
+ const outbox: Array<OutboxEntry> = []
145
+ let outboxSeq = 0
146
+
147
+ const appendOutbox = (job: MemJob, outcome: FlowChildReport["outcome"]) => {
148
+ if (job.parent === undefined) return
149
+ outbox.push({
150
+ id: `ob-${++outboxSeq}`,
151
+ flowName: job.parent.flowName,
152
+ parentStoreKey: job.parent.parentStoreKey,
153
+ report: {
154
+ flowId: job.parent.flowId,
155
+ childKey: job.parent.childKey,
156
+ outcome,
157
+ exit: job.exit,
158
+ failedReason: job.failedReason
159
+ }
160
+ })
161
+ }
117
162
  // Dedup registry: one entry per (name, key). `expiresAt` is set for
118
163
  // ttl/throttle windows; pending-mode entries live as long as their job.
119
164
  const dedupes = new Map<string, { jobId: JobId; expiresAt: number | undefined }>()
@@ -198,12 +243,68 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
198
243
  }
199
244
  }
200
245
 
246
+ // A pruned/removed flow parent takes its dependency rows with it.
247
+ const deleteJob = (id: string) => {
248
+ jobs.delete(id)
249
+ // SAFETY: flowChildren keys are JobIds; a plain string that is not one
250
+ // simply misses.
251
+ flowChildren.delete(id as JobId)
252
+ }
253
+
254
+ // A settled flow parent whose rows still owe cascade cancels is exempt
255
+ // from automatic retention (keep policies, the history sweep): deleting
256
+ // it would delete the only record that the child stores are still owed
257
+ // real cancels, leaving marked children running. Once the sweeper marks
258
+ // the rows cascaded, retention applies normally. The explicit `remove`
259
+ // verb is NOT exempted — it is an operator override.
260
+ const owesCascades = (id: string) => {
261
+ // SAFETY: flowChildren keys are JobIds; a plain string that is not one
262
+ // simply misses.
263
+ const rows = flowChildren.get(id as JobId)
264
+ if (rows === undefined) return false
265
+ for (const row of rows.values()) {
266
+ if (row.status === "cancelled" && !row.cascaded) return true
267
+ }
268
+ return false
269
+ }
270
+
271
+ // Settle-time marking: remaining pending rows flip to cancelled (NOT
272
+ // cascaded — the sweeper still owes the child stores real cancels), so
273
+ // late reports find their row terminal and drop, and `listChildResults`
274
+ // stays truthful. Returns how many rows flipped, for the flow counters.
275
+ // Lock order note: in this driver everything is one synchronous block,
276
+ // but the row-then-parent order is still observed.
277
+ const markPendingRowsCancelled = (flowId: JobId): number => {
278
+ const rows = flowChildren.get(flowId)
279
+ if (rows === undefined) return 0
280
+ let marked = 0
281
+ for (const row of rows.values()) {
282
+ if (row.status === "pending") {
283
+ row.status = "cancelled"
284
+ row.cascaded = false
285
+ marked += 1
286
+ }
287
+ }
288
+ return marked
289
+ }
290
+
291
+ const settleMarkRows = (job: MemJob) => {
292
+ const marked = markPendingRowsCancelled(job.id)
293
+ if (job.flow !== undefined) {
294
+ job.flow = { ...job.flow, pending: 0, cancelled: job.flow.cancelled + marked }
295
+ }
296
+ }
297
+
201
298
  const markCancelled = (job: MemJob, now: number) => {
202
299
  clearLock(job)
203
300
  job.cancelRequested = false
301
+ if (job.state === "waiting-children") {
302
+ settleMarkRows(job)
303
+ }
204
304
  job.state = "cancelled"
205
305
  job.finishedAt = now
206
306
  recordAttempt(job, "cancelled", now, undefined)
307
+ appendOutbox(job, "cancelled")
207
308
  releaseDedupe(job, now)
208
309
  applyKeep(job, now)
209
310
  }
@@ -253,7 +354,8 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
253
354
  }
254
355
  }
255
356
  for (const id of remove) {
256
- jobs.delete(id)
357
+ if (owesCascades(id)) continue
358
+ deleteJob(id)
257
359
  }
258
360
  }
259
361
 
@@ -267,8 +369,8 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
267
369
  // The sweep honours min(per-row keep age, store ceiling) — a quiet job
268
370
  // name is pruned on the timer, not only when its group is acked.
269
371
  const effective = keepAge !== undefined && (ttl === undefined || keepAge < ttl) ? keepAge : ttl
270
- if (effective !== undefined && job.finishedAt <= now - effective) {
271
- jobs.delete(job.id)
372
+ if (effective !== undefined && job.finishedAt <= now - effective && !owesCascades(job.id)) {
373
+ deleteJob(job.id)
272
374
  }
273
375
  }
274
376
  // Dead dedup entries: expired window, or a pointer at a vanished job.
@@ -289,7 +391,8 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
289
391
  }
290
392
  switch (job.state) {
291
393
  case "waiting":
292
- case "delayed": {
394
+ case "delayed":
395
+ case "waiting-children": {
293
396
  markCancelled(job, now)
294
397
  return
295
398
  }
@@ -321,6 +424,8 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
321
424
  cancelRequested: false,
322
425
  dedupeKey: request.dedupe?.key,
323
426
  trace: request.trace,
427
+ parent: request.parent,
428
+ flow: undefined,
324
429
  runAt: now + Math.max(0, request.delayMs),
325
430
  enqueuedAt: now,
326
431
  processedAt: undefined,
@@ -466,15 +571,79 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
466
571
  if (job.state !== "active" || job.lockToken !== token) {
467
572
  return yield* new LockLostError({ jobId: id })
468
573
  }
574
+ if (
575
+ outcome._tag === "FanOut" &&
576
+ outcome.children.some((child) => child.request.id === undefined)
577
+ ) {
578
+ // Validate BEFORE any mutation, so a bad spec cannot leave the job
579
+ // half-acked (lock cleared, ledger written, still active).
580
+ return yield* new JobStoreError({
581
+ message: "FanOut child specs require an explicit request.id"
582
+ })
583
+ }
469
584
  clearLock(job)
470
- job.attemptsMade += 1
585
+ // A fan-out is a phase transition, not a completed run — the attempt
586
+ // budget spans both phases.
587
+ if (outcome._tag !== "FanOut") {
588
+ job.attemptsMade += 1
589
+ }
471
590
  switch (outcome._tag) {
591
+ case "FanOut": {
592
+ recordAttempt(job, "fanned-out", now, undefined)
593
+ if (job.flow === undefined) {
594
+ job.flow = {
595
+ failFast: outcome.failFast,
596
+ pending: outcome.children.length,
597
+ completed: 0,
598
+ failed: 0,
599
+ cancelled: 0
600
+ }
601
+ const rows = new Map<string, MemFlowChild>()
602
+ for (const child of outcome.children) {
603
+ rows.set(child.childKey, {
604
+ flowId: job.id,
605
+ childKey: child.childKey,
606
+ storeKey: child.storeKey,
607
+ spec: child.request,
608
+ status: "pending",
609
+ exit: undefined,
610
+ failedReason: undefined,
611
+ cascaded: false,
612
+ pendingSince: now
613
+ })
614
+ }
615
+ flowChildren.set(job.id, rows)
616
+ }
617
+ // A manifest that was already present is kept untouched (double
618
+ // fan-out converges on the persisted children); the state
619
+ // transition follows the persisted pending count either way.
620
+ if (job.cancelRequested) {
621
+ // A cancel raced the fan-out: cancellation wins. Mark the rows
622
+ // here — the job is still `active`, so markCancelled's own
623
+ // waiting-children marking does not apply — and the sweeper
624
+ // cascades (mostly no-op cancels for never-enqueued children).
625
+ settleMarkRows(job)
626
+ markCancelled(job, now)
627
+ break
628
+ }
629
+ if (job.flow.pending > 0) {
630
+ job.state = "waiting-children"
631
+ } else {
632
+ // Empty spec: settle straight to runnable `collect`.
633
+ job.state = "waiting"
634
+ job.runAt = now
635
+ job.seq = ++seq
636
+ signalWake(job.queue)
637
+ }
638
+ break
639
+ }
472
640
  case "Complete": {
473
641
  job.cancelRequested = false
474
642
  job.state = "completed"
475
643
  job.exit = outcome.exit
476
644
  job.finishedAt = now
477
645
  recordAttempt(job, "completed", now, outcome.exit)
646
+ appendOutbox(job, "completed")
478
647
  releaseDedupe(job, now)
479
648
  applyKeep(job, now)
480
649
  break
@@ -485,6 +654,7 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
485
654
  job.exit = outcome.exit
486
655
  job.finishedAt = now
487
656
  recordAttempt(job, "failed", now, outcome.exit)
657
+ appendOutbox(job, "failed")
488
658
  releaseDedupe(job, now)
489
659
  applyKeep(job, now)
490
660
  break
@@ -494,6 +664,7 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
494
664
  job.state = "cancelled"
495
665
  job.finishedAt = now
496
666
  recordAttempt(job, "cancelled", now, undefined)
667
+ appendOutbox(job, "cancelled")
497
668
  releaseDedupe(job, now)
498
669
  applyKeep(job, now)
499
670
  break
@@ -584,6 +755,7 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
584
755
  job.state = "failed"
585
756
  job.finishedAt = now
586
757
  job.failedReason = "job stalled more than allowable limit"
758
+ appendOutbox(job, "failed")
587
759
  releaseDedupe(job, now)
588
760
  recovered.push({ id: job.id, failed: true })
589
761
  } else {
@@ -797,12 +969,201 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
797
969
  return true
798
970
  }),
799
971
 
972
+ recordChildResults: (reports) =>
973
+ Effect.gen(function*() {
974
+ const now = yield* Clock.currentTimeMillis
975
+ const results = reports.map(() => ({ applied: false, parentSettled: false }))
976
+
977
+ // Phase 1 — apply every row update (lock order: rows before
978
+ // parents), tracking per flow which report would decide a settle.
979
+ const touched = new Map<JobId, { lastApplied: number; firstAppliedFailed: number | undefined }>()
980
+ for (const [index, report] of reports.entries()) {
981
+ const row = flowChildren.get(report.flowId)?.get(report.childKey)
982
+ if (row === undefined || row.status !== "pending") continue
983
+ row.status = report.outcome
984
+ row.exit = report.exit
985
+ row.failedReason = report.failedReason
986
+ // The outcome came from the child's store: no cancel to deliver.
987
+ row.cascaded = true
988
+ results[index] = { applied: true, parentSettled: false }
989
+ const parent = jobs.get(report.flowId)
990
+ if (parent?.flow !== undefined) {
991
+ const flow = parent.flow
992
+ parent.flow = {
993
+ ...flow,
994
+ pending: Math.max(0, flow.pending - 1),
995
+ completed: flow.completed + (report.outcome === "completed" ? 1 : 0),
996
+ failed: flow.failed + (report.outcome === "failed" ? 1 : 0),
997
+ cancelled: flow.cancelled + (report.outcome === "cancelled" ? 1 : 0)
998
+ }
999
+ }
1000
+ const touch = touched.get(report.flowId) ?? { lastApplied: index, firstAppliedFailed: undefined }
1001
+ touch.lastApplied = index
1002
+ if (report.outcome === "failed" && touch.firstAppliedFailed === undefined) {
1003
+ touch.firstAppliedFailed = index
1004
+ }
1005
+ touched.set(report.flowId, touch)
1006
+ }
1007
+
1008
+ // Phase 2 — at most one settle decision per touched flow. Fail-fast
1009
+ // wins ties (a batch whose failure also empties `pending` settles
1010
+ // terminally, never into collect).
1011
+ for (const [flowId, touch] of touched) {
1012
+ const parent = jobs.get(flowId)
1013
+ if (parent === undefined || parent.flow === undefined) continue
1014
+ if (parent.state !== "waiting-children") continue
1015
+ const failedIndex = parent.flow.failFast ? touch.firstAppliedFailed : undefined
1016
+ const failedReport = failedIndex !== undefined ? reports[failedIndex] : undefined
1017
+ if (failedIndex !== undefined && failedReport !== undefined) {
1018
+ // First applied failure settles the parent terminally
1019
+ // (store-side, like stall exhaustion) and marks the remaining
1020
+ // rows in the same op. A nested parent's own report goes to the
1021
+ // outbox here — this settle IS its terminal transition.
1022
+ settleMarkRows(parent)
1023
+ parent.cancelRequested = false
1024
+ parent.state = "failed"
1025
+ parent.finishedAt = now
1026
+ parent.failedReason = `effect-mq: flow child "${failedReport.childKey}" failed`
1027
+ recordAttempt(parent, "failed", now, undefined)
1028
+ appendOutbox(parent, "failed")
1029
+ releaseDedupe(parent, now)
1030
+ applyKeep(parent, now)
1031
+ results[failedIndex] = { applied: true, parentSettled: true }
1032
+ continue
1033
+ }
1034
+ if (parent.flow.pending === 0) {
1035
+ // All children settled: the parent resumes runnable, phase
1036
+ // collect.
1037
+ parent.state = "waiting"
1038
+ parent.runAt = now
1039
+ parent.seq = ++seq
1040
+ signalWake(parent.queue)
1041
+ results[touch.lastApplied] = { applied: true, parentSettled: true }
1042
+ }
1043
+ }
1044
+ return results
1045
+ }),
1046
+
1047
+ peekOutbox: (options) =>
1048
+ Effect.sync(() => {
1049
+ // `after` compares by the id's embedded sequence so the cursor
1050
+ // works whether or not the named entry still exists.
1051
+ const afterSeq = options.after !== undefined && options.after.startsWith("ob-")
1052
+ ? Number(options.after.slice(3))
1053
+ : undefined
1054
+ const eligible = afterSeq === undefined || Number.isNaN(afterSeq)
1055
+ ? outbox
1056
+ : outbox.filter((entry) => Number(entry.id.slice(3)) > afterSeq)
1057
+ return eligible.slice(0, Math.max(0, options.limit))
1058
+ }),
1059
+
1060
+ deleteOutbox: (ids) =>
1061
+ Effect.sync(() => {
1062
+ const drop = new Set(ids)
1063
+ let write = 0
1064
+ for (const entry of outbox) {
1065
+ if (!drop.has(entry.id)) {
1066
+ outbox[write] = entry
1067
+ write += 1
1068
+ }
1069
+ }
1070
+ outbox.length = write
1071
+ }),
1072
+
1073
+ listChildResults: (flowId, options) =>
1074
+ Effect.sync(() => {
1075
+ const limit = Math.max(1, options?.limit ?? 1000)
1076
+ const rows = Array.from(flowChildren.get(flowId)?.values() ?? [])
1077
+ .toSorted((a, b) => (a.childKey < b.childKey ? -1 : a.childKey > b.childKey ? 1 : 0))
1078
+ .filter((row) => options?.cursor === undefined || row.childKey > options.cursor)
1079
+ const page = rows.slice(0, limit)
1080
+ const items: Array<FlowChildRecord> = page.map((row) => ({
1081
+ flowId: row.flowId,
1082
+ childKey: row.childKey,
1083
+ name: row.spec.name,
1084
+ storeKey: row.storeKey,
1085
+ // SAFETY: FanOut validated every spec id at ack time.
1086
+ childJobId: row.spec.id as JobId,
1087
+ status: row.status,
1088
+ exit: row.exit,
1089
+ failedReason: row.failedReason,
1090
+ cascaded: row.cascaded
1091
+ }))
1092
+ const last = page[page.length - 1]
1093
+ return {
1094
+ items,
1095
+ cursor: rows.length > limit && last !== undefined ? last.childKey : undefined
1096
+ }
1097
+ }),
1098
+
1099
+ flowSweepWork: (options) =>
1100
+ Effect.gen(function*() {
1101
+ const now = yield* Clock.currentTimeMillis
1102
+ const limit = Math.max(1, options.limit ?? 1000)
1103
+ const threshold = now - options.pendingAgeMs
1104
+ const reconcile: Array<{ flowId: JobId; children: Array<FlowChildSpec> }> = []
1105
+ const cascade: Array<
1106
+ { flowId: JobId; children: Array<{ childKey: string; storeKey: string; childJobId: JobId }> }
1107
+ > = []
1108
+ let reconcileCount = 0
1109
+ let cascadeCount = 0
1110
+ for (const [flowId, rows] of flowChildren) {
1111
+ const parent = jobs.get(flowId)
1112
+ const reconciling = parent !== undefined && parent.state === "waiting-children"
1113
+ let reconcileGroup: Array<FlowChildSpec> | undefined
1114
+ let cascadeGroup:
1115
+ | Array<{ childKey: string; storeKey: string; childJobId: JobId }>
1116
+ | undefined
1117
+ for (const row of rows.values()) {
1118
+ if (
1119
+ reconciling && row.status === "pending" &&
1120
+ row.pendingSince <= threshold && reconcileCount < limit
1121
+ ) {
1122
+ reconcileGroup ??= []
1123
+ reconcileGroup.push({ childKey: row.childKey, storeKey: row.storeKey, request: row.spec })
1124
+ reconcileCount += 1
1125
+ // Re-arm eligibility: returned work waits another pendingAgeMs
1126
+ // before it can come back, so a full page rotates (see the
1127
+ // interface's rotation note).
1128
+ row.pendingSince = now
1129
+ }
1130
+ if (row.status === "cancelled" && !row.cascaded && cascadeCount < limit) {
1131
+ cascadeGroup ??= []
1132
+ cascadeGroup.push({
1133
+ childKey: row.childKey,
1134
+ storeKey: row.storeKey,
1135
+ // SAFETY: FanOut validated every spec id at ack time.
1136
+ childJobId: row.spec.id as JobId
1137
+ })
1138
+ cascadeCount += 1
1139
+ }
1140
+ }
1141
+ if (reconcileGroup !== undefined) reconcile.push({ flowId, children: reconcileGroup })
1142
+ if (cascadeGroup !== undefined) cascade.push({ flowId, children: cascadeGroup })
1143
+ }
1144
+ const work: FlowSweepWork = { reconcile, cascade }
1145
+ return work
1146
+ }),
1147
+
1148
+ markChildrenCascaded: (flowId, childKeys) =>
1149
+ Effect.sync(() => {
1150
+ const rows = flowChildren.get(flowId)
1151
+ if (rows === undefined) return
1152
+ for (const key of childKeys) {
1153
+ const row = rows.get(key)
1154
+ if (row !== undefined) {
1155
+ row.cascaded = true
1156
+ }
1157
+ }
1158
+ }),
1159
+
800
1160
  counts: (queue) =>
801
1161
  Effect.sync(() => {
802
1162
  const counts = {
803
1163
  waiting: 0,
804
1164
  delayed: 0,
805
1165
  active: 0,
1166
+ "waiting-children": 0,
806
1167
  completed: 0,
807
1168
  failed: 0,
808
1169
  cancelled: 0
@@ -817,8 +1178,10 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
817
1178
  remove: (id) =>
818
1179
  Effect.sync(() => {
819
1180
  const job = jobs.get(id)
820
- if (job === undefined || job.state === "active") return false
821
- jobs.delete(id)
1181
+ if (job === undefined || job.state === "active" || job.state === "waiting-children") {
1182
+ return false
1183
+ }
1184
+ deleteJob(id)
822
1185
  return true
823
1186
  })
824
1187
  })
package/src/Metrics.ts CHANGED
@@ -133,3 +133,46 @@ export const stalledRecovered = Metric.counter("effect_mq_stalled_recovered", {
133
133
  export const scheduleTicks = Metric.counter("effect_mq_schedule_ticks", {
134
134
  description: "Repeatable-schedule occurrences enqueued by the schedule sweep"
135
135
  })
136
+
137
+ /**
138
+ * Flow fan-outs acked (manifests landed). Tags: `flow`.
139
+ *
140
+ * @since 0.6.0
141
+ */
142
+ export const flowFanOuts = Metric.counter("effect_mq_flow_fanouts", {
143
+ description: "Flow fan-out acks (child manifests persisted)"
144
+ })
145
+
146
+ /**
147
+ * Child results recorded into parent stores. Tags: `flow`, `outcome`
148
+ * (completed | failed | cancelled), `source` (`report` = delivered by a
149
+ * worker's outbox relay; `reconcile` = synthesized by the flow sweeper from
150
+ * child-store state). Only applied reports count — duplicates dropped by
151
+ * the dependency row do not.
152
+ *
153
+ * @since 0.6.0
154
+ */
155
+ export const flowChildReports = Metric.counter("effect_mq_flow_child_reports", {
156
+ description: "Applied flow child-result reports by outcome and delivery path"
157
+ })
158
+
159
+ /**
160
+ * Cancels delivered into child stores after a flow settle. Tags: none.
161
+ *
162
+ * @since 0.6.0
163
+ */
164
+ export const flowCascades = Metric.counter("effect_mq_flow_cascades", {
165
+ description: "Child cancels cascaded into child stores after a flow settled"
166
+ })
167
+
168
+ /**
169
+ * Undelivered outbox entries a relay drain had to leave behind (their
170
+ * parent store is not reachable from this worker — no matching `flows`
171
+ * registration). Tags: none. Sustained growth means no process anywhere
172
+ * can relay these flows; reconciliation keeps the flows correct meanwhile.
173
+ *
174
+ * @since 0.6.0
175
+ */
176
+ export const flowOutboxSkipped = Metric.counter("effect_mq_flow_outbox_skipped", {
177
+ description: "Outbox entries left undelivered by a relay drain (parent store unknown here)"
178
+ })