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
@@ -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" }),
@@ -245,6 +258,75 @@ export const mqDedupe = <JobName extends string = string>(
245
258
  ...options?.extraConfig?.(table) ?? []
246
259
  ])
247
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
+
248
330
  const queueControlColumns = <Queue extends string>() => ({
249
331
  queue: text("queue").primaryKey().$type<Queue>(),
250
332
  paused: boolean("paused").notNull().default(false)
@@ -287,3 +369,13 @@ export type MqQueueControlTable = ReturnType<typeof mqQueueControl<any>>
287
369
  * @since 0.3.0
288
370
  */
289
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
  *
@@ -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")),
@@ -115,15 +127,68 @@ const toSchedule = (hash: ReadonlyMap<string, string>): JobStore.ScheduleRecord
115
127
  const asArray = <A>(value: ReadonlyArray<A> | Record<string, never>): ReadonlyArray<A> =>
116
128
  Array.isArray(value) ? value : []
117
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
+
118
159
  const JOB_STATES: ReadonlyArray<JobStore.JobState> = [
119
160
  "waiting",
120
161
  "delayed",
121
162
  "active",
163
+ "waiting-children",
122
164
  "completed",
123
165
  "failed",
124
166
  "cancelled"
125
167
  ]
126
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
+
127
192
  /**
128
193
  * Build a `RedisJobStore` service. Needs the `Redis` service and a `Scope`
129
194
  * (the wake-up subscription and the optional history sweeper live in it).
@@ -160,6 +225,11 @@ export const make = (
160
225
  const evalEnqueueMany = redis.eval(scripts.enqueueMany)
161
226
  const evalSweepState = redis.eval(scripts.sweepState)
162
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)
163
233
 
164
234
  // Wake protocol: a queue-filtered waiter registry (same-process wake-ups
165
235
  // never depend on the pub/sub round trip), with the channel carrying
@@ -282,9 +352,71 @@ export const make = (
282
352
  request.dedupe?.ttlMs === undefined ? "" : String(request.dedupe.ttlMs),
283
353
  request.dedupe?.extend === true ? "1" : "0",
284
354
  request.dedupe?.replace === true ? "1" : "0",
285
- request.trace === undefined ? "" : JSON.stringify(request.trace)
355
+ request.trace === undefined ? "" : JSON.stringify(request.trace),
356
+ request.parent === undefined ? "" : JSON.stringify(request.parent)
286
357
  )
287
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
+
288
420
  // Shared by cancel and cancelByDedupe.
289
421
  const cancelJob = (id: JobStore.JobId) =>
290
422
  Effect.gen(function*() {
@@ -380,6 +512,7 @@ export const make = (
380
512
  request.keep === undefined ? "" : JSON.stringify(request.keep),
381
513
  request.timeoutMs === undefined ? "" : String(request.timeoutMs),
382
514
  request.trace === undefined ? "" : JSON.stringify(request.trace),
515
+ request.parent === undefined ? "" : JSON.stringify(request.parent),
383
516
  String(Math.max(0, request.delayMs))
384
517
  )
385
518
  }
@@ -504,15 +637,20 @@ export const make = (
504
637
  })
505
638
  }).pipe(Effect.mapError(storeError("claim failed"))),
506
639
 
507
- ack: (id, token, outcome) =>
508
- 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*() {
509
647
  const now = yield* Clock.currentTimeMillis
510
- const exitJson = outcome._tag === "Cancelled" || outcome.exit === undefined
648
+ const exitJson = settled._tag === "Cancelled" || settled.exit === undefined
511
649
  ? ""
512
- : JSON.stringify(outcome.exit)
513
- 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
514
652
  const reply: { error?: string; wake?: boolean; queue?: string } = JSON.parse(
515
- yield* evalAck(prefix, id, token, outcome._tag, exitJson, delayMs, now).pipe(
653
+ yield* evalAck(prefix, id, token, settled._tag, exitJson, delayMs, now).pipe(
516
654
  Effect.mapError(storeError("ack failed"))
517
655
  )
518
656
  )
@@ -521,7 +659,8 @@ export const make = (
521
659
  if (reply.wake === true) {
522
660
  yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : undefined)
523
661
  }
524
- }),
662
+ })
663
+ },
525
664
 
526
665
  release: (id, token) =>
527
666
  Effect.gen(function*() {
@@ -800,6 +939,7 @@ export const make = (
800
939
  request.keep === undefined ? "" : JSON.stringify(request.keep),
801
940
  request.timeoutMs === undefined ? "" : String(request.timeoutMs),
802
941
  request.trace === undefined ? "" : JSON.stringify(request.trace),
942
+ request.parent === undefined ? "" : JSON.stringify(request.parent),
803
943
  Math.max(0, request.delayMs),
804
944
  now
805
945
  )) === "1"
@@ -817,6 +957,147 @@ export const make = (
817
957
  evalAdvanceSchedule(prefix, key, expectedRunAt, nextRunAt).pipe(
818
958
  Effect.mapError(storeError("advanceSchedule failed")),
819
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
820
1101
  )
821
1102
  }
822
1103