effect-mq 0.4.2 → 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 (64) 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 +37 -6
  7. package/dist/Job.d.ts.map +1 -1
  8. package/dist/Job.js +17 -2
  9. package/dist/Job.js.map +1 -1
  10. package/dist/JobSchedules.d.ts +112 -0
  11. package/dist/JobSchedules.d.ts.map +1 -0
  12. package/dist/JobSchedules.js +106 -0
  13. package/dist/JobSchedules.js.map +1 -0
  14. package/dist/JobStore.d.ts +320 -10
  15. package/dist/JobStore.d.ts.map +1 -1
  16. package/dist/JobStore.js.map +1 -1
  17. package/dist/MemoryJobStore.d.ts.map +1 -1
  18. package/dist/MemoryJobStore.js +336 -8
  19. package/dist/MemoryJobStore.js.map +1 -1
  20. package/dist/Metrics.d.ts +31 -0
  21. package/dist/Metrics.d.ts.map +1 -1
  22. package/dist/Metrics.js +39 -0
  23. package/dist/Metrics.js.map +1 -1
  24. package/dist/Worker.d.ts +120 -11
  25. package/dist/Worker.d.ts.map +1 -1
  26. package/dist/Worker.js +452 -26
  27. package/dist/Worker.js.map +1 -1
  28. package/dist/drizzle-postgres/DrizzleJobStore.d.ts +19 -1
  29. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  30. package/dist/drizzle-postgres/DrizzleJobStore.js +662 -81
  31. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  32. package/dist/drizzle-postgres/schema.d.ts +310 -3
  33. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  34. package/dist/drizzle-postgres/schema.js +68 -1
  35. package/dist/drizzle-postgres/schema.js.map +1 -1
  36. package/dist/index.d.ts +14 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +14 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  41. package/dist/redis/RedisJobStore.js +221 -19
  42. package/dist/redis/RedisJobStore.js.map +1 -1
  43. package/dist/redis/scripts.d.ts +118 -11
  44. package/dist/redis/scripts.d.ts.map +1 -1
  45. package/dist/redis/scripts.js +497 -28
  46. package/dist/redis/scripts.js.map +1 -1
  47. package/dist/testing/conformance.d.ts +6 -0
  48. package/dist/testing/conformance.d.ts.map +1 -1
  49. package/dist/testing/conformance.js +765 -1
  50. package/dist/testing/conformance.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/Flow.ts +778 -0
  53. package/src/Job.ts +42 -11
  54. package/src/JobSchedules.ts +223 -0
  55. package/src/JobStore.ts +347 -9
  56. package/src/MemoryJobStore.ts +372 -8
  57. package/src/Metrics.ts +43 -0
  58. package/src/Worker.ts +726 -37
  59. package/src/drizzle-postgres/DrizzleJobStore.ts +827 -82
  60. package/src/drizzle-postgres/schema.ts +94 -0
  61. package/src/index.ts +16 -0
  62. package/src/redis/RedisJobStore.ts +291 -8
  63. package/src/redis/scripts.ts +529 -26
  64. package/src/testing/conformance.ts +989 -1
@@ -36,6 +36,7 @@ import { sql } from "drizzle-orm"
36
36
  import {
37
37
  type AnyPgColumnBuilder,
38
38
  bigint,
39
+ bigserial,
39
40
  boolean,
40
41
  index,
41
42
  integer,
@@ -56,6 +57,10 @@ type BackoffPolicy = JobStore.BackoffPolicy
56
57
  type KeepPolicy = JobStore.KeepPolicy
57
58
  type AttemptOutcome = JobStore.AttemptRecord["outcome"]
58
59
  type TraceContext = JobStore.TraceContext
60
+ type ParentEnvelope = JobStore.ParentEnvelope
61
+ type EnqueueRequest = JobStore.EnqueueRequest
62
+ type FlowChildStatus = JobStore.FlowChildRecord["status"]
63
+ type FlowChildReport = JobStore.FlowChildReport
59
64
 
60
65
  /**
61
66
  * Table-factory options: `extraConfig` receives the table's columns (exactly
@@ -89,6 +94,14 @@ const jobsColumns = <JobName extends string, Queue extends string>() => ({
89
94
  cancelRequested: boolean("cancel_requested").notNull().default(false),
90
95
  dedupeKey: text("dedupe_key"),
91
96
  trace: jsonb("trace").$type<TraceContext>(),
97
+ /** Flow-parent link on child jobs (opaque envelope, like `trace`). */
98
+ parent: jsonb("parent").$type<ParentEnvelope>(),
99
+ /** Flow bookkeeping: NULL together until a FanOut ack lands the manifest. */
100
+ flowFailFast: boolean("flow_fail_fast"),
101
+ flowPending: integer("flow_pending"),
102
+ flowCompleted: integer("flow_completed"),
103
+ flowFailed: integer("flow_failed"),
104
+ flowCancelled: integer("flow_cancelled"),
92
105
  runAt: timestamp("run_at", { withTimezone: true, mode: "date" }).notNull(),
93
106
  enqueuedAt: timestamp("enqueued_at", { withTimezone: true, mode: "date" }).notNull(),
94
107
  processedAt: timestamp("processed_at", { withTimezone: true, mode: "date" }),
@@ -201,6 +214,8 @@ const schedulesColumns = <JobName extends string, Queue extends string>() => ({
201
214
  backoff: jsonb("backoff").$type<BackoffPolicy>(),
202
215
  keep: jsonb("keep").$type<KeepPolicy>(),
203
216
  timeoutMs: bigint("timeout_ms", { mode: "number" }),
217
+ /** Ownership label for declarative reconciliation; NULL = never pruned. */
218
+ group: text("group_name"),
204
219
  nextRunAt: timestamp("next_run_at", { withTimezone: true, mode: "date" }).notNull()
205
220
  })
206
221
 
@@ -243,6 +258,75 @@ export const mqDedupe = <JobName extends string = string>(
243
258
  ...options?.extraConfig?.(table) ?? []
244
259
  ])
245
260
 
261
+ const flowChildrenColumns = () => ({
262
+ /** The parent (flow) job's id in this store. */
263
+ flowId: text("flow_id").notNull().$type<JobId>(),
264
+ /** Unique within the flow (the idempotency mechanism). */
265
+ childKey: text("child_key").notNull(),
266
+ /** The child job's name (projection of `spec.name` for dashboards). */
267
+ name: text("name").notNull(),
268
+ /** The CHILD store's context-key string (children may live elsewhere). */
269
+ storeKey: text("store_key").notNull(),
270
+ /** The FULL `EnqueueRequest`, so the sweeper can re-enqueue from storage. */
271
+ spec: jsonb("spec").notNull().$type<EnqueueRequest>(),
272
+ status: text("status").notNull().$type<FlowChildStatus>(),
273
+ /** The child's schema-encoded exit; NULL for store-side failures. */
274
+ exit: jsonb("exit"),
275
+ failedReason: text("failed_reason"),
276
+ /** True once no cancel needs delivering into the child's store. */
277
+ cascaded: boolean("cascaded").notNull(),
278
+ pendingSince: timestamp("pending_since", { withTimezone: true, mode: "date" }).notNull()
279
+ })
280
+
281
+ /**
282
+ * Flow dependency rows (one per fan-out child; see `JobStore.FlowChildRecord`).
283
+ * Lives next to the PARENT store's jobs table.
284
+ *
285
+ * @since 0.6.0
286
+ */
287
+ export const mqFlowChildren = (
288
+ tableName = "effect_mq_flow_children",
289
+ options?: MqTableOptions<ReturnType<typeof flowChildrenColumns>>
290
+ ) =>
291
+ pgTable(tableName, flowChildrenColumns(), (table) => [
292
+ primaryKey({ columns: [table.flowId, table.childKey] }),
293
+ // flowSweepWork reconcile: pending rows older than the threshold.
294
+ index(`${tableName}_pending_idx`)
295
+ .on(table.pendingSince)
296
+ .where(sql`${table.status} = 'pending'`),
297
+ // flowSweepWork cascade: cancelled rows not yet delivered.
298
+ index(`${tableName}_cascade_idx`)
299
+ .on(table.flowId)
300
+ .where(sql`${table.status} = 'cancelled' AND NOT ${table.cascaded}`),
301
+ ...options?.extraConfig?.(table) ?? []
302
+ ])
303
+
304
+ const flowOutboxColumns = () => ({
305
+ /** Store-assigned, oldest-first; exposed to callers as an opaque string. */
306
+ id: bigserial("id", { mode: "number" }).primaryKey(),
307
+ /** The flow definition's name, for relay routing. */
308
+ flowName: text("flow_name").notNull(),
309
+ /** The PARENT store's context-key string, for relay routing. */
310
+ parentStoreKey: text("parent_store_key").notNull(),
311
+ /** The full `FlowChildReport` to deliver into the parent store. */
312
+ report: jsonb("report").notNull().$type<FlowChildReport>()
313
+ })
314
+
315
+ /**
316
+ * Undelivered child-result reports (one per terminal transition of an
317
+ * envelope-carrying job; see `JobStore.OutboxEntry`). Lives next to the
318
+ * CHILD store's jobs table.
319
+ *
320
+ * @since 0.6.0
321
+ */
322
+ export const mqFlowOutbox = (
323
+ tableName = "effect_mq_flow_outbox",
324
+ options?: MqTableOptions<ReturnType<typeof flowOutboxColumns>>
325
+ ) =>
326
+ pgTable(tableName, flowOutboxColumns(), (table) => [
327
+ ...options?.extraConfig?.(table) ?? []
328
+ ])
329
+
246
330
  const queueControlColumns = <Queue extends string>() => ({
247
331
  queue: text("queue").primaryKey().$type<Queue>(),
248
332
  paused: boolean("paused").notNull().default(false)
@@ -285,3 +369,13 @@ export type MqQueueControlTable = ReturnType<typeof mqQueueControl<any>>
285
369
  * @since 0.3.0
286
370
  */
287
371
  export type MqDedupeTable = ReturnType<typeof mqDedupe<any>>
372
+
373
+ /**
374
+ * @since 0.6.0
375
+ */
376
+ export type MqFlowChildrenTable = ReturnType<typeof mqFlowChildren>
377
+
378
+ /**
379
+ * @since 0.6.0
380
+ */
381
+ export type MqFlowOutboxTable = ReturnType<typeof mqFlowOutbox>
package/src/index.ts CHANGED
@@ -4,6 +4,14 @@
4
4
  * @since 0.1.0
5
5
  */
6
6
 
7
+ /**
8
+ * Cross-store parent-child flows: `Flow.make`, `Flow.children`, the
9
+ * two-phase `fanOut`/`collect` handler.
10
+ *
11
+ * @since 0.6.0
12
+ */
13
+ export * as Flow from "./Flow.ts"
14
+
7
15
  /**
8
16
  * Schema-first job definitions: `Job.make`, `enqueue`, `toLayer`.
9
17
  *
@@ -11,6 +19,14 @@
11
19
  */
12
20
  export * as Job from "./Job.ts"
13
21
 
22
+ /**
23
+ * Declarative schedule reconciliation: declare a service's full schedule
24
+ * set as a layer; startup upserts it and detects deletion drift.
25
+ *
26
+ * @since 0.5.0
27
+ */
28
+ export * as JobSchedules from "./JobSchedules.ts"
29
+
14
30
  /**
15
31
  * The storage seam: the `JobStore` service, job records and typed errors.
16
32
  *
@@ -85,6 +85,18 @@ const toRecord = (hash: ReadonlyMap<string, string>): JobStore.JobRecord => ({
85
85
  cancelRequested: hash.get("cancelRequested") === "1",
86
86
  dedupeKey: optionalString(hash.get("dedupeKey")),
87
87
  trace: optionalJson<JobStore.TraceContext>(hash.get("trace")),
88
+ parent: optionalJson<JobStore.ParentEnvelope>(hash.get("parent")),
89
+ // Manifest presence IS the phase marker: hashes written before flows (or
90
+ // parents that never fanned out) simply lack the flow fields.
91
+ flow: optionalString(hash.get("flowPending")) === undefined
92
+ ? undefined
93
+ : {
94
+ failFast: hash.get("flowFailFast") === "1",
95
+ pending: Number(hash.get("flowPending")),
96
+ completed: Number(hash.get("flowCompleted") ?? 0),
97
+ failed: Number(hash.get("flowFailed") ?? 0),
98
+ cancelled: Number(hash.get("flowCancelled") ?? 0)
99
+ },
88
100
  runAt: Number(hash.get("runAt") ?? 0),
89
101
  enqueuedAt: Number(hash.get("enqueuedAt") ?? 0),
90
102
  processedAt: optionalNumber(hash.get("processedAt")),
@@ -107,6 +119,7 @@ const toSchedule = (hash: ReadonlyMap<string, string>): JobStore.ScheduleRecord
107
119
  backoff: optionalJson<JobStore.BackoffPolicy>(hash.get("backoff")),
108
120
  keep: optionalJson<JobStore.KeepPolicy>(hash.get("keep")),
109
121
  timeoutMs: optionalNumber(hash.get("timeoutMs")),
122
+ group: optionalString(hash.get("group")),
110
123
  nextRunAt: Number(hash.get("nextRunAt") ?? 0)
111
124
  })
112
125
 
@@ -114,15 +127,68 @@ const toSchedule = (hash: ReadonlyMap<string, string>): JobStore.ScheduleRecord
114
127
  const asArray = <A>(value: ReadonlyArray<A> | Record<string, never>): ReadonlyArray<A> =>
115
128
  Array.isArray(value) ? value : []
116
129
 
130
+ /**
131
+ * Decode one outbox zset member: `<seq>\0<json>` where the json carries the
132
+ * verbatim parent envelope plus the terminal outcome. The full member string
133
+ * is the opaque entry id (deleteOutbox is then a plain ZREM).
134
+ */
135
+ const toOutboxEntry = (member: string): JobStore.OutboxEntry => {
136
+ const sep = member.indexOf("\u0000")
137
+ const body: {
138
+ parent: JobStore.ParentEnvelope
139
+ outcome: JobStore.FlowChildReport["outcome"]
140
+ exit?: unknown
141
+ failedReason?: string
142
+ } = JSON.parse(member.slice(sep + 1))
143
+ return {
144
+ id: member,
145
+ flowName: body.parent.flowName,
146
+ parentStoreKey: body.parent.parentStoreKey,
147
+ report: {
148
+ flowId: body.parent.flowId,
149
+ childKey: body.parent.childKey,
150
+ outcome: body.outcome,
151
+ // The exit key is omitted entirely when absent (ledger convention),
152
+ // so a legitimate encoded null exit survives the round trip.
153
+ exit: Object.hasOwn(body, "exit") ? body.exit : undefined,
154
+ failedReason: body.failedReason
155
+ }
156
+ }
157
+ }
158
+
117
159
  const JOB_STATES: ReadonlyArray<JobStore.JobState> = [
118
160
  "waiting",
119
161
  "delayed",
120
162
  "active",
163
+ "waiting-children",
121
164
  "completed",
122
165
  "failed",
123
166
  "cancelled"
124
167
  ]
125
168
 
169
+ /**
170
+ * Fold one positional dependency-row tuple into a `FlowChildRecord`. The
171
+ * order must stay in lockstep with the HMGET field list in the
172
+ * `listChildResults` script:
173
+ * childKey, storeKey, childJobId, name, status, exit, failedReason, cascaded.
174
+ */
175
+ const toChildRecord = (
176
+ flowId: JobStore.JobId,
177
+ row: ReadonlyArray<string>
178
+ ): JobStore.FlowChildRecord => ({
179
+ flowId,
180
+ childKey: row[0] ?? "",
181
+ storeKey: row[1] ?? "",
182
+ childJobId: JobStore.JobId(row[2] ?? ""),
183
+ name: row[3] ?? "",
184
+ // SAFETY: the status field is only ever written with FlowChildRecord
185
+ // status members ("pending" at insert, a report/settle outcome after).
186
+ status: (row[4] ?? "pending") as JobStore.FlowChildRecord["status"],
187
+ exit: optionalJson(row[5]),
188
+ failedReason: optionalString(row[6]),
189
+ cascaded: row[7] === "1"
190
+ })
191
+
126
192
  /**
127
193
  * Build a `RedisJobStore` service. Needs the `Redis` service and a `Scope`
128
194
  * (the wake-up subscription and the optional history sweeper live in it).
@@ -159,6 +225,11 @@ export const make = (
159
225
  const evalEnqueueMany = redis.eval(scripts.enqueueMany)
160
226
  const evalSweepState = redis.eval(scripts.sweepState)
161
227
  const evalSweepDedupes = redis.eval(scripts.sweepDedupes)
228
+ const evalFanOut = redis.eval(scripts.fanOut)
229
+ const evalRecordChildResults = redis.eval(scripts.recordChildResults)
230
+ const evalListChildResults = redis.eval(scripts.listChildResults)
231
+ const evalFlowSweepWork = redis.eval(scripts.flowSweepWork)
232
+ const evalMarkChildrenCascaded = redis.eval(scripts.markChildrenCascaded)
162
233
 
163
234
  // Wake protocol: a queue-filtered waiter registry (same-process wake-ups
164
235
  // never depend on the pub/sub round trip), with the channel carrying
@@ -281,9 +352,71 @@ export const make = (
281
352
  request.dedupe?.ttlMs === undefined ? "" : String(request.dedupe.ttlMs),
282
353
  request.dedupe?.extend === true ? "1" : "0",
283
354
  request.dedupe?.replace === true ? "1" : "0",
284
- request.trace === undefined ? "" : JSON.stringify(request.trace)
355
+ request.trace === undefined ? "" : JSON.stringify(request.trace),
356
+ request.parent === undefined ? "" : JSON.stringify(request.parent)
285
357
  )
286
358
 
359
+ // The FanOut ack. Large manifests chunk their dependency rows across
360
+ // several lock-token-guarded script calls (ARGV strides, like
361
+ // enqueueMany); only the FINAL chunk writes the manifest and flips the
362
+ // state, so a crash mid-staging leaves the job active and recoverable —
363
+ // the next attempt's first chunk clears the orphaned staged rows.
364
+ const fanOutAck = (
365
+ id: JobStore.JobId,
366
+ token: string,
367
+ failFast: boolean,
368
+ children: ReadonlyArray<JobStore.FlowChildSpec>
369
+ ) =>
370
+ Effect.gen(function*() {
371
+ if (children.some((child) => child.request.id === undefined)) {
372
+ // Validate BEFORE any script call, so a bad spec cannot leave the
373
+ // job half-acked (rows staged, ledger written, still active).
374
+ return yield* new JobStore.JobStoreError({
375
+ message: "FanOut child specs require an explicit request.id"
376
+ })
377
+ }
378
+ const now = yield* Clock.currentTimeMillis
379
+ const chunks: Array<ReadonlyArray<JobStore.FlowChildSpec>> = []
380
+ for (let start = 0; start < children.length; start += 500) {
381
+ chunks.push(children.slice(start, start + 500))
382
+ }
383
+ // An empty manifest still needs the final (state-flipping) call.
384
+ if (chunks.length === 0) chunks.push([])
385
+ for (let i = 0; i < chunks.length; i++) {
386
+ const chunk = chunks[i]
387
+ if (chunk === undefined) continue
388
+ const items: Array<string> = []
389
+ for (const child of chunk) {
390
+ items.push(
391
+ child.childKey,
392
+ child.storeKey,
393
+ child.request.id ?? "",
394
+ child.request.name,
395
+ JSON.stringify(child.request)
396
+ )
397
+ }
398
+ const reply: { error?: string; wake?: boolean; queue?: string } = JSON.parse(
399
+ yield* evalFanOut(
400
+ prefix,
401
+ id,
402
+ token,
403
+ i === chunks.length - 1 ? "1" : "0",
404
+ i === 0 ? "1" : "0",
405
+ failFast ? "1" : "0",
406
+ children.length,
407
+ now,
408
+ chunk.length,
409
+ items
410
+ ).pipe(Effect.mapError(storeError("ack failed")))
411
+ )
412
+ if (reply.error === "notfound") return yield* new JobStore.JobNotFoundError({ jobId: id })
413
+ if (reply.error === "locklost") return yield* new JobStore.LockLostError({ jobId: id })
414
+ if (reply.wake === true) {
415
+ yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : undefined)
416
+ }
417
+ }
418
+ })
419
+
287
420
  // Shared by cancel and cancelByDedupe.
288
421
  const cancelJob = (id: JobStore.JobId) =>
289
422
  Effect.gen(function*() {
@@ -379,6 +512,7 @@ export const make = (
379
512
  request.keep === undefined ? "" : JSON.stringify(request.keep),
380
513
  request.timeoutMs === undefined ? "" : String(request.timeoutMs),
381
514
  request.trace === undefined ? "" : JSON.stringify(request.trace),
515
+ request.parent === undefined ? "" : JSON.stringify(request.parent),
382
516
  String(Math.max(0, request.delayMs))
383
517
  )
384
518
  }
@@ -503,15 +637,20 @@ export const make = (
503
637
  })
504
638
  }).pipe(Effect.mapError(storeError("claim failed"))),
505
639
 
506
- ack: (id, token, outcome) =>
507
- Effect.gen(function*() {
640
+ ack: (id, token, outcome) => {
641
+ if (outcome._tag === "FanOut") {
642
+ return fanOutAck(id, token, outcome.failFast, outcome.children)
643
+ }
644
+ // Narrowed binding: the closure below must see the FanOut-free union.
645
+ const settled = outcome
646
+ return Effect.gen(function*() {
508
647
  const now = yield* Clock.currentTimeMillis
509
- const exitJson = outcome._tag === "Cancelled" || outcome.exit === undefined
648
+ const exitJson = settled._tag === "Cancelled" || settled.exit === undefined
510
649
  ? ""
511
- : JSON.stringify(outcome.exit)
512
- const delayMs = outcome._tag === "Retry" ? Math.max(0, outcome.delayMs) : 0
650
+ : JSON.stringify(settled.exit)
651
+ const delayMs = settled._tag === "Retry" ? Math.max(0, settled.delayMs) : 0
513
652
  const reply: { error?: string; wake?: boolean; queue?: string } = JSON.parse(
514
- yield* evalAck(prefix, id, token, outcome._tag, exitJson, delayMs, now).pipe(
653
+ yield* evalAck(prefix, id, token, settled._tag, exitJson, delayMs, now).pipe(
515
654
  Effect.mapError(storeError("ack failed"))
516
655
  )
517
656
  )
@@ -520,7 +659,8 @@ export const make = (
520
659
  if (reply.wake === true) {
521
660
  yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : undefined)
522
661
  }
523
- }),
662
+ })
663
+ },
524
664
 
525
665
  release: (id, token) =>
526
666
  Effect.gen(function*() {
@@ -746,6 +886,7 @@ export const make = (
746
886
  schedule.backoff === undefined ? "" : JSON.stringify(schedule.backoff),
747
887
  schedule.keep === undefined ? "" : JSON.stringify(schedule.keep),
748
888
  schedule.timeoutMs === undefined ? "" : String(schedule.timeoutMs),
889
+ schedule.group ?? "",
749
890
  schedule.nextRunAt
750
891
  ).pipe(
751
892
  Effect.mapError(storeError("upsertSchedule failed")),
@@ -798,6 +939,7 @@ export const make = (
798
939
  request.keep === undefined ? "" : JSON.stringify(request.keep),
799
940
  request.timeoutMs === undefined ? "" : String(request.timeoutMs),
800
941
  request.trace === undefined ? "" : JSON.stringify(request.trace),
942
+ request.parent === undefined ? "" : JSON.stringify(request.parent),
801
943
  Math.max(0, request.delayMs),
802
944
  now
803
945
  )) === "1"
@@ -815,6 +957,147 @@ export const make = (
815
957
  evalAdvanceSchedule(prefix, key, expectedRunAt, nextRunAt).pipe(
816
958
  Effect.mapError(storeError("advanceSchedule failed")),
817
959
  Effect.asVoid
960
+ ),
961
+
962
+ recordChildResults: (reports) =>
963
+ Effect.gen(function*() {
964
+ if (reports.length === 0) {
965
+ const none: ReadonlyArray<{ applied: boolean; parentSettled: boolean }> = []
966
+ return none
967
+ }
968
+ const now = yield* Clock.currentTimeMillis
969
+ const all: Array<{ applied: boolean; parentSettled: boolean }> = []
970
+ // One atomic batch per chunk of 500 (ARGV headroom); the
971
+ // contract's per-batch settle semantics then apply per chunk.
972
+ for (let start = 0; start < reports.length; start += 500) {
973
+ const chunk = reports.slice(start, start + 500)
974
+ const items: Array<string> = []
975
+ for (const report of chunk) {
976
+ items.push(
977
+ report.flowId,
978
+ report.childKey,
979
+ report.outcome,
980
+ report.exit === undefined ? "" : JSON.stringify(report.exit),
981
+ report.failedReason ?? ""
982
+ )
983
+ }
984
+ const reply: {
985
+ results: ReadonlyArray<{ applied: boolean; parentSettled: boolean }>
986
+ wakes: ReadonlyArray<string> | Record<string, never>
987
+ } = JSON.parse(yield* evalRecordChildResults(prefix, now, chunk.length, items))
988
+ for (const queue of asArray(reply.wakes)) {
989
+ // A parent settled to runnable collect: wake its queue.
990
+ yield* wakeUp(JobStore.QueueName(queue))
991
+ }
992
+ for (const result of reply.results) {
993
+ all.push(result)
994
+ }
995
+ }
996
+ return all
997
+ }).pipe(Effect.mapError(storeError("recordChildResults failed"))),
998
+
999
+ peekOutbox: (peekOptions) =>
1000
+ Effect.gen(function*() {
1001
+ const limit = Math.floor(peekOptions.limit)
1002
+ if (limit <= 0) {
1003
+ const none: ReadonlyArray<JobStore.OutboxEntry> = []
1004
+ return none
1005
+ }
1006
+ // The `after` cursor compares by the id's embedded score (the seq
1007
+ // prefix before the NUL), so the walk moves past the named entry
1008
+ // whether or not it still exists. Unparseable input reads as unset.
1009
+ let afterSeq: number | undefined = undefined
1010
+ if (peekOptions.after !== undefined) {
1011
+ const nul = peekOptions.after.indexOf("\u0000")
1012
+ const seq = nul > 0 ? Number(peekOptions.after.slice(0, nul)) : Number.NaN
1013
+ if (Number.isFinite(seq)) afterSeq = seq
1014
+ }
1015
+ const raw = afterSeq === undefined
1016
+ ? yield* redis.send("ZRANGE", `${prefix}:flowoutbox`, "0", String(limit - 1))
1017
+ : yield* redis.send(
1018
+ "ZRANGEBYSCORE",
1019
+ `${prefix}:flowoutbox`,
1020
+ `(${afterSeq}`,
1021
+ "+inf",
1022
+ "LIMIT",
1023
+ "0",
1024
+ String(limit)
1025
+ )
1026
+ // SAFETY: ZRANGE/ZRANGEBYSCORE always reply with arrays of bulk
1027
+ // strings.
1028
+ const members = raw as ReadonlyArray<string>
1029
+ return members.map(toOutboxEntry)
1030
+ }).pipe(Effect.mapError(storeError("peekOutbox failed"))),
1031
+
1032
+ deleteOutbox: (ids) =>
1033
+ Effect.gen(function*() {
1034
+ // Chunked ZREMs keep the variadic argument count bounded; each
1035
+ // chunk is idempotent, so a partial failure just redelivers.
1036
+ for (let start = 0; start < ids.length; start += 500) {
1037
+ yield* redis.send("ZREM", `${prefix}:flowoutbox`, ...ids.slice(start, start + 500))
1038
+ }
1039
+ }).pipe(Effect.mapError(storeError("deleteOutbox failed"))),
1040
+
1041
+ listChildResults: (flowId, listOptions) =>
1042
+ Effect.gen(function*() {
1043
+ const limit = Math.max(1, listOptions?.limit ?? 1000)
1044
+ const reply: { items: ReadonlyArray<ReadonlyArray<string>> | Record<string, never>; more: boolean } = JSON
1045
+ .parse(
1046
+ yield* evalListChildResults(prefix, flowId, listOptions?.cursor ?? "", limit)
1047
+ )
1048
+ const items = asArray(reply.items).map((row) => toChildRecord(flowId, row))
1049
+ const last = items[items.length - 1]
1050
+ return {
1051
+ items,
1052
+ cursor: reply.more && last !== undefined ? last.childKey : undefined
1053
+ }
1054
+ }).pipe(Effect.mapError(storeError("listChildResults failed"))),
1055
+
1056
+ flowSweepWork: (sweepOptions) =>
1057
+ Effect.gen(function*() {
1058
+ const now = yield* Clock.currentTimeMillis
1059
+ const limit = Math.max(1, sweepOptions.limit ?? 1000)
1060
+ const reply: {
1061
+ reconcile:
1062
+ | ReadonlyArray<{
1063
+ flowId: string
1064
+ children: ReadonlyArray<{ childKey: string; storeKey: string; spec: string }>
1065
+ }>
1066
+ | Record<string, never>
1067
+ cascade:
1068
+ | ReadonlyArray<{
1069
+ flowId: string
1070
+ children: ReadonlyArray<{ childKey: string; storeKey: string; childJobId: string }>
1071
+ }>
1072
+ | Record<string, never>
1073
+ } = JSON.parse(yield* evalFlowSweepWork(prefix, sweepOptions.pendingAgeMs, limit, now))
1074
+ const work: JobStore.FlowSweepWork = {
1075
+ reconcile: asArray(reply.reconcile).map((group) => ({
1076
+ flowId: JobStore.JobId(group.flowId),
1077
+ children: group.children.map((child) => {
1078
+ // The stored spec is the verbatim JSON this driver wrote at
1079
+ // fan-out time (never routed through cjson), so it re-parses
1080
+ // to the original EnqueueRequest.
1081
+ const request: JobStore.EnqueueRequest = JSON.parse(child.spec)
1082
+ return { childKey: child.childKey, storeKey: child.storeKey, request }
1083
+ })
1084
+ })),
1085
+ cascade: asArray(reply.cascade).map((group) => ({
1086
+ flowId: JobStore.JobId(group.flowId),
1087
+ children: group.children.map((child) => ({
1088
+ childKey: child.childKey,
1089
+ storeKey: child.storeKey,
1090
+ childJobId: JobStore.JobId(child.childJobId)
1091
+ }))
1092
+ }))
1093
+ }
1094
+ return work
1095
+ }).pipe(Effect.mapError(storeError("flowSweepWork failed"))),
1096
+
1097
+ markChildrenCascaded: (flowId, childKeys) =>
1098
+ evalMarkChildrenCascaded(prefix, flowId, JSON.stringify(childKeys)).pipe(
1099
+ Effect.mapError(storeError("markChildrenCascaded failed")),
1100
+ Effect.asVoid
818
1101
  )
819
1102
  }
820
1103