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
@@ -12,7 +12,20 @@
12
12
  *
13
13
  * @since 0.2.0
14
14
  */
15
- import { Clock, type Context, Deferred, Duration, Effect, Exit, Layer, Option, Queue, Schedule, type Scope } from "effect"
15
+ import {
16
+ Clock,
17
+ type Context,
18
+ Data,
19
+ Deferred,
20
+ Duration,
21
+ Effect,
22
+ Exit,
23
+ Layer,
24
+ Option,
25
+ Queue,
26
+ Schedule,
27
+ type Scope
28
+ } from "effect"
16
29
  import { Redis } from "effect/unstable/persistence"
17
30
  import * as JobStore from "../JobStore.ts"
18
31
  import * as scripts from "./scripts.ts"
@@ -37,6 +50,65 @@ export interface RedisJobStoreOptions {
37
50
  * Default: `j-<n>` from the store's counter. See `JobStore.IdGenerator`.
38
51
  */
39
52
  readonly idGenerator?: JobStore.IdGenerator | undefined
53
+ /**
54
+ * The optional list indexes: `p:byname:<name>` and `p:byqueue:<queue>`,
55
+ * both scored by `enqueuedAt`, serving `list({ name })` / `list({ queue })`
56
+ * without a full scan. Default: all on. `false` disables both; a partial
57
+ * object disables one (`{ name: false }`). Disable an index only when you
58
+ * never list that way — a `list` that ROUTES to a disabled index dies with
59
+ * `ListIndexDisabledError` (a query servable from another structure, e.g.
60
+ * `name` + terminal `states` ordered by `finishedAt`, never touches it).
61
+ *
62
+ * This setting is a PER-PREFIX invariant: every store instance sharing a
63
+ * key prefix must agree on it. Index writes happen at insert time in the
64
+ * writing process, so a store with an index off inserts rows that index
65
+ * never sees — enabled readers on the same prefix silently miss those rows
66
+ * (the `p:index:<kind>:ready` marker churn between mixed stores is a
67
+ * symptom of the misconfiguration, not a safety net against it).
68
+ *
69
+ * Init reconciles each index one-shot. Enabled with no `ready` marker: a
70
+ * full driver-paged ZSCAN of `p:all` rebuilds it, then stamps the marker
71
+ * with the rebuild's start time. Enabled with a marker: rows enqueued since
72
+ * the marker minus a 60s margin are (re-)indexed — so a rolling deploy
73
+ * whose old, index-less writers kept inserting after the marker landed is
74
+ * healed by the last new-code boot — and the marker is re-stamped.
75
+ * Disabled: only the marker is deleted (a future re-enable then does a full
76
+ * rebuild instead of trusting stale zsets). The zsets themselves are NEVER
77
+ * deleted at init — this store cannot know whether an enabled sibling is
78
+ * serving reads from them. After disabling everywhere, reclaim the memory
79
+ * manually, e.g.:
80
+ * `redis-cli --scan --pattern '<prefix>:byname:*' | xargs redis-cli del`.
81
+ *
82
+ * @since 0.7.0
83
+ */
84
+ readonly indexes?: { readonly name?: boolean | undefined; readonly queue?: boolean | undefined } | false | undefined
85
+ }
86
+
87
+ /**
88
+ * A `list` query routed to a list index this store is configured not to
89
+ * maintain (`RedisJobStoreOptions.indexes`). Delivered as a defect, not a
90
+ * typed failure: the configuration said "we never list this way", so the
91
+ * query contradicting it is a programming mistake, matching the library's
92
+ * die-on-config-mistake idiom.
93
+ *
94
+ * @since 0.7.0
95
+ */
96
+ export class ListIndexDisabledError extends Data.TaggedError("ListIndexDisabledError")<{
97
+ readonly index: "name" | "queue"
98
+ readonly message: string
99
+ }> {}
100
+
101
+ const TERMINAL_STATES: ReadonlySet<JobStore.JobState> = new Set(["completed", "failed", "cancelled"])
102
+
103
+ /**
104
+ * The `list` predicates the routed structure does NOT pin, applied per row
105
+ * inside the list script. Field names are part of the script's contract.
106
+ */
107
+ interface ResidualFilters {
108
+ readonly queue?: JobStore.QueueName | undefined
109
+ readonly name?: string | undefined
110
+ readonly states?: ReadonlyArray<JobStore.JobState> | undefined
111
+ readonly metadata?: Readonly<Record<string, string>> | undefined
40
112
  }
41
113
 
42
114
  const storeError = (message: string) => (cause: unknown) => new JobStore.JobStoreError({ message, cause })
@@ -85,6 +157,18 @@ const toRecord = (hash: ReadonlyMap<string, string>): JobStore.JobRecord => ({
85
157
  cancelRequested: hash.get("cancelRequested") === "1",
86
158
  dedupeKey: optionalString(hash.get("dedupeKey")),
87
159
  trace: optionalJson<JobStore.TraceContext>(hash.get("trace")),
160
+ parent: optionalJson<JobStore.ParentEnvelope>(hash.get("parent")),
161
+ // Manifest presence IS the phase marker: hashes written before flows (or
162
+ // parents that never fanned out) simply lack the flow fields.
163
+ flow: optionalString(hash.get("flowPending")) === undefined
164
+ ? undefined
165
+ : {
166
+ failFast: hash.get("flowFailFast") === "1",
167
+ pending: Number(hash.get("flowPending")),
168
+ completed: Number(hash.get("flowCompleted") ?? 0),
169
+ failed: Number(hash.get("flowFailed") ?? 0),
170
+ cancelled: Number(hash.get("flowCancelled") ?? 0)
171
+ },
88
172
  runAt: Number(hash.get("runAt") ?? 0),
89
173
  enqueuedAt: Number(hash.get("enqueuedAt") ?? 0),
90
174
  processedAt: optionalNumber(hash.get("processedAt")),
@@ -115,15 +199,68 @@ const toSchedule = (hash: ReadonlyMap<string, string>): JobStore.ScheduleRecord
115
199
  const asArray = <A>(value: ReadonlyArray<A> | Record<string, never>): ReadonlyArray<A> =>
116
200
  Array.isArray(value) ? value : []
117
201
 
202
+ /**
203
+ * Decode one outbox zset member: `<seq>\0<json>` where the json carries the
204
+ * verbatim parent envelope plus the terminal outcome. The full member string
205
+ * is the opaque entry id (deleteOutbox is then a plain ZREM).
206
+ */
207
+ const toOutboxEntry = (member: string): JobStore.OutboxEntry => {
208
+ const sep = member.indexOf("\u0000")
209
+ const body: {
210
+ parent: JobStore.ParentEnvelope
211
+ outcome: JobStore.FlowChildReport["outcome"]
212
+ exit?: unknown
213
+ failedReason?: string
214
+ } = JSON.parse(member.slice(sep + 1))
215
+ return {
216
+ id: member,
217
+ flowName: body.parent.flowName,
218
+ parentStoreKey: body.parent.parentStoreKey,
219
+ report: {
220
+ flowId: body.parent.flowId,
221
+ childKey: body.parent.childKey,
222
+ outcome: body.outcome,
223
+ // The exit key is omitted entirely when absent (ledger convention),
224
+ // so a legitimate encoded null exit survives the round trip.
225
+ exit: Object.hasOwn(body, "exit") ? body.exit : undefined,
226
+ failedReason: body.failedReason
227
+ }
228
+ }
229
+ }
230
+
118
231
  const JOB_STATES: ReadonlyArray<JobStore.JobState> = [
119
232
  "waiting",
120
233
  "delayed",
121
234
  "active",
235
+ "waiting-children",
122
236
  "completed",
123
237
  "failed",
124
238
  "cancelled"
125
239
  ]
126
240
 
241
+ /**
242
+ * Fold one positional dependency-row tuple into a `FlowChildRecord`. The
243
+ * order must stay in lockstep with the HMGET field list in the
244
+ * `listChildResults` script:
245
+ * childKey, storeKey, childJobId, name, status, exit, failedReason, cascaded.
246
+ */
247
+ const toChildRecord = (
248
+ flowId: JobStore.JobId,
249
+ row: ReadonlyArray<string>
250
+ ): JobStore.FlowChildRecord => ({
251
+ flowId,
252
+ childKey: row[0] ?? "",
253
+ storeKey: row[1] ?? "",
254
+ childJobId: JobStore.JobId(row[2] ?? ""),
255
+ name: row[3] ?? "",
256
+ // SAFETY: the status field is only ever written with FlowChildRecord
257
+ // status members ("pending" at insert, a report/settle outcome after).
258
+ status: (row[4] ?? "pending") as JobStore.FlowChildRecord["status"],
259
+ exit: optionalJson(row[5]),
260
+ failedReason: optionalString(row[6]),
261
+ cascaded: row[7] === "1"
262
+ })
263
+
127
264
  /**
128
265
  * Build a `RedisJobStore` service. Needs the `Redis` service and a `Scope`
129
266
  * (the wake-up subscription and the optional history sweeper live in it).
@@ -137,29 +274,104 @@ export const make = (
137
274
  const redis = yield* Redis.Redis
138
275
  const prefix = options?.prefix ?? "effect-mq"
139
276
  const wakeChannel = `${prefix}:wake`
277
+ const indexOptions = options?.indexes
278
+ const indexes: scripts.IndexConfig = indexOptions === false
279
+ ? { name: false, queue: false }
280
+ : { name: indexOptions?.name ?? true, queue: indexOptions?.queue ?? true }
281
+ const HELPERS = scripts.helpers(indexes)
140
282
 
141
- const evalEnqueue = redis.eval(scripts.enqueue)
142
- const evalClaim = redis.eval(scripts.claim)
143
- const evalAck = redis.eval(scripts.ack)
144
- const evalRelease = redis.eval(scripts.release)
145
- const evalExtendLocks = redis.eval(scripts.extendLocks)
146
- const evalRecoverStalled = redis.eval(scripts.recoverStalled)
147
- const evalGetJob = redis.eval(scripts.getJob)
148
- const evalList = redis.eval(scripts.list)
149
- const evalCounts = redis.eval(scripts.counts)
150
- const evalRemove = redis.eval(scripts.remove)
151
- const evalRetry = redis.eval(scripts.retry)
152
- const evalCancel = redis.eval(scripts.cancel)
153
- const evalPromote = redis.eval(scripts.promote)
154
- const evalUpsertSchedule = redis.eval(scripts.upsertSchedule)
155
- const evalRemoveSchedule = redis.eval(scripts.removeSchedule)
156
- const evalListSchedules = redis.eval(scripts.listSchedules)
157
- const evalDueSchedules = redis.eval(scripts.dueSchedules)
158
- const evalAdvanceSchedule = redis.eval(scripts.advanceSchedule)
159
- const evalTickSchedule = redis.eval(scripts.tickSchedule)
160
- const evalEnqueueMany = redis.eval(scripts.enqueueMany)
161
- const evalSweepState = redis.eval(scripts.sweepState)
162
- const evalSweepDedupes = redis.eval(scripts.sweepDedupes)
283
+ const evalEnqueue = redis.eval(scripts.enqueue(HELPERS))
284
+ const evalClaim = redis.eval(scripts.claim(HELPERS))
285
+ const evalAck = redis.eval(scripts.ack(HELPERS))
286
+ const evalRelease = redis.eval(scripts.release(HELPERS))
287
+ const evalExtendLocks = redis.eval(scripts.extendLocks(HELPERS))
288
+ const evalRecoverStalled = redis.eval(scripts.recoverStalled(HELPERS))
289
+ const evalGetJob = redis.eval(scripts.getJob(HELPERS))
290
+ const evalList = redis.eval(scripts.list(HELPERS))
291
+ const evalIndexMembers = redis.eval(scripts.indexMembers(HELPERS))
292
+ const evalIndexTailPage = redis.eval(scripts.indexTailPage(HELPERS))
293
+ const evalCounts = redis.eval(scripts.counts(HELPERS))
294
+ const evalRemove = redis.eval(scripts.remove(HELPERS))
295
+ const evalRetry = redis.eval(scripts.retry(HELPERS))
296
+ const evalCancel = redis.eval(scripts.cancel(HELPERS))
297
+ const evalPromote = redis.eval(scripts.promote(HELPERS))
298
+ const evalUpsertSchedule = redis.eval(scripts.upsertSchedule(HELPERS))
299
+ const evalRemoveSchedule = redis.eval(scripts.removeSchedule(HELPERS))
300
+ const evalListSchedules = redis.eval(scripts.listSchedules(HELPERS))
301
+ const evalDueSchedules = redis.eval(scripts.dueSchedules(HELPERS))
302
+ const evalAdvanceSchedule = redis.eval(scripts.advanceSchedule(HELPERS))
303
+ const evalTickSchedule = redis.eval(scripts.tickSchedule(HELPERS))
304
+ const evalEnqueueMany = redis.eval(scripts.enqueueMany(HELPERS))
305
+ const evalSweepState = redis.eval(scripts.sweepState(HELPERS))
306
+ const evalSweepDedupes = redis.eval(scripts.sweepDedupes(HELPERS))
307
+ const evalFanOut = redis.eval(scripts.fanOut(HELPERS))
308
+ const evalRecordChildResults = redis.eval(scripts.recordChildResults(HELPERS))
309
+ const evalListChildResults = redis.eval(scripts.listChildResults(HELPERS))
310
+ const evalFlowSweepWork = redis.eval(scripts.flowSweepWork(HELPERS))
311
+ const evalMarkChildrenCascaded = redis.eval(scripts.markChildrenCascaded(HELPERS))
312
+
313
+ // List-index reconcile, once per boot per index. All work is driver-paged
314
+ // — never one giant Lua call — so the single-threaded server is never
315
+ // held. There is no build lock: concurrent boots duplicate idempotent
316
+ // ZADDs, a crash before the marker stamp makes the next boot redo the
317
+ // work, and rows inserted meanwhile are indexed live by insertJobRow.
318
+ //
319
+ // - enabled, no marker: full rebuild via ZSCAN over `all` (cursor-based
320
+ // and linear — immune to score ties and rank shifts; every member
321
+ // present for the whole scan is guaranteed returned). Rows deleted
322
+ // mid-scan leave at most stale members the read path self-heals.
323
+ // - enabled, marker present: heal the tail. Index writes are per-process,
324
+ // so writers without them (an older version mid-rolling-deploy, or a
325
+ // misconfigured indexes-off store) may have inserted unindexed rows
326
+ // AFTER the marker landed. Re-indexing everything enqueued since the
327
+ // marker minus a 60s margin closes that window: the last enabled boot
328
+ // after such writers stop covers everything they wrote before it.
329
+ // - disabled: delete ONLY the marker (a later re-enable must not trust
330
+ // stale zsets). The zsets stay — an enabled sibling may be reading
331
+ // them, and this store cannot know.
332
+ //
333
+ // Both paths re-stamp the marker with this boot's start time. Init-time
334
+ // infra failures die — the store never starts half-configured.
335
+ yield* Effect.gen(function*() {
336
+ const bootAt = yield* Clock.currentTimeMillis
337
+ for (const kind of ["name", "queue"] as const) {
338
+ const marker = `${prefix}:index:${kind}:ready`
339
+ if (!indexes[kind]) {
340
+ yield* redis.send("DEL", marker)
341
+ continue
342
+ }
343
+ // SAFETY: GET always replies with a bulk string or null.
344
+ const stamped = (yield* redis.send("GET", marker)) as string | null
345
+ const markerAt = stamped === null || stamped === "" ? Number.NaN : Number(stamped)
346
+ if (Number.isNaN(markerAt)) {
347
+ let cursor = "0"
348
+ do {
349
+ const reply = yield* redis.send("ZSCAN", `${prefix}:all`, cursor, "COUNT", "500")
350
+ // SAFETY: ZSCAN always replies [nextCursor, member/score pairs].
351
+ const [next, flat] = reply as [string, ReadonlyArray<string>]
352
+ cursor = next
353
+ const ids: Array<string> = []
354
+ for (let i = 0; i < flat.length; i += 2) {
355
+ const id = flat[i]
356
+ if (id !== undefined) ids.push(id)
357
+ }
358
+ // COUNT is only a hint — chunk what actually came back.
359
+ for (let start = 0; start < ids.length; start += 500) {
360
+ yield* evalIndexMembers(prefix, kind, JSON.stringify(ids.slice(start, start + 500)))
361
+ }
362
+ } while (cursor !== "0")
363
+ } else {
364
+ const min = String(markerAt - 60_000)
365
+ let offset = 0
366
+ while (true) {
367
+ const scanned = Number(yield* evalIndexTailPage(prefix, kind, min, offset, 500))
368
+ if (scanned < 500) break
369
+ offset += scanned
370
+ }
371
+ }
372
+ yield* redis.send("SET", marker, String(bootAt))
373
+ }
374
+ }).pipe(Effect.orDie)
163
375
 
164
376
  // Wake protocol: a queue-filtered waiter registry (same-process wake-ups
165
377
  // never depend on the pub/sub round trip), with the channel carrying
@@ -282,9 +494,71 @@ export const make = (
282
494
  request.dedupe?.ttlMs === undefined ? "" : String(request.dedupe.ttlMs),
283
495
  request.dedupe?.extend === true ? "1" : "0",
284
496
  request.dedupe?.replace === true ? "1" : "0",
285
- request.trace === undefined ? "" : JSON.stringify(request.trace)
497
+ request.trace === undefined ? "" : JSON.stringify(request.trace),
498
+ request.parent === undefined ? "" : JSON.stringify(request.parent)
286
499
  )
287
500
 
501
+ // The FanOut ack. Large manifests chunk their dependency rows across
502
+ // several lock-token-guarded script calls (ARGV strides, like
503
+ // enqueueMany); only the FINAL chunk writes the manifest and flips the
504
+ // state, so a crash mid-staging leaves the job active and recoverable —
505
+ // the next attempt's first chunk clears the orphaned staged rows.
506
+ const fanOutAck = (
507
+ id: JobStore.JobId,
508
+ token: string,
509
+ failFast: boolean,
510
+ children: ReadonlyArray<JobStore.FlowChildSpec>
511
+ ) =>
512
+ Effect.gen(function*() {
513
+ if (children.some((child) => child.request.id === undefined)) {
514
+ // Validate BEFORE any script call, so a bad spec cannot leave the
515
+ // job half-acked (rows staged, ledger written, still active).
516
+ return yield* new JobStore.JobStoreError({
517
+ message: "FanOut child specs require an explicit request.id"
518
+ })
519
+ }
520
+ const now = yield* Clock.currentTimeMillis
521
+ const chunks: Array<ReadonlyArray<JobStore.FlowChildSpec>> = []
522
+ for (let start = 0; start < children.length; start += 500) {
523
+ chunks.push(children.slice(start, start + 500))
524
+ }
525
+ // An empty manifest still needs the final (state-flipping) call.
526
+ if (chunks.length === 0) chunks.push([])
527
+ for (let i = 0; i < chunks.length; i++) {
528
+ const chunk = chunks[i]
529
+ if (chunk === undefined) continue
530
+ const items: Array<string> = []
531
+ for (const child of chunk) {
532
+ items.push(
533
+ child.childKey,
534
+ child.storeKey,
535
+ child.request.id ?? "",
536
+ child.request.name,
537
+ JSON.stringify(child.request)
538
+ )
539
+ }
540
+ const reply: { error?: string; wake?: boolean; queue?: string } = JSON.parse(
541
+ yield* evalFanOut(
542
+ prefix,
543
+ id,
544
+ token,
545
+ i === chunks.length - 1 ? "1" : "0",
546
+ i === 0 ? "1" : "0",
547
+ failFast ? "1" : "0",
548
+ children.length,
549
+ now,
550
+ chunk.length,
551
+ items
552
+ ).pipe(Effect.mapError(storeError("ack failed")))
553
+ )
554
+ if (reply.error === "notfound") return yield* new JobStore.JobNotFoundError({ jobId: id })
555
+ if (reply.error === "locklost") return yield* new JobStore.LockLostError({ jobId: id })
556
+ if (reply.wake === true) {
557
+ yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : undefined)
558
+ }
559
+ }
560
+ })
561
+
288
562
  // Shared by cancel and cancelByDedupe.
289
563
  const cancelJob = (id: JobStore.JobId) =>
290
564
  Effect.gen(function*() {
@@ -380,6 +654,7 @@ export const make = (
380
654
  request.keep === undefined ? "" : JSON.stringify(request.keep),
381
655
  request.timeoutMs === undefined ? "" : String(request.timeoutMs),
382
656
  request.trace === undefined ? "" : JSON.stringify(request.trace),
657
+ request.parent === undefined ? "" : JSON.stringify(request.parent),
383
658
  String(Math.max(0, request.delayMs))
384
659
  )
385
660
  }
@@ -504,15 +779,20 @@ export const make = (
504
779
  })
505
780
  }).pipe(Effect.mapError(storeError("claim failed"))),
506
781
 
507
- ack: (id, token, outcome) =>
508
- Effect.gen(function*() {
782
+ ack: (id, token, outcome) => {
783
+ if (outcome._tag === "FanOut") {
784
+ return fanOutAck(id, token, outcome.failFast, outcome.children)
785
+ }
786
+ // Narrowed binding: the closure below must see the FanOut-free union.
787
+ const settled = outcome
788
+ return Effect.gen(function*() {
509
789
  const now = yield* Clock.currentTimeMillis
510
- const exitJson = outcome._tag === "Cancelled" || outcome.exit === undefined
790
+ const exitJson = settled._tag === "Cancelled" || settled.exit === undefined
511
791
  ? ""
512
- : JSON.stringify(outcome.exit)
513
- const delayMs = outcome._tag === "Retry" ? Math.max(0, outcome.delayMs) : 0
792
+ : JSON.stringify(settled.exit)
793
+ const delayMs = settled._tag === "Retry" ? Math.max(0, settled.delayMs) : 0
514
794
  const reply: { error?: string; wake?: boolean; queue?: string } = JSON.parse(
515
- yield* evalAck(prefix, id, token, outcome._tag, exitJson, delayMs, now).pipe(
795
+ yield* evalAck(prefix, id, token, settled._tag, exitJson, delayMs, now).pipe(
516
796
  Effect.mapError(storeError("ack failed"))
517
797
  )
518
798
  )
@@ -521,7 +801,8 @@ export const make = (
521
801
  if (reply.wake === true) {
522
802
  yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : undefined)
523
803
  }
524
- }),
804
+ })
805
+ },
525
806
 
526
807
  release: (id, token) =>
527
808
  Effect.gen(function*() {
@@ -617,24 +898,102 @@ export const make = (
617
898
  list: (listOptions) =>
618
899
  Effect.gen(function*() {
619
900
  const limit = Math.max(1, listOptions.limit ?? 50)
620
- const filters = {
621
- queue: listOptions.queue,
622
- name: listOptions.name,
623
- states: listOptions.states,
624
- metadata: listOptions.metadata
901
+ const { metadata, name, queue, states } = listOptions
902
+ const orderBy = listOptions.orderBy ?? "enqueuedAt"
903
+ const order = listOptions.order ?? "desc"
904
+ // Routing: the narrowest structure whose zset score IS the
905
+ // requested order value; everything it does not pin stays a
906
+ // residual predicate applied in-script. Routing runs BEFORE the
907
+ // disabled-index check — a query another structure serves (e.g.
908
+ // name + terminal states ordered by finishedAt) must never die.
909
+ let sources: ReadonlyArray<string>
910
+ let residual: ResidualFilters
911
+ if (orderBy === "finishedAt") {
912
+ if (states === undefined || states.some((state) => !TERMINAL_STATES.has(state))) {
913
+ return yield* Effect.die(
914
+ new JobStore.ListOrderUnsupportedError({
915
+ orderBy,
916
+ message: "effect-mq: the Redis store serves orderBy \"finishedAt\" only with " +
917
+ "states ⊆ {completed, failed, cancelled} (the finished/terminal zsets carry that order)"
918
+ })
919
+ )
920
+ }
921
+ // ≤3 per-state sources, merged by (finishedAt, id) in-script.
922
+ const uniqueStates = [...new Set(states)]
923
+ sources = name === undefined
924
+ ? uniqueStates.map((state) => `${prefix}:finished:${state}`)
925
+ : uniqueStates.map((state) => `${prefix}:terminal:${name}:${state}`)
926
+ residual = { queue, metadata }
927
+ } else if (orderBy === "runAt") {
928
+ const onlyDelayed = states !== undefined && states.length > 0 &&
929
+ states.every((state) => state === "delayed")
930
+ if (queue === undefined || !onlyDelayed) {
931
+ return yield* Effect.die(
932
+ new JobStore.ListOrderUnsupportedError({
933
+ orderBy,
934
+ message: "effect-mq: the Redis store serves orderBy \"runAt\" only with " +
935
+ "states: [\"delayed\"] and a queue (the delayed:<queue> zset carries that order)"
936
+ })
937
+ )
938
+ }
939
+ sources = [`${prefix}:delayed:${queue}`]
940
+ residual = { name, metadata }
941
+ } else if (name !== undefined) {
942
+ if (!indexes.name) {
943
+ return yield* Effect.die(
944
+ new ListIndexDisabledError({
945
+ index: "name",
946
+ message: "effect-mq: list({ name }) routes to the byname index, " +
947
+ "but RedisJobStoreOptions.indexes.name is disabled for this store"
948
+ })
949
+ )
950
+ }
951
+ sources = [`${prefix}:byname:${name}`]
952
+ residual = { queue, states, metadata }
953
+ } else if (queue !== undefined) {
954
+ if (!indexes.queue) {
955
+ return yield* Effect.die(
956
+ new ListIndexDisabledError({
957
+ index: "queue",
958
+ message: "effect-mq: list({ queue }) routes to the byqueue index, " +
959
+ "but RedisJobStoreOptions.indexes.queue is disabled for this store"
960
+ })
961
+ )
962
+ }
963
+ sources = [`${prefix}:byqueue:${queue}`]
964
+ residual = { states, metadata }
965
+ } else {
966
+ sources = [`${prefix}:all`]
967
+ residual = { states, metadata }
625
968
  }
626
969
  const reply: { items: ReadonlyArray<ReadonlyArray<string>> | Record<string, never>; more: boolean } = JSON
627
970
  .parse(
628
- yield* evalList(prefix, JSON.stringify(filters), listOptions.cursor ?? "", limit)
971
+ yield* evalList(
972
+ prefix,
973
+ JSON.stringify(sources),
974
+ order,
975
+ JSON.stringify(residual),
976
+ listOptions.cursor ?? "",
977
+ limit
978
+ ).pipe(Effect.mapError(storeError("list failed")))
629
979
  )
630
980
  const items = asArray(reply.items).map((flat) => toRecord(foldPairs(flat)))
631
981
  const last = items[items.length - 1]
982
+ // The cursor value is the order field, which equals the routed
983
+ // zset's score for every row the script returned.
984
+ const orderValue = last === undefined
985
+ ? 0
986
+ : orderBy === "enqueuedAt"
987
+ ? last.enqueuedAt
988
+ : orderBy === "runAt"
989
+ ? last.runAt
990
+ : last.finishedAt ?? 0
632
991
  const result: JobStore.ListResult = {
633
992
  items,
634
- cursor: reply.more && last !== undefined ? `${last.enqueuedAt}:${last.id}` : undefined
993
+ cursor: reply.more && last !== undefined ? `${orderValue}:${last.id}` : undefined
635
994
  }
636
995
  return result
637
- }).pipe(Effect.mapError(storeError("list failed"))),
996
+ }),
638
997
 
639
998
  retry: (id) =>
640
999
  Effect.gen(function*() {
@@ -800,6 +1159,7 @@ export const make = (
800
1159
  request.keep === undefined ? "" : JSON.stringify(request.keep),
801
1160
  request.timeoutMs === undefined ? "" : String(request.timeoutMs),
802
1161
  request.trace === undefined ? "" : JSON.stringify(request.trace),
1162
+ request.parent === undefined ? "" : JSON.stringify(request.parent),
803
1163
  Math.max(0, request.delayMs),
804
1164
  now
805
1165
  )) === "1"
@@ -817,6 +1177,147 @@ export const make = (
817
1177
  evalAdvanceSchedule(prefix, key, expectedRunAt, nextRunAt).pipe(
818
1178
  Effect.mapError(storeError("advanceSchedule failed")),
819
1179
  Effect.asVoid
1180
+ ),
1181
+
1182
+ recordChildResults: (reports) =>
1183
+ Effect.gen(function*() {
1184
+ if (reports.length === 0) {
1185
+ const none: ReadonlyArray<{ applied: boolean; parentSettled: boolean }> = []
1186
+ return none
1187
+ }
1188
+ const now = yield* Clock.currentTimeMillis
1189
+ const all: Array<{ applied: boolean; parentSettled: boolean }> = []
1190
+ // One atomic batch per chunk of 500 (ARGV headroom); the
1191
+ // contract's per-batch settle semantics then apply per chunk.
1192
+ for (let start = 0; start < reports.length; start += 500) {
1193
+ const chunk = reports.slice(start, start + 500)
1194
+ const items: Array<string> = []
1195
+ for (const report of chunk) {
1196
+ items.push(
1197
+ report.flowId,
1198
+ report.childKey,
1199
+ report.outcome,
1200
+ report.exit === undefined ? "" : JSON.stringify(report.exit),
1201
+ report.failedReason ?? ""
1202
+ )
1203
+ }
1204
+ const reply: {
1205
+ results: ReadonlyArray<{ applied: boolean; parentSettled: boolean }>
1206
+ wakes: ReadonlyArray<string> | Record<string, never>
1207
+ } = JSON.parse(yield* evalRecordChildResults(prefix, now, chunk.length, items))
1208
+ for (const queue of asArray(reply.wakes)) {
1209
+ // A parent settled to runnable collect: wake its queue.
1210
+ yield* wakeUp(JobStore.QueueName(queue))
1211
+ }
1212
+ for (const result of reply.results) {
1213
+ all.push(result)
1214
+ }
1215
+ }
1216
+ return all
1217
+ }).pipe(Effect.mapError(storeError("recordChildResults failed"))),
1218
+
1219
+ peekOutbox: (peekOptions) =>
1220
+ Effect.gen(function*() {
1221
+ const limit = Math.floor(peekOptions.limit)
1222
+ if (limit <= 0) {
1223
+ const none: ReadonlyArray<JobStore.OutboxEntry> = []
1224
+ return none
1225
+ }
1226
+ // The `after` cursor compares by the id's embedded score (the seq
1227
+ // prefix before the NUL), so the walk moves past the named entry
1228
+ // whether or not it still exists. Unparseable input reads as unset.
1229
+ let afterSeq: number | undefined = undefined
1230
+ if (peekOptions.after !== undefined) {
1231
+ const nul = peekOptions.after.indexOf("\u0000")
1232
+ const seq = nul > 0 ? Number(peekOptions.after.slice(0, nul)) : Number.NaN
1233
+ if (Number.isFinite(seq)) afterSeq = seq
1234
+ }
1235
+ const raw = afterSeq === undefined
1236
+ ? yield* redis.send("ZRANGE", `${prefix}:flowoutbox`, "0", String(limit - 1))
1237
+ : yield* redis.send(
1238
+ "ZRANGEBYSCORE",
1239
+ `${prefix}:flowoutbox`,
1240
+ `(${afterSeq}`,
1241
+ "+inf",
1242
+ "LIMIT",
1243
+ "0",
1244
+ String(limit)
1245
+ )
1246
+ // SAFETY: ZRANGE/ZRANGEBYSCORE always reply with arrays of bulk
1247
+ // strings.
1248
+ const members = raw as ReadonlyArray<string>
1249
+ return members.map(toOutboxEntry)
1250
+ }).pipe(Effect.mapError(storeError("peekOutbox failed"))),
1251
+
1252
+ deleteOutbox: (ids) =>
1253
+ Effect.gen(function*() {
1254
+ // Chunked ZREMs keep the variadic argument count bounded; each
1255
+ // chunk is idempotent, so a partial failure just redelivers.
1256
+ for (let start = 0; start < ids.length; start += 500) {
1257
+ yield* redis.send("ZREM", `${prefix}:flowoutbox`, ...ids.slice(start, start + 500))
1258
+ }
1259
+ }).pipe(Effect.mapError(storeError("deleteOutbox failed"))),
1260
+
1261
+ listChildResults: (flowId, listOptions) =>
1262
+ Effect.gen(function*() {
1263
+ const limit = Math.max(1, listOptions?.limit ?? 1000)
1264
+ const reply: { items: ReadonlyArray<ReadonlyArray<string>> | Record<string, never>; more: boolean } = JSON
1265
+ .parse(
1266
+ yield* evalListChildResults(prefix, flowId, listOptions?.cursor ?? "", limit)
1267
+ )
1268
+ const items = asArray(reply.items).map((row) => toChildRecord(flowId, row))
1269
+ const last = items[items.length - 1]
1270
+ return {
1271
+ items,
1272
+ cursor: reply.more && last !== undefined ? last.childKey : undefined
1273
+ }
1274
+ }).pipe(Effect.mapError(storeError("listChildResults failed"))),
1275
+
1276
+ flowSweepWork: (sweepOptions) =>
1277
+ Effect.gen(function*() {
1278
+ const now = yield* Clock.currentTimeMillis
1279
+ const limit = Math.max(1, sweepOptions.limit ?? 1000)
1280
+ const reply: {
1281
+ reconcile:
1282
+ | ReadonlyArray<{
1283
+ flowId: string
1284
+ children: ReadonlyArray<{ childKey: string; storeKey: string; spec: string }>
1285
+ }>
1286
+ | Record<string, never>
1287
+ cascade:
1288
+ | ReadonlyArray<{
1289
+ flowId: string
1290
+ children: ReadonlyArray<{ childKey: string; storeKey: string; childJobId: string }>
1291
+ }>
1292
+ | Record<string, never>
1293
+ } = JSON.parse(yield* evalFlowSweepWork(prefix, sweepOptions.pendingAgeMs, limit, now))
1294
+ const work: JobStore.FlowSweepWork = {
1295
+ reconcile: asArray(reply.reconcile).map((group) => ({
1296
+ flowId: JobStore.JobId(group.flowId),
1297
+ children: group.children.map((child) => {
1298
+ // The stored spec is the verbatim JSON this driver wrote at
1299
+ // fan-out time (never routed through cjson), so it re-parses
1300
+ // to the original EnqueueRequest.
1301
+ const request: JobStore.EnqueueRequest = JSON.parse(child.spec)
1302
+ return { childKey: child.childKey, storeKey: child.storeKey, request }
1303
+ })
1304
+ })),
1305
+ cascade: asArray(reply.cascade).map((group) => ({
1306
+ flowId: JobStore.JobId(group.flowId),
1307
+ children: group.children.map((child) => ({
1308
+ childKey: child.childKey,
1309
+ storeKey: child.storeKey,
1310
+ childJobId: JobStore.JobId(child.childJobId)
1311
+ }))
1312
+ }))
1313
+ }
1314
+ return work
1315
+ }).pipe(Effect.mapError(storeError("flowSweepWork failed"))),
1316
+
1317
+ markChildrenCascaded: (flowId, childKeys) =>
1318
+ evalMarkChildrenCascaded(prefix, flowId, JSON.stringify(childKeys)).pipe(
1319
+ Effect.mapError(storeError("markChildrenCascaded failed")),
1320
+ Effect.asVoid
820
1321
  )
821
1322
  }
822
1323