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
package/src/Worker.ts CHANGED
@@ -8,9 +8,10 @@
8
8
  *
9
9
  * claim -> decode payload -> run handler -> ack (complete | retry | fail)
10
10
  *
11
- * plus two maintenance fibers: lock renewal for in-flight jobs, and stalled
12
- * job recovery. On scope close, in-flight handlers are interrupted and their
13
- * jobs released back to `waiting` without consuming an attempt.
11
+ * plus maintenance fibers: lock renewal for in-flight jobs, stalled job
12
+ * recovery, schedule sweeping — and, once flows are registered, the outbox
13
+ * relay and flow sweeper. On scope close, in-flight handlers are interrupted
14
+ * and their jobs released back to `waiting` without consuming an attempt.
14
15
  *
15
16
  * @since 0.1.0
16
17
  */
@@ -18,6 +19,9 @@ import { Cause, Clock, Context, Deferred, Duration, Effect, Exit, Fiber, FiberSe
18
19
  import {
19
20
  type AckOutcome,
20
21
  type BackoffPolicy,
22
+ type EnqueueRequest,
23
+ type FlowChildSpec,
24
+ type FlowState,
21
25
  isJobStoreError,
22
26
  isMarkedUnrecoverable,
23
27
  JobId,
@@ -34,8 +38,8 @@ import {
34
38
  import * as Metrics from "./Metrics.ts"
35
39
 
36
40
  /**
37
- * Information about the currently running attempt, passed to handlers as the
38
- * second argument.
41
+ * Information about the currently running attempt. Handlers (and anything
42
+ * they call, however deeply nested) read it from the `CurrentJob` service.
39
43
  *
40
44
  * @since 0.1.0
41
45
  */
@@ -49,6 +53,28 @@ export interface JobContext {
49
53
  readonly attemptsMax: number
50
54
  }
51
55
 
56
+ /**
57
+ * The currently running attempt, provided by the worker around every
58
+ * handler run (plain jobs and both flow phases). Declaring it as a
59
+ * requirement makes "this code only runs inside a job" a compile-time
60
+ * fact — `toLayer` subtracts it, since the worker supplies it per run:
61
+ *
62
+ * ```ts
63
+ * const notify = Effect.gen(function*() {
64
+ * const { jobId, attempt } = yield* Worker.CurrentJob
65
+ * // ... use them however deep in the call graph this runs
66
+ * })
67
+ * ```
68
+ *
69
+ * Outside a worker (unit tests calling such code directly), provide a
70
+ * value with `Effect.provideService(Worker.CurrentJob, {...})`.
71
+ *
72
+ * @since 0.6.0
73
+ */
74
+ export class CurrentJob extends Context.Service<CurrentJob, JobContext>()(
75
+ "effect-mq/Worker/CurrentJob"
76
+ ) {}
77
+
52
78
  /**
53
79
  * The structural shape of a job definition the worker needs for
54
80
  * registration. `Job.Job` satisfies this — the indirection avoids a module
@@ -78,6 +104,56 @@ export interface JobDescriptor<
78
104
  readonly retryable: ((error: Error["Type"]) => boolean) | undefined
79
105
  }
80
106
 
107
+ /**
108
+ * The structural shape of a flow definition the worker needs — for running
109
+ * flow CHILDREN (the outbox relay routes results by parent store) and, via
110
+ * `FlowDescriptor`, for running the parent's two phases. `Flow.Flow`
111
+ * satisfies this; the indirection avoids a module cycle.
112
+ *
113
+ * @since 0.6.0
114
+ */
115
+ export interface FlowAny {
116
+ /** The flow's unique name (what child `parent` envelopes reference). */
117
+ readonly name: string
118
+ readonly parent: {
119
+ readonly store: Context.Key<any, StoreService>
120
+ }
121
+ /** True when the flow's `onChildFailure` policy is `"fail"`. */
122
+ readonly failFast: boolean
123
+ }
124
+
125
+ /**
126
+ * What `Flow.toLayer` hands to `registerFlow`: the parent job's codecs plus
127
+ * the two phase runners, pre-wired by the flow module (fan-out builds
128
+ * complete `FlowChildSpec`s — deterministic ids, `parent` envelopes, trace
129
+ * stamps — and collect consumes the recorded dependency rows).
130
+ *
131
+ * @internal
132
+ */
133
+ export interface FlowDescriptor extends FlowAny {
134
+ readonly parent: {
135
+ readonly _tag: string
136
+ readonly queue: QueueName
137
+ readonly store: Context.Key<any, StoreService>
138
+ readonly payloadJsonSchema: Schema.Top
139
+ readonly exitSchema: Schema.Top
140
+ readonly retryable: ((error: never) => boolean) | undefined
141
+ }
142
+ /** Every child store the sweeper/enqueuer must reach, for context lookup. */
143
+ readonly childStores: ReadonlyArray<Context.Key<any, StoreService>>
144
+ readonly fanOut: (
145
+ payload: JobRecord["payload"],
146
+ context: JobContext,
147
+ /** The parent's own nesting depth (0 for top-level flows). */
148
+ parentDepth: number
149
+ ) => Effect.Effect<ReadonlyArray<FlowChildSpec>, unknown, unknown>
150
+ readonly collect: (
151
+ payload: JobRecord["payload"],
152
+ flowState: FlowState,
153
+ context: JobContext
154
+ ) => Effect.Effect<unknown, unknown, unknown>
155
+ }
156
+
81
157
  /**
82
158
  * @since 0.1.0
83
159
  */
@@ -152,6 +228,29 @@ export interface WorkerOptions<StoreId = JobStore> {
152
228
  * trackers, paging), not a logging prerequisite.
153
229
  */
154
230
  readonly onJobFailure?: ((failure: JobFailure) => Effect.Effect<void>) | undefined
231
+ /**
232
+ * Flows whose CHILD jobs this worker runs. Child results are written to
233
+ * the child store's outbox atomically with each terminal transition; this
234
+ * list gives the worker the flows' parent stores (each parent StoreId
235
+ * lands in the layer's requirements), so its relay can push those results
236
+ * immediately. Without it the results wait for a relay elsewhere or for
237
+ * the parent-side sweeper's reconciliation — correct either way, just
238
+ * slower. Workers running a flow's PARENT phases get their registration
239
+ * implicitly from `Flow.toLayer`.
240
+ */
241
+ readonly flows?: ReadonlyArray<FlowAny> | undefined
242
+ /**
243
+ * Cadence of the flow sweeper (default 30s), which repairs whatever the
244
+ * push path missed: (re-)enqueues fanned-out children that never landed
245
+ * in their store, synthesizes reports for children that reached a
246
+ * terminal state without a delivered report, and cascades cancels after
247
+ * a flow settles. Also the age a dependency row must reach before it is
248
+ * reconciled, and the outbox relay's fallback drain cadence (the relay
249
+ * normally drains the moment a child result is acked). The sweeper runs
250
+ * only on workers that registered a flow via `Flow.toLayer`; the relay
251
+ * runs on any worker with flow registrations of either kind.
252
+ */
253
+ readonly flowSweepInterval?: Duration.Input | undefined
155
254
  /** Identifier used in lock tokens (default: random). */
156
255
  readonly id?: string | undefined
157
256
  }
@@ -187,25 +286,38 @@ export class Worker extends Context.Service<Worker, {
187
286
  >(
188
287
  job: JobDescriptor<Payload, Success, Error>,
189
288
  handler: (
190
- payload: Payload["Type"],
191
- context: JobContext
289
+ payload: Payload["Type"]
192
290
  ) => Effect.Effect<Success["Type"], Error["Type"], R>,
193
291
  options?: RegisterOptions | undefined
194
292
  ) => Effect.Effect<
195
293
  void,
196
294
  never,
197
295
  | Scope.Scope
198
- | R
296
+ // The worker provides CurrentJob around every run.
297
+ | Exclude<R, CurrentJob>
199
298
  | Payload["DecodingServices"]
200
299
  | Success["EncodingServices"]
201
300
  | Error["EncodingServices"]
202
301
  >
302
+ /**
303
+ * Register a flow's parent phases (`fanOut`/`collect`). Called by
304
+ * `Flow.toLayer`, which declares the real requirements (parent + child
305
+ * stores, handler R, codec services) on its own signature.
306
+ *
307
+ * @internal
308
+ */
309
+ readonly registerFlow: (
310
+ flow: FlowDescriptor,
311
+ options?: RegisterOptions | undefined
312
+ ) => Effect.Effect<void, never, Scope.Scope>
203
313
  }>()("effect-mq/Worker") {}
204
314
 
205
315
  interface HandlerEntry {
316
+ /** For flow parents this is the `collect` phase (`flowState` carries its tallies). */
206
317
  readonly run: (
207
318
  payload: JobRecord["payload"],
208
- context: JobContext
319
+ context: JobContext,
320
+ flowState: FlowState | undefined
209
321
  ) => Effect.Effect<unknown, unknown>
210
322
  readonly encodeExit: (
211
323
  exit: Exit.Exit<unknown, unknown>
@@ -213,8 +325,40 @@ interface HandlerEntry {
213
325
  readonly unrecoverableFailure:
214
326
  | ((cause: Cause.Cause<unknown>) => boolean)
215
327
  | undefined
328
+ /** Present for flow parents: the fan-out phase and its plumbing. */
329
+ readonly flow: {
330
+ readonly flowName: string
331
+ readonly failFast: boolean
332
+ readonly fanOut: (
333
+ payload: JobRecord["payload"],
334
+ context: JobContext,
335
+ parentDepth: number
336
+ ) => Effect.Effect<ReadonlyArray<FlowChildSpec>, unknown>
337
+ readonly enqueueChildren: (
338
+ children: ReadonlyArray<FlowChildSpec>
339
+ ) => Effect.Effect<void>
340
+ } | undefined
216
341
  }
217
342
 
343
+ // A `retryable` predicate lifted to a cause classifier (false ⇒ skip the
344
+ // remaining retry budget).
345
+ const toUnrecoverableFailure = (
346
+ retryable: ((error: never) => boolean) | undefined
347
+ ): ((cause: Cause.Cause<unknown>) => boolean) | undefined =>
348
+ retryable === undefined ? undefined : (cause) => {
349
+ const failure = Cause.findErrorOption(cause)
350
+ if (Option.isNone(failure)) return false
351
+ try {
352
+ // SAFETY: the only typed failures a handler can produce are its
353
+ // declared error type, which is what `retryable` accepts.
354
+ return !retryable(failure.value as never)
355
+ } catch {
356
+ // A throwing predicate must never leave the job un-acked: treat the
357
+ // failure as retryable and let the budget decide.
358
+ return false
359
+ }
360
+ }
361
+
218
362
  // A cause is unrecoverable when its error or defect was marked via
219
363
  // `Job.unrecoverable` (identity-based, so typed channels stay untouched).
220
364
  const causeIsMarkedUnrecoverable = (cause: Cause.Cause<unknown>): boolean => {
@@ -252,14 +396,29 @@ const storeRetryPolicy = Schedule.min([
252
396
  type Restore = <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
253
397
 
254
398
  /**
255
- * Build the worker service. Requires a `Scope` (fibers live in it) and the
256
- * `JobStore`.
399
+ * The parent StoreIds of the flows passed via `Worker.layer({ flows })`
400
+ * a naked type parameter so the union of flows distributes.
401
+ *
402
+ * @since 0.6.0
403
+ */
404
+ export type FlowParentStores<F> = F extends {
405
+ readonly parent: { readonly store: Context.Key<infer Id, StoreService> }
406
+ } ? Id
407
+ : never
408
+
409
+ /**
410
+ * Build the worker service. Requires a `Scope` (fibers live in it), the
411
+ * `JobStore`, and the parent store of every flow in `flows`.
257
412
  *
258
413
  * @since 0.1.0
259
414
  */
260
- export const make = <StoreId = JobStore>(
261
- options?: WorkerOptions<StoreId> | undefined
262
- ): Effect.Effect<Worker["Service"], never, Scope.Scope | StoreId> =>
415
+ export const make = <StoreId = JobStore, const Flows extends ReadonlyArray<FlowAny> = ReadonlyArray<never>>(
416
+ options?: (WorkerOptions<StoreId> & { readonly flows?: Flows | undefined }) | undefined
417
+ ): Effect.Effect<
418
+ Worker["Service"],
419
+ never,
420
+ Scope.Scope | StoreId | FlowParentStores<Flows[number]>
421
+ > =>
263
422
  Effect.gen(function*() {
264
423
  // Taker fibers are forked from whichever registration arrives first; pin
265
424
  // them to the worker's own context so they never inherit one job's
@@ -278,6 +437,7 @@ export const make = <StoreId = JobStore>(
278
437
  : Math.max(1, Math.floor(lockDurationMs / 2))
279
438
  const stalledMs = Duration.toMillis(options?.stalledInterval ?? 30_000)
280
439
  const scheduleSweepMs = Duration.toMillis(options?.scheduleSweepInterval ?? 15_000)
440
+ const flowSweepMs = Duration.toMillis(options?.flowSweepInterval ?? 30_000)
281
441
  const maxStalledCount = options?.maxStalledCount ?? 1
282
442
  const pollMs = Duration.toMillis(options?.pollInterval ?? 5_000)
283
443
  const workerId = options?.id ?? `worker-${Math.random().toString(36).slice(2, 10)}`
@@ -293,6 +453,24 @@ export const make = <StoreId = JobStore>(
293
453
  // interrupt-only exits ack as Cancelled instead of shutdown-release.
294
454
  const cancelling = new Set<JobId>()
295
455
 
456
+ // Flow plumbing. `storesByKey` maps store-key strings to resolved
457
+ // services — this worker's own store, every `flows` entry's parent
458
+ // store, and each registered flow's child stores (a registered flow's
459
+ // parent store IS the worker's own store). The relay
460
+ // routes outbox entries by `parentStoreKey` through it, and the sweeper
461
+ // and post-fan-out enqueue resolve child stores through it.
462
+ // `flowPolicies` carries each known flow's fail-fast bit for settle
463
+ // logging.
464
+ const storesByKey = new Map<string, StoreService>()
465
+ const flowPolicies = new Map<string, { readonly failFast: boolean }>()
466
+ let flowSweeperStarted = false
467
+ let relayStarted = false
468
+ storesByKey.set(storeKey.key, store)
469
+ for (const flow of options?.flows ?? []) {
470
+ storesByKey.set(flow.parent.store.key, yield* flow.parent.store)
471
+ flowPolicies.set(flow.name, { failFast: flow.failFast })
472
+ }
473
+
296
474
  let tokenCounter = 0
297
475
  const nextToken = () => `${workerId}:${++tokenCounter}`
298
476
 
@@ -341,6 +519,29 @@ export const make = <StoreId = JobStore>(
341
519
  )
342
520
  )
343
521
 
522
+ // Like ackSafely, but says whether the ack definitely landed — flow
523
+ // reports and post-fan-out child enqueues must only follow an ack this
524
+ // worker actually won.
525
+ const ackLanded = (
526
+ effect: Effect.Effect<
527
+ void,
528
+ JobStoreError | JobNotFoundError | LockLostError
529
+ >,
530
+ what: string
531
+ ) =>
532
+ effect.pipe(
533
+ Effect.retry({
534
+ while: (error) => isJobStoreError(error),
535
+ schedule: storeRetryPolicy
536
+ }),
537
+ Effect.as(true),
538
+ Effect.catch((error) =>
539
+ Effect.logWarning(`effect-mq: ${what} dropped (${error._tag})`, error).pipe(
540
+ Effect.as(false)
541
+ )
542
+ )
543
+ )
544
+
344
545
  // Every failed run is logged (warning while retries remain, error once
345
546
  // terminal) and handed to the onJobFailure hook. The hook runs isolated:
346
547
  // whatever it does, job processing proceeds.
@@ -369,6 +570,121 @@ export const make = <StoreId = JobStore>(
369
570
  }
370
571
  })
371
572
 
573
+ // The outbox relay. Child results land in this store's outbox
574
+ // atomically with each terminal transition; the relay drains them into
575
+ // their parent stores in batches and deletes what it delivered.
576
+ // Redelivery after a crash is safe (dependency rows dedup), so there
577
+ // are no leases — peek, deliver, delete. A terminal ack nudges the
578
+ // relay so results push immediately; the periodic pass is the fallback.
579
+ let relayPulseVersion = 0
580
+ let relayPulse = Deferred.makeUnsafe<void>()
581
+ const fireRelayPulse = Effect.suspend(() => {
582
+ relayPulseVersion += 1
583
+ const current = relayPulse
584
+ relayPulse = Deferred.makeUnsafe<void>()
585
+ return Deferred.succeed(current, void 0)
586
+ })
587
+ const awaitRelayPulse = (observed: number) =>
588
+ Effect.suspend(() =>
589
+ relayPulseVersion > observed ? Effect.void : Deferred.await(relayPulse)
590
+ )
591
+
592
+ const relayDrain = Effect.gen(function*() {
593
+ const pageSize = 500
594
+ // Cursor-walk the whole outbox: `after` pages past everything already
595
+ // seen this drain, so entries this worker cannot route — or could not
596
+ // deliver just now — never blockade the ones behind them. Each drain
597
+ // starts from the head again, which is what retries them.
598
+ let after: string | undefined
599
+ while (true) {
600
+ const entries = yield* retryStore(store.peekOutbox({ limit: pageSize, after }))
601
+ if (entries.length === 0) return
602
+ after = entries[entries.length - 1]?.id
603
+ const byStore = new Map<string, Array<(typeof entries)[number]>>()
604
+ let skipped = 0
605
+ for (const entry of entries) {
606
+ if (!storesByKey.has(entry.parentStoreKey)) {
607
+ // No route from this worker; a worker with the right `flows`
608
+ // registration relays it, and reconciliation keeps the flow
609
+ // correct regardless.
610
+ skipped += 1
611
+ continue
612
+ }
613
+ let group = byStore.get(entry.parentStoreKey)
614
+ if (group === undefined) {
615
+ group = []
616
+ byStore.set(entry.parentStoreKey, group)
617
+ }
618
+ group.push(entry)
619
+ }
620
+ if (skipped > 0) {
621
+ yield* Metric.update(Metrics.flowOutboxSkipped, skipped)
622
+ }
623
+ for (const [key, group] of byStore) {
624
+ const target = storesByKey.get(key)
625
+ if (target === undefined) continue
626
+ // One unreachable parent store must not block deliveries to the
627
+ // others: log, leave the group's entries for the next pass, move
628
+ // on.
629
+ yield* Effect.gen(function*() {
630
+ const results = yield* retryStore(
631
+ target.recordChildResults(group.map((entry) => entry.report))
632
+ )
633
+ for (const [index, result] of results.entries()) {
634
+ const entry = group[index]
635
+ if (entry === undefined) continue
636
+ if (result.applied) {
637
+ yield* Metric.update(
638
+ Metrics.flowChildReports.pipe(
639
+ Metric.withAttributes({
640
+ flow: entry.flowName,
641
+ outcome: entry.report.outcome,
642
+ source: "report"
643
+ })
644
+ ),
645
+ 1
646
+ )
647
+ }
648
+ if (
649
+ result.parentSettled && entry.report.outcome === "failed" &&
650
+ flowPolicies.get(entry.flowName)?.failFast === true
651
+ ) {
652
+ yield* Effect.logError(
653
+ `effect-mq: flow "${entry.flowName}" (${entry.report.flowId}) settled failed-fast on child "${entry.report.childKey}"`
654
+ )
655
+ }
656
+ }
657
+ yield* retryStore(store.deleteOutbox(group.map((entry) => entry.id)))
658
+ }).pipe(
659
+ Effect.catchCause((cause) =>
660
+ Effect.logWarning(
661
+ `effect-mq: outbox relay could not deliver to store "${key}"; retrying next pass`,
662
+ cause
663
+ )
664
+ )
665
+ )
666
+ }
667
+ if (entries.length < pageSize) return
668
+ }
669
+ })
670
+
671
+ const relayLoop = Effect.gen(function*() {
672
+ const observed = relayPulseVersion
673
+ // Failures are contained per drain so the race below always paces the
674
+ // loop (no hot retry); a pulse fired DURING the drain re-runs it
675
+ // immediately via the version check.
676
+ yield* relayDrain.pipe(
677
+ Effect.catchCause((cause) => Effect.logError("effect-mq: outbox relay drain failed", cause))
678
+ )
679
+ yield* Effect.race(awaitRelayPulse(observed), Effect.sleep(flowSweepMs))
680
+ }).pipe(Effect.forever)
681
+
682
+ const ensureRelay = Effect.suspend(() => {
683
+ if (relayStarted) return Effect.void
684
+ relayStarted = true
685
+ return FiberSet.run(fibers, relayLoop.pipe(Effect.updateContext(() => workerContext)))
686
+ })
687
+
372
688
  const routeFailure = (record: JobRecord, exit: JobRecord["exit"]): AckOutcome => {
373
689
  const attempt = record.attemptsMade + 1
374
690
  if (attempt >= record.attemptsMax) {
@@ -405,6 +721,38 @@ export const make = <StoreId = JobStore>(
405
721
  Math.max(0, finished - (record.processedAt ?? finished))
406
722
  )
407
723
  })
724
+ if (record.flow !== undefined && entry.flow === undefined) {
725
+ // A fanned-out flow parent claimed by a PLAIN handler registration
726
+ // (the flow was re-registered as an ordinary job in a deploy):
727
+ // running the plain handler would silently ack its result as the
728
+ // parent's terminal exit and discard the recorded child results.
729
+ // Fail visibly instead; once the deploy is fixed, an admin retry
730
+ // re-enters collect with the manifest intact.
731
+ return Effect.gen(function*() {
732
+ const cause = Cause.die(new Error(
733
+ `effect-mq: job "${record.name}" carries flow state (collect phase) but is registered as a plain handler; ` +
734
+ `register its flow via Flow.toLayer instead of Job.toLayer`
735
+ ))
736
+ const encoded = yield* Effect.exit(entry.encodeExit(Exit.failCause(cause)))
737
+ yield* ackSafely(
738
+ store.ack(record.id, token, {
739
+ _tag: "Fail",
740
+ exit: Exit.isSuccess(encoded) ? encoded.value : undefined
741
+ }),
742
+ "ack"
743
+ )
744
+ yield* reportFailure({
745
+ jobId: record.id,
746
+ name: record.name,
747
+ queue: record.queue,
748
+ attempt: context.attempt,
749
+ attemptsMax: record.attemptsMax,
750
+ willRetry: false,
751
+ cause
752
+ }, undefined)
753
+ yield* recordRun("failed")
754
+ })
755
+ }
408
756
  return Effect.gen(function*() {
409
757
  // The per-run time limit interrupts the handler internally; surface
410
758
  // it as a defect so it flows through normal retry accounting.
@@ -425,7 +773,25 @@ export const make = <StoreId = JobStore>(
425
773
  spanId: record.trace.spanId,
426
774
  sampled: record.trace.sampled
427
775
  })
428
- const withRunSpan = entry.run(record.payload, context).pipe(
776
+ // Flow-parent phase dispatch is persisted, not inferred: no `flow`
777
+ // bookkeeping on the record means the manifest never landed (run
778
+ // `fanOut`); its presence means a resumed parent (run `collect`,
779
+ // stored in `entry.run`) — a re-claimed parent can never fan out
780
+ // twice.
781
+ const flow = entry.flow
782
+ const isFanOut = flow !== undefined && record.flow === undefined
783
+ const runEffect = (isFanOut
784
+ ? flow.fanOut(record.payload, context, record.parent?.depth ?? 0)
785
+ : entry.run(record.payload, context, record.flow)).pipe(
786
+ // Every log line a handler writes carries the job identity —
787
+ // log-based alerting needs no per-handler setup.
788
+ Effect.annotateLogs({
789
+ effectMqJobId: record.id,
790
+ effectMqQueue: record.queue,
791
+ effectMqAttempt: context.attempt
792
+ })
793
+ )
794
+ const withRunSpan = runEffect.pipe(
429
795
  Effect.withSpan(
430
796
  options?.handlerSpanName?.(context) ?? `${record.name}.run`,
431
797
  {
@@ -472,7 +838,12 @@ export const make = <StoreId = JobStore>(
472
838
  // A cancel-request interrupt is terminal: ack Cancelled (even if a
473
839
  // shutdown races it — cancellation wins, the job must not revive).
474
840
  if (wasCancelled && Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) {
475
- yield* ackSafely(store.ack(record.id, token, { _tag: "Cancelled" }), "ack")
841
+ const landed = yield* ackLanded(store.ack(record.id, token, { _tag: "Cancelled" }), "ack")
842
+ if (landed && record.parent !== undefined) {
843
+ // The ack appended this child's report to the outbox; drain now
844
+ // instead of waiting for the periodic pass.
845
+ yield* fireRelayPulse
846
+ }
476
847
  return yield* recordRun("cancelled")
477
848
  }
478
849
 
@@ -499,6 +870,53 @@ export const make = <StoreId = JobStore>(
499
870
  )
500
871
  : exit
501
872
 
873
+ if (isFanOut && Exit.isSuccess(effective)) {
874
+ // SAFETY: the fan-out runner's success type is the built specs.
875
+ const children = effective.value as ReadonlyArray<FlowChildSpec>
876
+ const acked = yield* Effect.exit(
877
+ store.ack(record.id, token, {
878
+ _tag: "FanOut",
879
+ failFast: flow.failFast,
880
+ children
881
+ }).pipe(
882
+ Effect.retry({
883
+ while: (error) => isJobStoreError(error),
884
+ schedule: storeRetryPolicy
885
+ })
886
+ )
887
+ )
888
+ if (Exit.isSuccess(acked)) {
889
+ yield* Metric.update(
890
+ Metrics.flowFanOuts.pipe(Metric.withAttributes({ flow: flow.flowName })),
891
+ 1
892
+ )
893
+ // The ack may have settled the parent instead of parking it (a
894
+ // cancel raced the fan-out: cancellation wins and the rows are
895
+ // already marked for cascade) — children of a settled flow must
896
+ // not start. A settle that lands AFTER this check still
897
+ // converges: the marked rows make the sweeper cancel whatever
898
+ // we enqueue below.
899
+ const parked = children.length === 0 ? Option.none() : yield* retryStore(store.getJob(record.id))
900
+ if (Option.isSome(parked) && parked.value.state === "waiting-children") {
901
+ // Fast path only: the flow sweeper re-drives whatever a
902
+ // crash here misses, straight from the persisted specs.
903
+ yield* flow.enqueueChildren(children)
904
+ } else if (children.length > 0) {
905
+ yield* Effect.logInfo(
906
+ `effect-mq: flow "${flow.flowName}" (${record.id}) settled during fan-out; children not enqueued`
907
+ )
908
+ }
909
+ } else {
910
+ // Lock lost or job vanished: the manifest did NOT land, so the
911
+ // children must not run — another worker re-runs fanOut.
912
+ yield* Effect.logWarning(
913
+ `effect-mq: FanOut ack dropped for flow "${flow.flowName}" (${record.id})`,
914
+ acked.cause
915
+ )
916
+ }
917
+ return yield* recordRun("fanned-out")
918
+ }
919
+
502
920
  // Never let an encode defect escape: the job would be stuck active.
503
921
  let encoded = yield* Effect.exit(entry.encodeExit(effective))
504
922
  let encodable = true
@@ -527,7 +945,15 @@ export const make = <StoreId = JobStore>(
527
945
  : unrecoverable
528
946
  ? { _tag: "Fail", exit: exitValue }
529
947
  : routeFailure(record, exitValue)
530
- yield* ackSafely(store.ack(record.id, token, outcome), "ack")
948
+ const landed = yield* ackLanded(store.ack(record.id, token, outcome), "ack")
949
+ if (
950
+ landed && record.parent !== undefined &&
951
+ (outcome._tag === "Complete" || outcome._tag === "Fail")
952
+ ) {
953
+ // The ack appended this child's report to the outbox; drain now
954
+ // instead of waiting for the periodic pass.
955
+ yield* fireRelayPulse
956
+ }
531
957
  if (outcome._tag === "Fail" || outcome._tag === "Retry") {
532
958
  const cause = Exit.isFailure(effective)
533
959
  ? effective.cause
@@ -752,6 +1178,7 @@ export const make = <StoreId = JobStore>(
752
1178
  timeoutMs: schedule.timeoutMs,
753
1179
  dedupe: undefined,
754
1180
  trace: undefined,
1181
+ parent: undefined,
755
1182
  delayMs: 0
756
1183
  }))
757
1184
  if (fired) {
@@ -779,9 +1206,149 @@ export const make = <StoreId = JobStore>(
779
1206
  Effect.forever
780
1207
  )
781
1208
 
1209
+ // The flow reconciliation engine (see `FlowSweepWork`). Every action is
1210
+ // idempotent by construction: enqueues dedup on the deterministic child
1211
+ // id, reports dedup on the dependency row's state, cancels dedup on the
1212
+ // child's state, and the cascaded flag dedups its own write — so crashes
1213
+ // anywhere in the sweep are safe.
1214
+ const reconcileChild = (flowId: JobId, child: FlowChildSpec) =>
1215
+ Effect.gen(function*() {
1216
+ const childStore = storesByKey.get(child.storeKey)
1217
+ if (childStore === undefined) {
1218
+ // A flow registered on another worker shares this parent store;
1219
+ // that worker's sweeper holds the child store layer.
1220
+ return
1221
+ }
1222
+ const id = child.request.id
1223
+ if (id === undefined) return
1224
+ const existing = yield* retryStore(childStore.getJob(id))
1225
+ if (Option.isNone(existing)) {
1226
+ // Never landed (crash between the FanOut ack and the enqueue), or
1227
+ // pruned before reporting (see the child-retention guidance) —
1228
+ // (re-)drive it from the persisted spec. The flow may have settled
1229
+ // since this work item was snapshotted (another sweeper's cascade
1230
+ // would then have found this child missing, marked its row
1231
+ // cascaded, and moved on), so re-check the parent around the
1232
+ // enqueue: before, to skip settled flows, and after, so a settle
1233
+ // that lands inside the window cannot leave an orphan running.
1234
+ const before = yield* retryStore(store.getJob(flowId))
1235
+ if (Option.isNone(before) || before.value.state !== "waiting-children") return
1236
+ yield* retryStore(childStore.enqueue(child.request))
1237
+ const after = yield* retryStore(store.getJob(flowId))
1238
+ if (Option.isNone(after) || after.value.state !== "waiting-children") {
1239
+ yield* retryStore(
1240
+ childStore.cancel(id).pipe(
1241
+ Effect.catchTag(["JobNotFoundError", "JobNotCancellableError"], () => Effect.void)
1242
+ )
1243
+ )
1244
+ }
1245
+ return
1246
+ }
1247
+ const state = existing.value.state
1248
+ if (state !== "completed" && state !== "failed" && state !== "cancelled") {
1249
+ // Still in flight: never blindly re-drive a live child.
1250
+ return
1251
+ }
1252
+ // Terminal without a delivered report (a dropped outbox entry, or a
1253
+ // child whose store has no relaying worker): synthesize the report
1254
+ // from the child store's own record.
1255
+ const results = yield* retryStore(store.recordChildResults([{
1256
+ flowId,
1257
+ childKey: child.childKey,
1258
+ outcome: state,
1259
+ exit: existing.value.exit,
1260
+ failedReason: existing.value.failedReason
1261
+ }]))
1262
+ const result = results[0]
1263
+ if (result?.applied === true) {
1264
+ yield* Metric.update(
1265
+ Metrics.flowChildReports.pipe(
1266
+ Metric.withAttributes({
1267
+ flow: child.request.parent?.flowName ?? "unknown",
1268
+ outcome: state,
1269
+ source: "reconcile"
1270
+ })
1271
+ ),
1272
+ 1
1273
+ )
1274
+ if (result?.parentSettled === true && state === "failed") {
1275
+ yield* Effect.logError(
1276
+ `effect-mq: flow ${flowId} settled on reconciled child failure "${child.childKey}"`
1277
+ )
1278
+ }
1279
+ }
1280
+ })
1281
+
1282
+ const cascadeChildren = (
1283
+ flowId: JobId,
1284
+ children: ReadonlyArray<{
1285
+ readonly childKey: string
1286
+ readonly storeKey: string
1287
+ readonly childJobId: JobId
1288
+ }>
1289
+ ) =>
1290
+ Effect.gen(function*() {
1291
+ const done: Array<string> = []
1292
+ for (const child of children) {
1293
+ const childStore = storesByKey.get(child.storeKey)
1294
+ if (childStore === undefined) continue
1295
+ // Idempotent: a vanished or already-terminal child is "cancelled
1296
+ // enough".
1297
+ yield* retryStore(
1298
+ childStore.cancel(child.childJobId).pipe(
1299
+ Effect.catchTag(["JobNotFoundError", "JobNotCancellableError"], () => Effect.void)
1300
+ )
1301
+ )
1302
+ done.push(child.childKey)
1303
+ }
1304
+ if (done.length > 0) {
1305
+ yield* retryStore(store.markChildrenCascaded(flowId, done))
1306
+ yield* Metric.update(Metrics.flowCascades, done.length)
1307
+ }
1308
+ })
1309
+
1310
+ const flowSweepLoop = Effect.gen(function*() {
1311
+ yield* Effect.sleep(flowSweepMs)
1312
+ const work = yield* retryStore(store.flowSweepWork({ pendingAgeMs: flowSweepMs, limit: 512 }))
1313
+ for (const group of work.reconcile) {
1314
+ for (const child of group.children) {
1315
+ // Each child in isolation: one poison row cannot starve the sweep.
1316
+ yield* reconcileChild(group.flowId, child).pipe(
1317
+ Effect.catchCause((cause) =>
1318
+ Effect.logError(
1319
+ `effect-mq: flow reconcile failed for child "${child.childKey}" of ${group.flowId}`,
1320
+ cause
1321
+ )
1322
+ )
1323
+ )
1324
+ }
1325
+ }
1326
+ for (const group of work.cascade) {
1327
+ yield* cascadeChildren(group.flowId, group.children).pipe(
1328
+ Effect.catchCause((cause) =>
1329
+ Effect.logError(`effect-mq: flow cascade failed for ${group.flowId}`, cause)
1330
+ )
1331
+ )
1332
+ }
1333
+ }).pipe(
1334
+ Effect.catchCause((cause) => Effect.logError("effect-mq: flow sweep failed", cause)),
1335
+ Effect.forever
1336
+ )
1337
+
1338
+ // Started lazily by the first flow registration — plain workers never
1339
+ // pay for a sweep query.
1340
+ const ensureFlowSweeper = Effect.suspend(() => {
1341
+ if (flowSweeperStarted) return Effect.void
1342
+ flowSweeperStarted = true
1343
+ return FiberSet.run(fibers, flowSweepLoop.pipe(Effect.updateContext(() => workerContext)))
1344
+ })
1345
+
782
1346
  yield* FiberSet.run(fibers, renewalLoop)
783
1347
  yield* FiberSet.run(fibers, stalledLoop)
784
1348
  yield* FiberSet.run(fibers, scheduleLoop)
1349
+ if (flowPolicies.size > 0) {
1350
+ yield* ensureRelay
1351
+ }
785
1352
 
786
1353
  if (options?.queueMetricsInterval !== undefined) {
787
1354
  const sampleMs = Duration.toMillis(options.queueMetricsInterval)
@@ -828,7 +1395,7 @@ export const make = <StoreId = JobStore>(
828
1395
  // registration layer; capture it, minus runtime-ambient keys that
829
1396
  // must always come from the executing fiber.
830
1397
  const services = (yield* Effect.context<never>()).pipe(
831
- Context.omit(Scope_.Scope, Tracer.ParentSpan)
1398
+ Context.omit(Scope_.Scope, Tracer.ParentSpan, CurrentJob)
832
1399
  )
833
1400
  const decodePayload = Schema.decodeUnknownEffect(job.payloadJsonSchema)
834
1401
  const encodeExit = Schema.encodeEffect(job.exitSchema)
@@ -842,29 +1409,20 @@ export const make = <StoreId = JobStore>(
842
1409
  Context.merge(input, services) as Context.Context<unknown>
843
1410
  )
844
1411
  ) as Effect.Effect<A, E>
845
- const retryable = job.retryable
846
1412
  const entry: HandlerEntry = {
847
1413
  run: (payload, context) =>
848
1414
  provideCaptured(
849
1415
  decodePayload(payload).pipe(
850
1416
  Effect.orDie,
851
- Effect.flatMap((decoded) => handler(decoded, context))
1417
+ Effect.flatMap((decoded) => handler(decoded)),
1418
+ // Innermost, so neither the captured registration context
1419
+ // nor the worker's own can shadow the running attempt.
1420
+ Effect.provideService(CurrentJob, context)
852
1421
  )
853
1422
  ),
854
1423
  encodeExit: (exit) => provideCaptured(encodeExit(exit)),
855
- unrecoverableFailure: retryable === undefined ? undefined : (cause) => {
856
- const failure = Cause.findErrorOption(cause)
857
- if (Option.isNone(failure)) return false
858
- try {
859
- // SAFETY: the only typed failures a handler can produce are
860
- // its declared error type, which is what `retryable` accepts.
861
- return !retryable(failure.value as Parameters<typeof retryable>[0])
862
- } catch {
863
- // A throwing predicate must never leave the job un-acked:
864
- // treat the failure as retryable and let the budget decide.
865
- return false
866
- }
867
- }
1424
+ unrecoverableFailure: toUnrecoverableFailure(job.retryable),
1425
+ flow: undefined
868
1426
  }
869
1427
  handlers.set(name, entry)
870
1428
  const queue = registerOptions?.queue !== undefined
@@ -884,7 +1442,137 @@ export const make = <StoreId = JobStore>(
884
1442
  )
885
1443
  yield* ensureQueueLoop(queue, registerOptions?.concurrency)
886
1444
  yield* firePulse
887
- }) as Effect.Effect<void, never, never>
1445
+ }) as Effect.Effect<void, never, never>,
1446
+
1447
+ registerFlow: (flow, registerOptions) =>
1448
+ Effect.gen(function*() {
1449
+ const name = flow.parent._tag
1450
+ if (handlers.has(name)) {
1451
+ return yield* Effect.die(
1452
+ new Error(`effect-mq: duplicate handler registered for job "${name}"`)
1453
+ )
1454
+ }
1455
+ if (flow.parent.store.key !== storeKey.key) {
1456
+ return yield* Effect.die(
1457
+ new Error(
1458
+ `effect-mq: flow "${flow.name}" parent "${name}" is bound to store "${flow.parent.store.key}" but this worker claims from "${storeKey.key}". ` +
1459
+ `Provide a Worker.layer({ store }) for the parent's store.`
1460
+ )
1461
+ )
1462
+ }
1463
+ const services = (yield* Effect.context<never>()).pipe(
1464
+ Context.omit(Scope_.Scope, Tracer.ParentSpan, CurrentJob)
1465
+ )
1466
+ // Resolve every child store from the registration context (declared
1467
+ // on Flow.toLayer's signature) — this worker is the one process
1468
+ // guaranteed able to reconcile and cascade across all of them.
1469
+ for (const key of flow.childStores) {
1470
+ const service = Context.getOption(services, key)
1471
+ if (Option.isNone(service)) {
1472
+ return yield* Effect.die(
1473
+ new Error(
1474
+ `effect-mq: flow "${flow.name}" requires child store "${key.key}" — provide it to the flow's layer`
1475
+ )
1476
+ )
1477
+ }
1478
+ storesByKey.set(key.key, service.value)
1479
+ }
1480
+ flowPolicies.set(flow.name, { failFast: flow.failFast })
1481
+ const decodePayload = Schema.decodeUnknownEffect(flow.parent.payloadJsonSchema)
1482
+ const encodeExit = Schema.encodeEffect(flow.parent.exitSchema)
1483
+ // SAFETY: same contract as `register` — the captured context holds
1484
+ // everything Flow.toLayer's signature required.
1485
+ const provideCaptured = <A, E>(effect: Effect.Effect<A, E, unknown>): Effect.Effect<A, E> =>
1486
+ effect.pipe(
1487
+ Effect.updateContext((input) =>
1488
+ Context.merge(input, services) as Context.Context<unknown>
1489
+ )
1490
+ ) as Effect.Effect<A, E>
1491
+ const emptyFlow: FlowState = {
1492
+ failFast: flow.failFast,
1493
+ pending: 0,
1494
+ completed: 0,
1495
+ failed: 0,
1496
+ cancelled: 0
1497
+ }
1498
+ const entry: HandlerEntry = {
1499
+ run: (payload, context, flowState) =>
1500
+ provideCaptured(
1501
+ decodePayload(payload).pipe(
1502
+ Effect.orDie,
1503
+ Effect.flatMap((decoded) =>
1504
+ // collect dispatch requires a persisted manifest, so the
1505
+ // fallback is defensive only.
1506
+ flow.collect(decoded, flowState ?? emptyFlow, context)
1507
+ ),
1508
+ Effect.provideService(CurrentJob, context)
1509
+ )
1510
+ ),
1511
+ encodeExit: (exit) => provideCaptured(encodeExit(exit)),
1512
+ unrecoverableFailure: toUnrecoverableFailure(flow.parent.retryable),
1513
+ flow: {
1514
+ flowName: flow.name,
1515
+ failFast: flow.failFast,
1516
+ fanOut: (payload, context, parentDepth) =>
1517
+ provideCaptured(
1518
+ decodePayload(payload).pipe(
1519
+ Effect.orDie,
1520
+ Effect.flatMap((decoded) => flow.fanOut(decoded, context, parentDepth)),
1521
+ Effect.provideService(CurrentJob, context)
1522
+ )
1523
+ ),
1524
+ enqueueChildren: (children) =>
1525
+ Effect.gen(function*() {
1526
+ // Group per child store; enqueueMany chunks internally and
1527
+ // dedups on the deterministic ids.
1528
+ const byStore = new Map<string, Array<EnqueueRequest>>()
1529
+ for (const child of children) {
1530
+ let group = byStore.get(child.storeKey)
1531
+ if (group === undefined) {
1532
+ group = []
1533
+ byStore.set(child.storeKey, group)
1534
+ }
1535
+ group.push(child.request)
1536
+ }
1537
+ for (const [key, requests] of byStore) {
1538
+ const childStore = storesByKey.get(key)
1539
+ if (childStore === undefined) continue
1540
+ yield* retryStore(childStore.enqueueMany(requests)).pipe(
1541
+ Effect.catchCause((cause) =>
1542
+ Effect.logWarning(
1543
+ `effect-mq: flow "${flow.name}" child enqueue incomplete; the flow sweeper will reconcile`,
1544
+ cause
1545
+ )
1546
+ )
1547
+ )
1548
+ }
1549
+ })
1550
+ }
1551
+ }
1552
+ handlers.set(name, entry)
1553
+ const queue = registerOptions?.queue !== undefined
1554
+ ? QueueName(registerOptions.queue)
1555
+ : flow.parent.queue
1556
+ let names = queueNames.get(queue)
1557
+ if (names === undefined) {
1558
+ names = new Set()
1559
+ queueNames.set(queue, names)
1560
+ }
1561
+ names.add(name)
1562
+ yield* Effect.addFinalizer(() =>
1563
+ Effect.sync(() => {
1564
+ handlers.delete(name)
1565
+ queueNames.get(queue)?.delete(name)
1566
+ })
1567
+ )
1568
+ yield* ensureQueueLoop(queue, registerOptions?.concurrency)
1569
+ yield* ensureFlowSweeper
1570
+ yield* ensureRelay
1571
+ yield* firePulse
1572
+ // SAFETY: like `register`, the implementation erases requirements
1573
+ // that Flow.toLayer's public signature declares and the captured
1574
+ // context (via provideCaptured) restores; only Scope remains.
1575
+ }) as Effect.Effect<void, never, Scope.Scope>
888
1576
  })
889
1577
  })
890
1578
 
@@ -894,6 +1582,7 @@ export const make = <StoreId = JobStore>(
894
1582
  *
895
1583
  * @since 0.1.0
896
1584
  */
897
- export const layer = <StoreId = JobStore>(
898
- options?: WorkerOptions<StoreId> | undefined
899
- ): Layer.Layer<Worker, never, StoreId> => Layer.effect(Worker, make(options))
1585
+ export const layer = <StoreId = JobStore, const Flows extends ReadonlyArray<FlowAny> = ReadonlyArray<never>>(
1586
+ options?: (WorkerOptions<StoreId> & { readonly flows?: Flows | undefined }) | undefined
1587
+ ): Layer.Layer<Worker, never, StoreId | FlowParentStores<Flows[number]>> =>
1588
+ Layer.effect(Worker, make(options))