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
@@ -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 {
@@ -623,7 +795,14 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
623
795
  Effect.sync(() => {
624
796
  const limit = Math.max(1, options.limit ?? 50)
625
797
  const states = options.states === undefined ? undefined : new Set(options.states)
626
- // Newest first, stable across retries: (enqueuedAt desc, id desc).
798
+ const orderBy = options.orderBy ?? "enqueuedAt"
799
+ const descending = (options.order ?? "desc") === "desc"
800
+ // Jobs missing the field (finishedAt on non-terminal rows) sort as 0.
801
+ const orderValue = (job: MemJob): number =>
802
+ orderBy === "enqueuedAt" ? job.enqueuedAt : orderBy === "runAt" ? job.runAt : job.finishedAt ?? 0
803
+ const compareIds = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
804
+ // Stable across retries: the id tiebreak follows the direction, and
805
+ // the cursor excludes everything at or before its (value, id).
627
806
  let cursor: { readonly at: number; readonly id: string } | undefined
628
807
  if (options.cursor !== undefined) {
629
808
  const split = options.cursor.indexOf(":")
@@ -639,26 +818,27 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
639
818
  (states === undefined || states.has(job.state)) &&
640
819
  (options.metadata === undefined || metadataMatches(job.metadata, options.metadata))
641
820
  )
642
- .toSorted((a, b) =>
643
- b.enqueuedAt !== a.enqueuedAt
644
- ? b.enqueuedAt - a.enqueuedAt
645
- : b.id < a.id
646
- ? -1
647
- : b.id > a.id
648
- ? 1
649
- : 0
650
- )
651
- .filter((job) =>
652
- cursor === undefined ||
653
- job.enqueuedAt < cursor.at ||
654
- (job.enqueuedAt === cursor.at && job.id < cursor.id)
655
- )
821
+ .toSorted((a, b) => {
822
+ const byValue = descending
823
+ ? orderValue(b) - orderValue(a)
824
+ : orderValue(a) - orderValue(b)
825
+ if (byValue !== 0) return byValue
826
+ return descending ? compareIds(b.id, a.id) : compareIds(a.id, b.id)
827
+ })
828
+ .filter((job) => {
829
+ if (cursor === undefined) return true
830
+ const value = orderValue(job)
831
+ if (descending) {
832
+ return value < cursor.at || (value === cursor.at && job.id < cursor.id)
833
+ }
834
+ return value > cursor.at || (value === cursor.at && job.id > cursor.id)
835
+ })
656
836
  const items = matches.slice(0, limit).map(snapshot)
657
- const last = items[items.length - 1]
837
+ const lastJob = matches[Math.min(limit, matches.length) - 1]
658
838
  const result: ListResult = {
659
839
  items,
660
- cursor: matches.length > limit && last !== undefined
661
- ? `${last.enqueuedAt}:${last.id}`
840
+ cursor: matches.length > limit && lastJob !== undefined
841
+ ? `${orderValue(lastJob)}:${lastJob.id}`
662
842
  : undefined
663
843
  }
664
844
  return result
@@ -797,12 +977,201 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
797
977
  return true
798
978
  }),
799
979
 
980
+ recordChildResults: (reports) =>
981
+ Effect.gen(function*() {
982
+ const now = yield* Clock.currentTimeMillis
983
+ const results = reports.map(() => ({ applied: false, parentSettled: false }))
984
+
985
+ // Phase 1 — apply every row update (lock order: rows before
986
+ // parents), tracking per flow which report would decide a settle.
987
+ const touched = new Map<JobId, { lastApplied: number; firstAppliedFailed: number | undefined }>()
988
+ for (const [index, report] of reports.entries()) {
989
+ const row = flowChildren.get(report.flowId)?.get(report.childKey)
990
+ if (row === undefined || row.status !== "pending") continue
991
+ row.status = report.outcome
992
+ row.exit = report.exit
993
+ row.failedReason = report.failedReason
994
+ // The outcome came from the child's store: no cancel to deliver.
995
+ row.cascaded = true
996
+ results[index] = { applied: true, parentSettled: false }
997
+ const parent = jobs.get(report.flowId)
998
+ if (parent?.flow !== undefined) {
999
+ const flow = parent.flow
1000
+ parent.flow = {
1001
+ ...flow,
1002
+ pending: Math.max(0, flow.pending - 1),
1003
+ completed: flow.completed + (report.outcome === "completed" ? 1 : 0),
1004
+ failed: flow.failed + (report.outcome === "failed" ? 1 : 0),
1005
+ cancelled: flow.cancelled + (report.outcome === "cancelled" ? 1 : 0)
1006
+ }
1007
+ }
1008
+ const touch = touched.get(report.flowId) ?? { lastApplied: index, firstAppliedFailed: undefined }
1009
+ touch.lastApplied = index
1010
+ if (report.outcome === "failed" && touch.firstAppliedFailed === undefined) {
1011
+ touch.firstAppliedFailed = index
1012
+ }
1013
+ touched.set(report.flowId, touch)
1014
+ }
1015
+
1016
+ // Phase 2 — at most one settle decision per touched flow. Fail-fast
1017
+ // wins ties (a batch whose failure also empties `pending` settles
1018
+ // terminally, never into collect).
1019
+ for (const [flowId, touch] of touched) {
1020
+ const parent = jobs.get(flowId)
1021
+ if (parent === undefined || parent.flow === undefined) continue
1022
+ if (parent.state !== "waiting-children") continue
1023
+ const failedIndex = parent.flow.failFast ? touch.firstAppliedFailed : undefined
1024
+ const failedReport = failedIndex !== undefined ? reports[failedIndex] : undefined
1025
+ if (failedIndex !== undefined && failedReport !== undefined) {
1026
+ // First applied failure settles the parent terminally
1027
+ // (store-side, like stall exhaustion) and marks the remaining
1028
+ // rows in the same op. A nested parent's own report goes to the
1029
+ // outbox here — this settle IS its terminal transition.
1030
+ settleMarkRows(parent)
1031
+ parent.cancelRequested = false
1032
+ parent.state = "failed"
1033
+ parent.finishedAt = now
1034
+ parent.failedReason = `effect-mq: flow child "${failedReport.childKey}" failed`
1035
+ recordAttempt(parent, "failed", now, undefined)
1036
+ appendOutbox(parent, "failed")
1037
+ releaseDedupe(parent, now)
1038
+ applyKeep(parent, now)
1039
+ results[failedIndex] = { applied: true, parentSettled: true }
1040
+ continue
1041
+ }
1042
+ if (parent.flow.pending === 0) {
1043
+ // All children settled: the parent resumes runnable, phase
1044
+ // collect.
1045
+ parent.state = "waiting"
1046
+ parent.runAt = now
1047
+ parent.seq = ++seq
1048
+ signalWake(parent.queue)
1049
+ results[touch.lastApplied] = { applied: true, parentSettled: true }
1050
+ }
1051
+ }
1052
+ return results
1053
+ }),
1054
+
1055
+ peekOutbox: (options) =>
1056
+ Effect.sync(() => {
1057
+ // `after` compares by the id's embedded sequence so the cursor
1058
+ // works whether or not the named entry still exists.
1059
+ const afterSeq = options.after !== undefined && options.after.startsWith("ob-")
1060
+ ? Number(options.after.slice(3))
1061
+ : undefined
1062
+ const eligible = afterSeq === undefined || Number.isNaN(afterSeq)
1063
+ ? outbox
1064
+ : outbox.filter((entry) => Number(entry.id.slice(3)) > afterSeq)
1065
+ return eligible.slice(0, Math.max(0, options.limit))
1066
+ }),
1067
+
1068
+ deleteOutbox: (ids) =>
1069
+ Effect.sync(() => {
1070
+ const drop = new Set(ids)
1071
+ let write = 0
1072
+ for (const entry of outbox) {
1073
+ if (!drop.has(entry.id)) {
1074
+ outbox[write] = entry
1075
+ write += 1
1076
+ }
1077
+ }
1078
+ outbox.length = write
1079
+ }),
1080
+
1081
+ listChildResults: (flowId, options) =>
1082
+ Effect.sync(() => {
1083
+ const limit = Math.max(1, options?.limit ?? 1000)
1084
+ const rows = Array.from(flowChildren.get(flowId)?.values() ?? [])
1085
+ .toSorted((a, b) => (a.childKey < b.childKey ? -1 : a.childKey > b.childKey ? 1 : 0))
1086
+ .filter((row) => options?.cursor === undefined || row.childKey > options.cursor)
1087
+ const page = rows.slice(0, limit)
1088
+ const items: Array<FlowChildRecord> = page.map((row) => ({
1089
+ flowId: row.flowId,
1090
+ childKey: row.childKey,
1091
+ name: row.spec.name,
1092
+ storeKey: row.storeKey,
1093
+ // SAFETY: FanOut validated every spec id at ack time.
1094
+ childJobId: row.spec.id as JobId,
1095
+ status: row.status,
1096
+ exit: row.exit,
1097
+ failedReason: row.failedReason,
1098
+ cascaded: row.cascaded
1099
+ }))
1100
+ const last = page[page.length - 1]
1101
+ return {
1102
+ items,
1103
+ cursor: rows.length > limit && last !== undefined ? last.childKey : undefined
1104
+ }
1105
+ }),
1106
+
1107
+ flowSweepWork: (options) =>
1108
+ Effect.gen(function*() {
1109
+ const now = yield* Clock.currentTimeMillis
1110
+ const limit = Math.max(1, options.limit ?? 1000)
1111
+ const threshold = now - options.pendingAgeMs
1112
+ const reconcile: Array<{ flowId: JobId; children: Array<FlowChildSpec> }> = []
1113
+ const cascade: Array<
1114
+ { flowId: JobId; children: Array<{ childKey: string; storeKey: string; childJobId: JobId }> }
1115
+ > = []
1116
+ let reconcileCount = 0
1117
+ let cascadeCount = 0
1118
+ for (const [flowId, rows] of flowChildren) {
1119
+ const parent = jobs.get(flowId)
1120
+ const reconciling = parent !== undefined && parent.state === "waiting-children"
1121
+ let reconcileGroup: Array<FlowChildSpec> | undefined
1122
+ let cascadeGroup:
1123
+ | Array<{ childKey: string; storeKey: string; childJobId: JobId }>
1124
+ | undefined
1125
+ for (const row of rows.values()) {
1126
+ if (
1127
+ reconciling && row.status === "pending" &&
1128
+ row.pendingSince <= threshold && reconcileCount < limit
1129
+ ) {
1130
+ reconcileGroup ??= []
1131
+ reconcileGroup.push({ childKey: row.childKey, storeKey: row.storeKey, request: row.spec })
1132
+ reconcileCount += 1
1133
+ // Re-arm eligibility: returned work waits another pendingAgeMs
1134
+ // before it can come back, so a full page rotates (see the
1135
+ // interface's rotation note).
1136
+ row.pendingSince = now
1137
+ }
1138
+ if (row.status === "cancelled" && !row.cascaded && cascadeCount < limit) {
1139
+ cascadeGroup ??= []
1140
+ cascadeGroup.push({
1141
+ childKey: row.childKey,
1142
+ storeKey: row.storeKey,
1143
+ // SAFETY: FanOut validated every spec id at ack time.
1144
+ childJobId: row.spec.id as JobId
1145
+ })
1146
+ cascadeCount += 1
1147
+ }
1148
+ }
1149
+ if (reconcileGroup !== undefined) reconcile.push({ flowId, children: reconcileGroup })
1150
+ if (cascadeGroup !== undefined) cascade.push({ flowId, children: cascadeGroup })
1151
+ }
1152
+ const work: FlowSweepWork = { reconcile, cascade }
1153
+ return work
1154
+ }),
1155
+
1156
+ markChildrenCascaded: (flowId, childKeys) =>
1157
+ Effect.sync(() => {
1158
+ const rows = flowChildren.get(flowId)
1159
+ if (rows === undefined) return
1160
+ for (const key of childKeys) {
1161
+ const row = rows.get(key)
1162
+ if (row !== undefined) {
1163
+ row.cascaded = true
1164
+ }
1165
+ }
1166
+ }),
1167
+
800
1168
  counts: (queue) =>
801
1169
  Effect.sync(() => {
802
1170
  const counts = {
803
1171
  waiting: 0,
804
1172
  delayed: 0,
805
1173
  active: 0,
1174
+ "waiting-children": 0,
806
1175
  completed: 0,
807
1176
  failed: 0,
808
1177
  cancelled: 0
@@ -817,8 +1186,10 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
817
1186
  remove: (id) =>
818
1187
  Effect.sync(() => {
819
1188
  const job = jobs.get(id)
820
- if (job === undefined || job.state === "active") return false
821
- jobs.delete(id)
1189
+ if (job === undefined || job.state === "active" || job.state === "waiting-children") {
1190
+ return false
1191
+ }
1192
+ deleteJob(id)
822
1193
  return true
823
1194
  })
824
1195
  })
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
+ })