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
package/src/Job.ts CHANGED
@@ -63,7 +63,7 @@ export {
63
63
  */
64
64
  unrecoverable
65
65
  }
66
- import { type JobContext, type RegisterOptions, Worker } from "./Worker.ts"
66
+ import { type CurrentJob, type RegisterOptions, Worker } from "./Worker.ts"
67
67
 
68
68
  const TypeId = "~effect-mq/Job" as const
69
69
 
@@ -308,7 +308,12 @@ export type EnqueueManyOptions = Omit<JobOptions, "delay"> & RunTimeInput & {
308
308
  readonly metadata?: Readonly<Record<string, string>> | undefined
309
309
  }
310
310
 
311
- interface ResolvedDefaults {
311
+ /**
312
+ * A definition's `defaults`, normalized to store units.
313
+ *
314
+ * @since 0.6.0
315
+ */
316
+ export interface ResolvedDefaults {
312
317
  readonly delayMs: number
313
318
  readonly priority: number
314
319
  readonly attempts: number
@@ -384,8 +389,12 @@ export interface JobAttempt<A, E> {
384
389
  readonly attempt: number
385
390
  readonly startedAt: number | undefined
386
391
  readonly finishedAt: number
387
- readonly outcome: "completed" | "retried" | "failed" | "stalled" | "cancelled"
388
- /** Absent for `stalled` and `cancelled` entries. */
392
+ readonly outcome: "completed" | "retried" | "failed" | "stalled" | "cancelled" | "fanned-out"
393
+ /**
394
+ * Absent for `stalled`, `cancelled`, and `fanned-out` entries — and for
395
+ * `failed` ones settled store-side without a handler exit (e.g. a
396
+ * fail-fast flow settle).
397
+ */
389
398
  readonly exit: Option.Option<Exit.Exit<A, E>>
390
399
  }
391
400
 
@@ -554,19 +563,20 @@ export interface Job<
554
563
 
555
564
  /**
556
565
  * Attach the handler that processes this job, as a layer to provide on top
557
- * of `Worker.layer` (bound to the same store).
566
+ * of `Worker.layer` (bound to the same store). The handler reads the
567
+ * running attempt from the `Worker.CurrentJob` service — the worker
568
+ * provides it per run, so it never appears in the layer's requirements.
558
569
  */
559
570
  readonly toLayer: <R>(
560
571
  handler: (
561
- payload: Payload["Type"],
562
- context: JobContext
572
+ payload: Payload["Type"]
563
573
  ) => Effect.Effect<Success["Type"], Error["Type"], R>,
564
574
  options?: RegisterOptions | undefined
565
575
  ) => Layer.Layer<
566
576
  never,
567
577
  never,
568
578
  | Worker
569
- | R
579
+ | Exclude<R, CurrentJob>
570
580
  | Payload["DecodingServices"]
571
581
  | Success["EncodingServices"]
572
582
  | Error["EncodingServices"]
@@ -589,7 +599,13 @@ const defaultPollSchedule = Schedule.min([
589
599
  Schedule.spaced("1 second")
590
600
  ])
591
601
 
592
- const normalizeBackoff = (input: BackoffInput | undefined): BackoffPolicy | undefined =>
602
+ /**
603
+ * Normalize a user-facing `BackoffInput` to the persisted policy. Shared
604
+ * with the flow runtime's child-spec builder.
605
+ *
606
+ * @internal
607
+ */
608
+ export const normalizeBackoff = (input: BackoffInput | undefined): BackoffPolicy | undefined =>
593
609
  input === undefined ? undefined : {
594
610
  _tag: input.type,
595
611
  delayMs: Duration.toMillis(input.delay),
@@ -601,7 +617,13 @@ const normalizeKeepState = (input: KeepStateInput): KeepStatePolicy => ({
601
617
  ageMs: input.age !== undefined ? Duration.toMillis(input.age) : undefined
602
618
  })
603
619
 
604
- const normalizeKeep = (input: KeepInput | undefined): KeepPolicy | undefined => {
620
+ /**
621
+ * Normalize a user-facing `KeepInput` to the persisted policy. Shared with
622
+ * the flow runtime's child-spec builder.
623
+ *
624
+ * @internal
625
+ */
626
+ export const normalizeKeep = (input: KeepInput | undefined): KeepPolicy | undefined => {
605
627
  if (input === undefined) return undefined
606
628
  const split = "completed" in input || "failed" in input || "cancelled" in input
607
629
  const flat = "count" in input || "age" in input
@@ -697,6 +719,7 @@ const Proto = {
697
719
  trace: capturedSpan === undefined
698
720
  ? undefined
699
721
  : { ...capturedSpan, delayed: resolvedDelayMs > 0 } satisfies TraceContext,
722
+ parent: undefined,
700
723
  delayMs: resolvedDelayMs
701
724
  }))
702
725
  ),
@@ -779,6 +802,7 @@ const Proto = {
779
802
  trace: capturedSpan === undefined
780
803
  ? undefined
781
804
  : { ...capturedSpan, delayed: resolvedDelayMs > 0 } satisfies TraceContext,
805
+ parent: undefined,
782
806
  delayMs: resolvedDelayMs
783
807
  })))
784
808
  )
@@ -1010,7 +1034,7 @@ const Proto = {
1010
1034
 
1011
1035
  toLayer(
1012
1036
  this: AnyWithProps,
1013
- handler: (payload: any, context: JobContext) => Effect.Effect<any, any, any>,
1037
+ handler: (payload: any) => Effect.Effect<any, any, any>,
1014
1038
  options?: RegisterOptions
1015
1039
  ) {
1016
1040
  return Layer.effectDiscard(
package/src/JobStore.ts CHANGED
@@ -67,6 +67,8 @@ export const ScheduleKey: Brand.Constructor<ScheduleKey> = Brand.nominal<Schedul
67
67
  * - `waiting`: runnable now, ordered by (priority desc, enqueue order asc)
68
68
  * - `delayed`: must not run before `runAt`
69
69
  * - `active`: claimed by a worker holding a lock token
70
+ * - `waiting-children`: a flow parent parked until its children settle (see
71
+ * `AckOutcome`'s `FanOut`); never claimable, not terminal
70
72
  * - `completed` / `failed`: terminal, with an encoded `Exit` stored
71
73
  *
72
74
  * @since 0.1.0
@@ -75,6 +77,7 @@ export type JobState =
75
77
  | "waiting"
76
78
  | "delayed"
77
79
  | "active"
80
+ | "waiting-children"
78
81
  | "completed"
79
82
  | "failed"
80
83
  | "cancelled"
@@ -205,8 +208,12 @@ export interface AttemptRecord {
205
208
  readonly startedAt: number | undefined
206
209
  /** Ack/recovery time of this run (epoch millis). */
207
210
  readonly finishedAt: number
208
- readonly outcome: "completed" | "retried" | "failed" | "stalled" | "cancelled"
209
- /** Schema-encoded `Exit`; undefined for `stalled`. */
211
+ readonly outcome: "completed" | "retried" | "failed" | "stalled" | "cancelled" | "fanned-out"
212
+ /**
213
+ * Schema-encoded `Exit`; undefined for `stalled`, `cancelled`, and
214
+ * `fanned-out` entries, and for `failed` entries written by a store-side
215
+ * settle (a fail-fast flow parent) rather than a handler run.
216
+ */
210
217
  readonly exit: unknown
211
218
  }
212
219
 
@@ -244,6 +251,10 @@ export interface JobRecord {
244
251
  readonly dedupeKey: string | undefined
245
252
  /** The producer's span context, restored as the handler span's parent. */
246
253
  readonly trace: TraceContext | undefined
254
+ /** Present on flow children: the link back to their parent flow. */
255
+ readonly parent: ParentEnvelope | undefined
256
+ /** Present on flow parents once their manifest landed (see `FlowState`). */
257
+ readonly flow: FlowState | undefined
247
258
  /** Epoch millis before which the job must not be claimed. */
248
259
  readonly runAt: number
249
260
  readonly enqueuedAt: number
@@ -304,6 +315,171 @@ export interface TraceContext {
304
315
  readonly delayed: boolean
305
316
  }
306
317
 
318
+ /**
319
+ * The persisted link from a flow child job to its parent flow. Attached by
320
+ * the flow runtime at fan-out (never by producers). Its presence puts the
321
+ * job under the outbox invariant: the child's store appends its report to
322
+ * the outbox with every terminal transition (see `OutboxEntry`), and a
323
+ * worker's relay delivers it into the parent's store (see
324
+ * `Worker.layer({ flows })`).
325
+ *
326
+ * @since 0.6.0
327
+ */
328
+ export interface ParentEnvelope {
329
+ /** The flow definition's name (`Flow.make(name, ...)`). */
330
+ readonly flowName: string
331
+ /** The parent job's id in the parent store. */
332
+ readonly flowId: JobId
333
+ /** This child's key, unique within the flow (the idempotency mechanism). */
334
+ readonly childKey: string
335
+ /** The parent store's context-key string, for cross-store report routing. */
336
+ readonly parentStoreKey: string
337
+ /**
338
+ * This child's nesting level: 1 for children of a top-level flow, one
339
+ * more per level of nesting. Carried explicitly (never parsed out of
340
+ * ids — user keys are arbitrary strings) so the fan-out depth cap can
341
+ * catch cyclic definitions.
342
+ */
343
+ readonly depth: number
344
+ }
345
+
346
+ /**
347
+ * Flow bookkeeping persisted on a parent job by the `FanOut` ack. Its
348
+ * presence IS the phase marker: absent means the parent has not fanned out
349
+ * yet (a claim dispatches `fanOut`); present means the manifest landed (a
350
+ * claim dispatches `collect`, and a re-run can never fan out twice).
351
+ *
352
+ * The four counters always sum to the manifest size: applied reports move
353
+ * one child from `pending` to its outcome bucket, and settle-time marking
354
+ * (fail-fast, parent cancel) moves every remaining `pending` child to
355
+ * `cancelled` in the same atomic op. `collect` reads its tallies from here
356
+ * without touching a single dependency row.
357
+ *
358
+ * @since 0.6.0
359
+ */
360
+ export interface FlowState {
361
+ /** When true, the first failed child report settles the parent as failed. */
362
+ readonly failFast: boolean
363
+ /** Children whose result has not been recorded yet. */
364
+ readonly pending: number
365
+ readonly completed: number
366
+ readonly failed: number
367
+ readonly cancelled: number
368
+ }
369
+
370
+ /**
371
+ * One child of a flow fan-out, persisted on its dependency row so the flow
372
+ * sweeper can (re-)enqueue the child from storage alone after any crash.
373
+ * `request.id` is the deterministic flow child id — derived from the parent
374
+ * store key, flow id, and child key, so re-enqueues are idempotent — and
375
+ * `request.parent` carries the envelope.
376
+ *
377
+ * @since 0.6.0
378
+ */
379
+ export interface FlowChildSpec {
380
+ readonly childKey: string
381
+ /** The CHILD store's context-key string (children may live elsewhere). */
382
+ readonly storeKey: string
383
+ readonly request: EnqueueRequest
384
+ }
385
+
386
+ /**
387
+ * A dependency row: one child's status and result as recorded in the PARENT
388
+ * store. `exit` is the child's schema-encoded exit; `failedReason` carries
389
+ * store-side child failures (stall exhaustion, a nested parent's fail-fast
390
+ * settle) that never produced an exit. `cascaded` marks that a cancel no
391
+ * longer needs to be delivered into the child's store (set by
392
+ * `markChildrenCascaded`, or immediately when the recorded outcome came
393
+ * FROM the child's store).
394
+ *
395
+ * @since 0.6.0
396
+ */
397
+ export interface FlowChildRecord {
398
+ readonly flowId: JobId
399
+ readonly childKey: string
400
+ readonly name: string
401
+ readonly storeKey: string
402
+ readonly childJobId: JobId
403
+ readonly status: "pending" | "completed" | "failed" | "cancelled"
404
+ readonly exit: unknown
405
+ readonly failedReason: string | undefined
406
+ readonly cascaded: boolean
407
+ }
408
+
409
+ /**
410
+ * An idempotent child-result report delivered into the parent store — by a
411
+ * worker's outbox relay (the push path) or synthesized by the flow sweeper
412
+ * from child-store state (the reconcile path). Both may deliver the same
413
+ * report; the dependency row's state dedups them.
414
+ *
415
+ * @since 0.6.0
416
+ */
417
+ export interface FlowChildReport {
418
+ readonly flowId: JobId
419
+ readonly childKey: string
420
+ readonly outcome: "completed" | "failed" | "cancelled"
421
+ /** The child's schema-encoded exit; undefined for store-side failures. */
422
+ readonly exit: unknown
423
+ /** Present when the child was failed store-side (no exit exists). */
424
+ readonly failedReason: string | undefined
425
+ }
426
+
427
+ /**
428
+ * One undelivered child-result report in a CHILD store's outbox.
429
+ *
430
+ * The outbox invariant every driver must uphold: whenever a store operation
431
+ * moves a job carrying a `parent` envelope INTO a terminal state — a
432
+ * `Complete`/`Fail`/`Cancelled` ack, stall exhaustion, a direct `cancel` of
433
+ * a waiting/delayed child, a cancel honoured during release or stall
434
+ * recovery, or a fail-fast settle of a NESTED flow parent (its terminal
435
+ * transition happens store-side, with no ack) — the same atomic operation
436
+ * appends the corresponding report here. The worker's relay then drains
437
+ * the outbox into the parent store in batches and deletes what it
438
+ * delivered. Because dependency rows dedup redelivery, the relay needs no
439
+ * leases: crash anywhere and the entries are simply delivered again.
440
+ *
441
+ * `remove` appends nothing (an operator override), and a `FanOut` ack
442
+ * appends nothing (`waiting-children` is not terminal).
443
+ *
444
+ * @since 0.6.0
445
+ */
446
+ export interface OutboxEntry {
447
+ /** Store-assigned, opaque; pass back to `deleteOutbox` verbatim. */
448
+ readonly id: string
449
+ readonly flowName: string
450
+ /** The parent store's context-key string, for relay routing. */
451
+ readonly parentStoreKey: string
452
+ readonly report: FlowChildReport
453
+ }
454
+
455
+ /**
456
+ * The flow sweeper's work list, scoped by parent state so settled flows
457
+ * never re-drive work:
458
+ *
459
+ * - `reconcile`: for parents still in `waiting-children`, dependency rows
460
+ * `pending` longer than the caller's threshold, with their stored specs —
461
+ * the sweeper checks the child's store and either enqueues the child
462
+ * (missing) or synthesizes its report (terminal).
463
+ * - `cascade`: rows marked `cancelled` by a settle but not yet delivered as
464
+ * real cancels into their child store (any parent state).
465
+ *
466
+ * @since 0.6.0
467
+ */
468
+ export interface FlowSweepWork {
469
+ readonly reconcile: ReadonlyArray<{
470
+ readonly flowId: JobId
471
+ readonly children: ReadonlyArray<FlowChildSpec>
472
+ }>
473
+ readonly cascade: ReadonlyArray<{
474
+ readonly flowId: JobId
475
+ readonly children: ReadonlyArray<{
476
+ readonly childKey: string
477
+ readonly storeKey: string
478
+ readonly childJobId: JobId
479
+ }>
480
+ }>
481
+ }
482
+
307
483
  /**
308
484
  * @since 0.1.0
309
485
  */
@@ -327,6 +503,8 @@ export interface EnqueueRequest {
327
503
  readonly dedupe: DedupePolicy | undefined
328
504
  /** The producer's span context, for cross-process trace propagation. */
329
505
  readonly trace: TraceContext | undefined
506
+ /** Flow-parent link, set only by the flow runtime (opaque to the store). */
507
+ readonly parent: ParentEnvelope | undefined
330
508
  readonly delayMs: number
331
509
  }
332
510
 
@@ -389,7 +567,18 @@ export type ClaimResult =
389
567
  * attempts accounting) is computed by the worker; the store only applies it.
390
568
  *
391
569
  * Every outcome appends an `AttemptRecord` to the job's ledger (`Complete` →
392
- * completed, `Retry` → retried, `Fail` → failed).
570
+ * completed, `Retry` → retried, `Fail` → failed, `FanOut` → fanned-out).
571
+ *
572
+ * `FanOut` (flow parents only) atomically: persists `FlowState` (phase
573
+ * marker + policy + `pending = children.length`), inserts one `pending`
574
+ * dependency row per child (spec stored, so crash recovery needs only the
575
+ * parent store), and parks the parent in `waiting-children` — or, for an
576
+ * empty spec, settles it straight to `waiting`. It does NOT consume an
577
+ * attempt (a phase transition is not a completed run), and it does NOT
578
+ * enqueue the children (the flow runtime and sweeper own that). A parent
579
+ * whose `flow` is already present keeps its persisted manifest untouched —
580
+ * the new children are ignored and the state transition follows the existing
581
+ * `pending` count, so a double fan-out cannot duplicate children.
393
582
  *
394
583
  * @since 0.1.0
395
584
  */
@@ -398,11 +587,25 @@ export type AckOutcome =
398
587
  | { readonly _tag: "Retry"; readonly delayMs: number; readonly exit: unknown }
399
588
  | { readonly _tag: "Fail"; readonly exit: unknown }
400
589
  | { readonly _tag: "Cancelled" }
590
+ | {
591
+ readonly _tag: "FanOut"
592
+ readonly failFast: boolean
593
+ readonly children: ReadonlyArray<FlowChildSpec>
594
+ }
401
595
 
402
596
  /**
403
- * Filters and pagination for `list`. Results are ordered newest-first
404
- * (`enqueuedAt` desc, then id desc); pass the returned `cursor` back to get
405
- * the next page.
597
+ * Filters, ordering, and pagination for `list`. The default order is
598
+ * newest-first (`enqueuedAt` desc, then id desc); pass the returned
599
+ * `cursor` back — with the SAME options that produced it — to get the next
600
+ * page.
601
+ *
602
+ * Ordering is not free on every storage layout, so the contract defines a
603
+ * REQUIRED (filter, orderBy) surface every driver serves
604
+ * (conformance-pinned): `enqueuedAt` with any filters; `runAt` for
605
+ * `states: ["delayed"]` within a `queue`; `finishedAt` for terminal
606
+ * `states`, with or without `name`/`queue`. Beyond that surface a driver
607
+ * either serves the query or dies with `ListOrderUnsupportedError` — never
608
+ * a silent full scan. Memory and Postgres serve every combination.
406
609
  *
407
610
  * @since 0.1.0
408
611
  */
@@ -412,11 +615,37 @@ export interface ListOptions {
412
615
  readonly states?: ReadonlyArray<JobState> | undefined
413
616
  /** Every entry must match the record's metadata exactly (AND semantics). */
414
617
  readonly metadata?: Readonly<Record<string, string>> | undefined
618
+ /**
619
+ * The field to order by (default `enqueuedAt`). Jobs missing the field
620
+ * (`finishedAt` on non-terminal rows) sort as 0.
621
+ *
622
+ * @since 0.7.0
623
+ */
624
+ readonly orderBy?: "enqueuedAt" | "runAt" | "finishedAt" | undefined
625
+ /**
626
+ * Direction (default `desc`). The id tiebreak follows the direction.
627
+ *
628
+ * @since 0.7.0
629
+ */
630
+ readonly order?: "asc" | "desc" | undefined
415
631
  readonly cursor?: string | undefined
416
632
  /** Page size; default 50. */
417
633
  readonly limit?: number | undefined
418
634
  }
419
635
 
636
+ /**
637
+ * A driver received a (filter, `orderBy`) combination outside the surface
638
+ * it can serve without a full scan (see `ListOptions`). Delivered as a
639
+ * defect: the query contradicts the driver's documented matrix, which is a
640
+ * programming mistake, not a runtime condition to recover from.
641
+ *
642
+ * @since 0.7.0
643
+ */
644
+ export class ListOrderUnsupportedError extends Data.TaggedError("ListOrderUnsupportedError")<{
645
+ readonly orderBy: string
646
+ readonly message: string
647
+ }> {}
648
+
420
649
  /**
421
650
  * @since 0.1.0
422
651
  */
@@ -663,7 +892,8 @@ export interface Service {
663
892
  * Acknowledge a claimed job. Verifies the lock token, releases the lock,
664
893
  * increments `attemptsMade`, appends to the attempts ledger, then applies
665
894
  * the outcome (`Complete`/`Fail` are terminal and apply the record's `keep`
666
- * policy; `Retry` re-queues after `delayMs`).
895
+ * policy; `Retry` re-queues after `delayMs`). Terminal outcomes for jobs
896
+ * carrying a `parent` envelope also append an `OutboxEntry` atomically.
667
897
  */
668
898
  readonly ack: (
669
899
  id: JobId,
@@ -693,7 +923,8 @@ export interface Service {
693
923
  * Sweep active jobs whose lock has expired. Each recovered job gets
694
924
  * `stalledCount + 1` and a `stalled` ledger entry; jobs exceeding
695
925
  * `maxStalledCount` are failed (`failed: true` in the result), the rest
696
- * return to `waiting`.
926
+ * return to `waiting`. Stall-exhausting a job that carries a `parent`
927
+ * envelope appends its failed report to the outbox atomically.
697
928
  */
698
929
  readonly recoverStalled: (options: {
699
930
  readonly maxStalledCount: number
@@ -730,7 +961,10 @@ export interface Service {
730
961
  /**
731
962
  * Re-run a failed job: back to `waiting` with a fresh attempt budget
732
963
  * (`attemptsMade`/`stalledCount` reset, terminal fields cleared). The
733
- * attempts ledger is preserved and keeps numbering monotonically.
964
+ * attempts ledger is preserved and keeps numbering monotonically. A flow
965
+ * parent's `flow` field and dependency rows survive: a retried fail-fast
966
+ * flow re-enters the `collect` phase with its recorded (mixed) results —
967
+ * it can never fan out twice.
734
968
  */
735
969
  readonly retry: (
736
970
  id: JobId
@@ -742,8 +976,13 @@ export interface Service {
742
976
  /**
743
977
  * Cancel a job. Waiting/delayed jobs become terminal (`cancelled`)
744
978
  * immediately; active jobs get `cancelRequested` set, and the owning
745
- * worker interrupts the handler on its next heartbeat. Terminal jobs fail
746
- * with `JobNotCancellableError`.
979
+ * worker interrupts the handler on its next heartbeat. A `waiting-children`
980
+ * flow parent settles to `cancelled` AND flips its remaining `pending`
981
+ * dependency rows to `cancelled` (not `cascaded`) in the same atomic op —
982
+ * the flow sweeper then delivers real cancels into the child stores, and
983
+ * late child reports find their row terminal and drop. Cancelling a job
984
+ * that itself carries a `parent` envelope appends its cancelled report to
985
+ * the outbox atomically. Terminal jobs fail with `JobNotCancellableError`.
747
986
  */
748
987
  readonly cancel: (
749
988
  id: JobId
@@ -838,11 +1077,137 @@ export interface Service {
838
1077
  nextRunAt: number
839
1078
  ) => Effect.Effect<void, JobStoreError>
840
1079
 
1080
+ /**
1081
+ * Record a batch of child outcomes on their dependency rows — idempotent
1082
+ * and atomic, results positional. Each report applies only when its row
1083
+ * is still `pending` (`applied: false` for duplicate, late, or unknown
1084
+ * reports); an applied report moves the child from the parent's `pending`
1085
+ * counter to its outcome counter and marks the row `cascaded` (the
1086
+ * outcome came from the child's store — no cancel needs delivering). A
1087
+ * batch may span flows.
1088
+ *
1089
+ * Per flow, all row updates apply BEFORE the settle decision, and the
1090
+ * parent settles at most once per batch (`parentSettled: true` on the
1091
+ * report that decided it): when `pending` hits zero, `waiting-children` →
1092
+ * `waiting` (runnable now, phase `collect`) — or on the first applied
1093
+ * `failed` report in batch order under the fail-fast policy, which
1094
+ * instead settles the parent terminally `failed` (store-side,
1095
+ * `failedReason` set, no exit — like stall exhaustion) and flips every
1096
+ * remaining `pending` row to `cancelled`/not-`cascaded` in the same
1097
+ * atomic op. Fail-fast wins the tie when one report triggers both rules.
1098
+ *
1099
+ * Lock ordering (drivers MUST follow it): dependency rows first, the
1100
+ * parent row second — reports, fail-fast marking, and cancel marking all
1101
+ * take locks in this order, so report-vs-settle cannot deadlock.
1102
+ *
1103
+ * Drivers may process very large batches in atomic sub-batches (the
1104
+ * Redis driver chunks at 500); the apply-all-before-settle rule then
1105
+ * holds per sub-batch. Worker relays never exceed one page (500), so
1106
+ * this only shows on direct store calls with larger batches.
1107
+ *
1108
+ * @since 0.6.0
1109
+ */
1110
+ readonly recordChildResults: (
1111
+ reports: ReadonlyArray<FlowChildReport>
1112
+ ) => Effect.Effect<
1113
+ ReadonlyArray<{ readonly applied: boolean; readonly parentSettled: boolean }>,
1114
+ JobStoreError
1115
+ >
1116
+
1117
+ /**
1118
+ * The oldest undelivered outbox entries, up to `limit` (see
1119
+ * `OutboxEntry` for the append invariant). The relay peeks, delivers via
1120
+ * `recordChildResults` on the parent store, then deletes — redelivery
1121
+ * after a crash is safe because dependency rows dedup.
1122
+ *
1123
+ * `after` pages past a previously-returned entry id (exclusive), whether
1124
+ * or not that entry still exists — the relay walks the whole outbox this
1125
+ * way, so entries it cannot route (their parent store is not provided
1126
+ * here) never blockade the ones behind them. Anything other than an id
1127
+ * this store issued may be treated as unset.
1128
+ *
1129
+ * @since 0.6.0
1130
+ */
1131
+ readonly peekOutbox: (options: {
1132
+ readonly limit: number
1133
+ readonly after?: string | undefined
1134
+ }) => Effect.Effect<ReadonlyArray<OutboxEntry>, JobStoreError>
1135
+
1136
+ /**
1137
+ * Delete delivered outbox entries by id. Idempotent; unknown ids are
1138
+ * ignored.
1139
+ *
1140
+ * @since 0.6.0
1141
+ */
1142
+ readonly deleteOutbox: (
1143
+ ids: ReadonlyArray<string>
1144
+ ) => Effect.Effect<void, JobStoreError>
1145
+
1146
+ /**
1147
+ * A flow's dependency rows, ordered by child key; feeds `collect` and
1148
+ * dashboards. Pass the returned `cursor` back for the next page.
1149
+ *
1150
+ * @since 0.6.0
1151
+ */
1152
+ readonly listChildResults: (
1153
+ flowId: JobId,
1154
+ options?: {
1155
+ readonly cursor?: string | undefined
1156
+ /** Page size; default 1000. */
1157
+ readonly limit?: number | undefined
1158
+ } | undefined
1159
+ ) => Effect.Effect<
1160
+ {
1161
+ readonly items: ReadonlyArray<FlowChildRecord>
1162
+ readonly cursor: string | undefined
1163
+ },
1164
+ JobStoreError
1165
+ >
1166
+
1167
+ /**
1168
+ * The flow sweeper's work list (see `FlowSweepWork`). `pendingAgeMs`
1169
+ * scopes reconciliation to rows whose eligibility timestamp is at least
1170
+ * this old (giving the push path time); `limit` bounds the rows returned
1171
+ * per class per sweep.
1172
+ *
1173
+ * Returning a row for reconciliation re-arms its eligibility timestamp
1174
+ * (it is not returned again until another `pendingAgeMs` elapses), so a
1175
+ * full page ROTATES across sweeps: healthy in-flight children and rows
1176
+ * this sweeper cannot act on never pin the head of the page and starve
1177
+ * the rows behind them.
1178
+ *
1179
+ * @since 0.6.0
1180
+ */
1181
+ readonly flowSweepWork: (options: {
1182
+ readonly pendingAgeMs: number
1183
+ readonly limit?: number | undefined
1184
+ }) => Effect.Effect<FlowSweepWork, JobStoreError>
1185
+
1186
+ /**
1187
+ * Mark dependency rows as `cascaded` after their cancels were delivered
1188
+ * into (or confirmed unnecessary by) the child's store. Idempotent.
1189
+ *
1190
+ * @since 0.6.0
1191
+ */
1192
+ readonly markChildrenCascaded: (
1193
+ flowId: JobId,
1194
+ childKeys: ReadonlyArray<string>
1195
+ ) => Effect.Effect<void, JobStoreError>
1196
+
841
1197
  readonly counts: (
842
1198
  queue?: QueueName
843
1199
  ) => Effect.Effect<Record<JobState, number>, JobStoreError>
844
1200
 
845
- /** Remove a job (and its ledger). Refuses (returns false) when active. */
1201
+ /**
1202
+ * Remove a job (and its ledger; a flow parent's dependency rows go with
1203
+ * it). Refuses (returns false) when active or `waiting-children`.
1204
+ *
1205
+ * Note the retention asymmetry for flows: AUTOMATIC pruning (`keep`
1206
+ * policies, the `historyTtl` sweep) must skip a settled flow parent whose
1207
+ * rows still owe cascade cancels (`cancelled` and not `cascaded`) — those
1208
+ * rows are the only record that real cancels are still due in the child
1209
+ * stores. `remove` is the explicit operator override and deletes anyway.
1210
+ */
846
1211
  readonly remove: (id: JobId) => Effect.Effect<boolean, JobStoreError>
847
1212
  }
848
1213