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
@@ -21,6 +21,19 @@
21
21
  * - `p:delayed:<queue>` ZSET, score `runAt`
22
22
  * - `p:active` ZSET, score `lockExpiresAt`
23
23
  * - `p:all` ZSET, score `enqueuedAt` (list pagination)
24
+ * - `p:byname:<name>` ZSET, score `enqueuedAt` (list name index;
25
+ * optional, `RedisJobStoreOptions.indexes.name`)
26
+ * - `p:byqueue:<queue>` ZSET, score `enqueuedAt` (list queue index;
27
+ * optional, `RedisJobStoreOptions.indexes.queue`)
28
+ * - `p:index:<kind>:ready` STRING, millis timestamp of the last index
29
+ * reconcile's start for `<kind>` (name |
30
+ * queue). Absent: the next enabled boot does a
31
+ * full ZSCAN rebuild. Present: enabled boots
32
+ * heal the tail (rows enqueued since the value
33
+ * minus a safety margin) and re-stamp it —
34
+ * closing the rolling-deploy window where
35
+ * index-less writers inserted rows after the
36
+ * marker landed
24
37
  * - `p:finished:<state>` ZSET, score `finishedAt` (history TTL)
25
38
  * - `p:terminal:<name>:<state>` ZSET, score `finishedAt` (keep pruning)
26
39
  * - `p:counts` HASH `<queue>|<state>` -> integer
@@ -28,16 +41,50 @@
28
41
  * - `p:schedules` / `p:schedule:<key>` ZSET by nextRunAt + HASH per record
29
42
  * - `p:dedupe:<name>\0<key>` HASH {jobId, expiresAt} + `p:dedupes` index
30
43
  * ZSET (score = window expiry, +inf = pending)
44
+ * - `p:flowchild:<flowId>\0<childKey>` HASH of one flow dependency row
45
+ * - `p:flowchildren:<flowId>` ZSET (score 0, member = childKey; ZRANGEBYLEX
46
+ * gives child-key order + cursor pagination)
47
+ * - `p:flowpending` ZSET, member `<flowId>\0<childKey>`, score =
48
+ * sweep-eligibility timestamp (pendingSince at
49
+ * staging, re-armed on sweep return)
50
+ * - `p:flowcascade` ZSET, score 0, member `<flowId>\0<childKey>`
51
+ * (cancels still owed to child stores)
52
+ * - `p:flowoutbox` ZSET, score = seq from `p:flowoutbox:seq`,
53
+ * member `<seq>\0<report json>` — undelivered
54
+ * child-result reports (see OutboxEntry). The
55
+ * seq prefix makes every member id a peek
56
+ * cursor that survives deletions (see
57
+ * peekOutbox in RedisJobStore)
58
+ *
59
+ * `waiting-children` parents live only in the job hash, `p:all`, and
60
+ * `p:counts` — never in a pending zset, so `claim` can never return them.
31
61
  *
32
62
  * @since 0.2.0
33
63
  */
34
64
  import { Redis } from "effect/unstable/persistence"
35
65
 
66
+ /**
67
+ * Which optional list indexes (`p:byname:<name>` / `p:byqueue:<queue>`) this
68
+ * store maintains. The flags are baked into the script text (the helpers are
69
+ * shared by every script), so a store instance only ever runs scripts that
70
+ * match its configuration — disabled indexes cost no writes at all.
71
+ *
72
+ * @since 0.7.0
73
+ */
74
+ export interface IndexConfig {
75
+ readonly name: boolean
76
+ readonly queue: boolean
77
+ }
78
+
36
79
  /**
37
80
  * Shared helpers textually prepended to every script (the `Redis.script`
38
81
  * runner has no include mechanism). `ARGV[1]` is always the key prefix.
82
+ * Built once per store instance: `insertJobRow` writes only the list indexes
83
+ * enabled by `IndexConfig` (`deleteJob` clears both unconditionally — a ZREM
84
+ * on a missing key is free, and stray entries from an earlier configuration
85
+ * must never outlive their job).
39
86
  */
40
- const HELPERS = `
87
+ export const helpers = (indexes: IndexConfig): string => `
41
88
  local prefix = ARGV[1]
42
89
  local function fmt(x) return string.format("%.0f", x) end
43
90
  local function jobKey(id) return prefix .. ":job:" .. id end
@@ -45,6 +92,8 @@ local function attemptsKey(id) return prefix .. ":attempts:" .. id end
45
92
  local function waitingKey(queue) return prefix .. ":waiting:" .. queue end
46
93
  local function delayedKey(queue) return prefix .. ":delayed:" .. queue end
47
94
  local function terminalKey(name, state) return prefix .. ":terminal:" .. name .. ":" .. state end
95
+ local function bynameKey(name) return prefix .. ":byname:" .. name end
96
+ local function byqueueKey(queue) return prefix .. ":byqueue:" .. queue end
48
97
  -- Waiting order: score = -priority (higher priority first, full number range);
49
98
  -- FIFO within a priority via lexicographic members "<seq %016d>:<id>". A
50
99
  -- composite numeric score would clip either priority or seq past float53.
@@ -71,7 +120,79 @@ local function appendAttempt(id, outcome, startedAt, finishedAt, exitJson)
71
120
  '{"attempt":' .. n .. ',"startedAt":' .. started .. ',"finishedAt":' .. finishedAt ..
72
121
  ',"outcome":"' .. outcome .. '"' .. ex .. '}')
73
122
  end
74
- -- Remove a job and every index entry that references it.
123
+ -- The outbox invariant: every operation that moves a job carrying a parent
124
+ -- envelope INTO a terminal state appends its child-result report here, in
125
+ -- the same script. MUST run after the terminal fields (exit, failedReason)
126
+ -- are written — the report is built from the hash. The parent envelope and
127
+ -- exit are spliced as raw JSON (never cjson-decoded: precision, surrogates);
128
+ -- exit/failedReason keys are OMITTED when absent, like the attempts ledger.
129
+ local function appendOutbox(id, outcome)
130
+ local jk = jobKey(id)
131
+ local parentJson = redis.call("HGET", jk, "parent")
132
+ if parentJson == false or parentJson == "" then return end
133
+ local exitJson = redis.call("HGET", jk, "exit")
134
+ local failedReason = redis.call("HGET", jk, "failedReason")
135
+ local ex = (exitJson == false or exitJson == "") and "" or (',"exit":' .. exitJson)
136
+ local fr = (failedReason == false or failedReason == "") and ""
137
+ or (',"failedReason":' .. cjson.encode(failedReason))
138
+ local seq = redis.call("INCR", prefix .. ":flowoutbox:seq")
139
+ redis.call("ZADD", prefix .. ":flowoutbox", seq, fmt(seq) .. "\0" ..
140
+ '{"parent":' .. parentJson .. ',"outcome":"' .. outcome .. '"' .. ex .. fr .. '}')
141
+ end
142
+ -- Flow dependency rows live in the parent store, one hash per child plus a
143
+ -- per-flow member index (childKey order via ZRANGEBYLEX). Row keys and the
144
+ -- sweep-index members join flowId and childKey with NUL — ids and keys may
145
+ -- both contain ":", so a printable separator could alias two distinct
146
+ -- (flowId, childKey) pairs onto one row.
147
+ local function flowChildKey(flowId, childKey) return prefix .. ":flowchild:" .. flowId .. "\0" .. childKey end
148
+ local function flowIndexKey(flowId) return prefix .. ":flowchildren:" .. flowId end
149
+ local function flowMember(flowId, childKey) return flowId .. "\0" .. childKey end
150
+ -- Settle-time marking: remaining pending rows flip to cancelled (NOT
151
+ -- cascaded — the sweeper still owes the child stores real cancels), moving
152
+ -- from the pending sweep index to the cascade sweep index. Returns the
153
+ -- number of rows flipped, for the flow counters.
154
+ local function markPendingRowsCancelled(flowId)
155
+ local keys = redis.call("ZRANGE", flowIndexKey(flowId), 0, -1)
156
+ local marked = 0
157
+ for i = 1, #keys do
158
+ local rk = flowChildKey(flowId, keys[i])
159
+ if redis.call("HGET", rk, "status") == "pending" then
160
+ redis.call("HSET", rk, "status", "cancelled", "cascaded", "0")
161
+ redis.call("ZREM", prefix .. ":flowpending", flowMember(flowId, keys[i]))
162
+ redis.call("ZADD", prefix .. ":flowcascade", 0, flowMember(flowId, keys[i]))
163
+ marked = marked + 1
164
+ end
165
+ end
166
+ return marked
167
+ end
168
+ -- Settle-time marking plus the manifest counters: pending -> 0, cancelled +=
169
+ -- the rows flipped (the four counters always sum to the manifest size).
170
+ local function settleMarkRows(flowId)
171
+ local marked = markPendingRowsCancelled(flowId)
172
+ local jk = jobKey(flowId)
173
+ if redis.call("HEXISTS", jk, "flowPending") == 1 then
174
+ redis.call("HSET", jk, "flowPending", "0")
175
+ if marked > 0 then redis.call("HINCRBY", jk, "flowCancelled", marked) end
176
+ end
177
+ end
178
+ -- A settled flow parent whose rows still owe cascade cancels is exempt from
179
+ -- AUTOMATIC retention (keep policies, the history sweep): deleting it would
180
+ -- delete the only record that the child stores are still owed real cancels.
181
+ -- The explicit remove verb is the operator override and is NOT exempted.
182
+ -- Cheap for non-parents: the per-flow index only exists for fanned-out jobs.
183
+ local function owesCascades(id)
184
+ if redis.call("EXISTS", flowIndexKey(id)) == 0 then return false end
185
+ local keys = redis.call("ZRANGE", flowIndexKey(id), 0, -1)
186
+ for i = 1, #keys do
187
+ local rk = flowChildKey(id, keys[i])
188
+ if redis.call("HGET", rk, "status") == "cancelled" and redis.call("HGET", rk, "cascaded") == "0" then
189
+ return true
190
+ end
191
+ end
192
+ return false
193
+ end
194
+ -- Remove a job and every index entry that references it. A flow parent takes
195
+ -- its dependency rows and their sweep-index members with it (flowId = job id).
75
196
  local function deleteJob(id)
76
197
  local jk = jobKey(id)
77
198
  local queue = redis.call("HGET", jk, "queue")
@@ -80,11 +201,20 @@ local function deleteJob(id)
80
201
  local name = redis.call("HGET", jk, "name")
81
202
  countsAdd(queue, state, -1)
82
203
  redis.call("ZREM", prefix .. ":all", id)
204
+ redis.call("ZREM", bynameKey(name), id)
205
+ redis.call("ZREM", byqueueKey(queue), id)
83
206
  redis.call("ZREM", prefix .. ":finished:" .. state, id)
84
207
  redis.call("ZREM", prefix .. ":active", id)
85
208
  redis.call("ZREM", terminalKey(name, state), id)
86
209
  remWaiting(queue, id)
87
210
  redis.call("ZREM", delayedKey(queue), id)
211
+ local children = redis.call("ZRANGE", flowIndexKey(id), 0, -1)
212
+ for i = 1, #children do
213
+ redis.call("DEL", flowChildKey(id, children[i]))
214
+ redis.call("ZREM", prefix .. ":flowpending", flowMember(id, children[i]))
215
+ redis.call("ZREM", prefix .. ":flowcascade", flowMember(id, children[i]))
216
+ end
217
+ redis.call("DEL", flowIndexKey(id))
88
218
  redis.call("DEL", jk, attemptsKey(id))
89
219
  end
90
220
  -- Terminal retention for one name+state group. Correctness over speed: the
@@ -107,9 +237,13 @@ local function applyKeep(name, state, keepJson, now)
107
237
  end
108
238
  end
109
239
  local tkey = terminalKey(name, state)
240
+ -- Retention exemption: parents still owing cascade cancels are spared
241
+ -- (they still occupy a keep-count slot, exactly like the memory driver).
110
242
  if keep.ageMs ~= nil then
111
243
  local old = redis.call("ZRANGEBYSCORE", tkey, "-inf", now - keep.ageMs)
112
- for i = 1, #old do deleteJob(old[i]) end
244
+ for i = 1, #old do
245
+ if not owesCascades(old[i]) then deleteJob(old[i]) end
246
+ end
113
247
  end
114
248
  -- Floor + clamp: a fractional/negative count must degrade like the memory
115
249
  -- driver's slice(), never error mid-script (writes before an error stick).
@@ -128,7 +262,9 @@ local function applyKeep(name, state, keepJson, now)
128
262
  if a.fa ~= b.fa then return a.fa > b.fa end
129
263
  return a.seq > b.seq
130
264
  end)
131
- for i = count + 1, #arr do deleteJob(arr[i].id) end
265
+ for i = count + 1, #arr do
266
+ if not owesCascades(arr[i].id) then deleteJob(arr[i].id) end
267
+ end
132
268
  end
133
269
  end
134
270
  local function dedupeStoreKey(name, key) return prefix .. ":dedupe:" .. name .. "\0" .. key end
@@ -156,14 +292,30 @@ local function finishCancelled(id, queue, name, startedAt, now, nowStr)
156
292
  redis.call("ZADD", prefix .. ":finished:cancelled", now, id)
157
293
  redis.call("ZADD", terminalKey(name, "cancelled"), now, id)
158
294
  appendAttempt(id, "cancelled", startedAt, nowStr, "")
295
+ appendOutbox(id, "cancelled")
159
296
  releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
160
297
  applyKeep(name, "cancelled", redis.call("HGET", jk, "keep"), now)
161
298
  end
299
+ -- Index one EXISTING job row into a list index (backfill and tail heal;
300
+ -- insertJobRow covers live writes). A missing row is skipped — nothing to
301
+ -- index, and the read path self-heals whatever stale member pointed here.
302
+ local function indexInto(kind, id)
303
+ local row = redis.call("HMGET", jobKey(id), "name", "queue", "enqueuedAt")
304
+ if row[1] then
305
+ local enqueuedAt = tonumber(row[3]) or 0
306
+ if kind == "name" then
307
+ redis.call("ZADD", bynameKey(row[1]), enqueuedAt, id)
308
+ else
309
+ redis.call("ZADD", byqueueKey(row[2]), enqueuedAt, id)
310
+ end
311
+ end
312
+ end
162
313
  -- Insert one fresh job row plus every index entry. String params are stored
163
- -- verbatim (payload/metadata/backoff/keep/trace are pre-encoded JSON, "" =
164
- -- absent); priority/delayMs/now numeric-coercible.
314
+ -- verbatim (payload/metadata/backoff/keep/trace/parent are pre-encoded JSON,
315
+ -- "" = absent); priority/delayMs/now numeric-coercible. New jobs never carry
316
+ -- flow fields — only the FanOut ack writes those.
165
317
  local function insertJobRow(id, name, queue, payloadJson, metadataJson, priority,
166
- attemptsMax, backoffJson, keepJson, timeoutMs, dedupeKey, traceJson, delayMs, now, nowStr)
318
+ attemptsMax, backoffJson, keepJson, timeoutMs, dedupeKey, traceJson, parentJson, delayMs, now, nowStr)
167
319
  local seq = redis.call("INCR", prefix .. ":seq")
168
320
  local state = delayMs > 0 and "delayed" or "waiting"
169
321
  local runAt = now + delayMs
@@ -172,11 +324,14 @@ local function insertJobRow(id, name, queue, payloadJson, metadataJson, priority
172
324
  "payload", payloadJson, "metadata", metadataJson, "state", state,
173
325
  "priority", priority, "attemptsMax", attemptsMax, "attemptsMade", "0", "stalledCount", "0",
174
326
  "backoff", backoffJson, "keep", keepJson, "timeoutMs", timeoutMs,
175
- "cancelRequested", "0", "dedupeKey", dedupeKey, "trace", traceJson, "runAt", fmt(runAt), "enqueuedAt", nowStr,
327
+ "cancelRequested", "0", "dedupeKey", dedupeKey, "trace", traceJson, "parent", parentJson,
328
+ "runAt", fmt(runAt), "enqueuedAt", nowStr,
176
329
  "processedAt", "", "finishedAt", "", "exit", "", "failedReason", "",
177
330
  "lockToken", "", "lockExpiresAt", "", "seq", fmt(seq))
178
331
  redis.call("ZADD", prefix .. ":all", now, id)
179
- if state == "waiting" then
332
+ ${indexes.name ? ` redis.call("ZADD", bynameKey(name), now, id)\n` : ""}${
333
+ indexes.queue ? ` redis.call("ZADD", byqueueKey(queue), now, id)\n` : ""
334
+ } if state == "waiting" then
180
335
  addWaiting(queue, tonumber(priority), seq, id)
181
336
  else
182
337
  redis.call("ZADD", delayedKey(queue), runAt, id)
@@ -187,11 +342,12 @@ end
187
342
 
188
343
  /**
189
344
  * enqueue(prefix, idMode, id, name, queue, payloadJson, metadataJson,
190
- * priority, attemptsMax, backoffJson, keepJson, timeoutMs, delayMs, now)
345
+ * priority, attemptsMax, backoffJson, keepJson, timeoutMs, delayMs,
346
+ * now, dedupe..., traceJson, parentJson)
191
347
  * idMode: "user" (dedup no-op), "generated" (collision -> retry sentinel),
192
348
  * "auto" (j-<seq>, in-script collision loop).
193
349
  */
194
- export const enqueue = Redis.script(
350
+ export const enqueue = (HELPERS: string) => Redis.script(
195
351
  (
196
352
  prefix: string,
197
353
  idMode: string,
@@ -211,7 +367,8 @@ export const enqueue = Redis.script(
211
367
  dedupeTtlMs: string,
212
368
  dedupeExtend: string,
213
369
  dedupeReplace: string,
214
- traceJson: string
370
+ traceJson: string,
371
+ parentJson: string
215
372
  ) => [
216
373
  prefix,
217
374
  idMode,
@@ -231,7 +388,8 @@ export const enqueue = Redis.script(
231
388
  dedupeTtlMs,
232
389
  dedupeExtend,
233
390
  dedupeReplace,
234
- traceJson
391
+ traceJson,
392
+ parentJson
235
393
  ],
236
394
  {
237
395
  numberOfKeys: 0,
@@ -305,7 +463,7 @@ if idMode == "auto" then
305
463
  if id == "" then return '{"error":"id"}' end
306
464
  end
307
465
  insertJobRow(id, ARGV[4], queue, ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11],
308
- ARGV[12], dKey, ARGV[19], delayMs, now, nowStr)
466
+ ARGV[12], dKey, ARGV[19], ARGV[20], delayMs, now, nowStr)
309
467
  if dKey ~= "" then
310
468
  local sk = dedupeStoreKey(name, dKey)
311
469
  redis.call("DEL", sk)
@@ -327,7 +485,7 @@ return '{"id":' .. cjson.encode(id) .. ',"duplicate":false,"wake":true}'
327
485
  * waiting job whose name matches. Returns the claimed record (HGETALL pairs)
328
486
  * or an Empty result with the earliest matching delayed runAt.
329
487
  */
330
- export const claim = Redis.script(
488
+ export const claim = (HELPERS: string) => Redis.script(
331
489
  (prefix: string, queue: string, namesJson: string, token: string, lockDurationMs: number, now: number) => [
332
490
  prefix,
333
491
  queue,
@@ -404,7 +562,7 @@ return cjson.encode({ empty = true, nextRunAt = nextRunAt })
404
562
  * Token-guarded. Retry on a cancel-requested job finishes it as cancelled
405
563
  * (cancellation wins over revival, mirroring release/recoverStalled).
406
564
  */
407
- export const ack = Redis.script(
565
+ export const ack = (HELPERS: string) => Redis.script(
408
566
  (prefix: string, id: string, token: string, outcomeTag: string, exitJson: string, delayMs: number, now: number) => [
409
567
  prefix,
410
568
  id,
@@ -445,6 +603,7 @@ local function finish(newState, storeExit, outcome, ledgerExit)
445
603
  redis.call("ZADD", prefix .. ":finished:" .. newState, now, id)
446
604
  redis.call("ZADD", terminalKey(name, newState), now, id)
447
605
  appendAttempt(id, outcome, startedAt, nowStr, ledgerExit)
606
+ appendOutbox(id, newState)
448
607
  releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
449
608
  applyKeep(name, newState, redis.call("HGET", jk, "keep"), now)
450
609
  end
@@ -483,7 +642,7 @@ return '{"ok":true}'
483
642
  * release(prefix, id, token, now) — hand the job back without consuming an
484
643
  * attempt; a pending cancel wins and finishes the job instead.
485
644
  */
486
- export const release = Redis.script(
645
+ export const release = (HELPERS: string) => Redis.script(
487
646
  (prefix: string, id: string, token: string, now: number) => [prefix, id, token, now],
488
647
  {
489
648
  numberOfKeys: 0,
@@ -517,7 +676,7 @@ return '{"ok":true,"wake":true,"queue":' .. cjson.encode(queue) .. '}'
517
676
  * extendLocks(prefix, locksJson, durationMs, now) -> { lost, cancel }
518
677
  * Cancel-requested locks are reported, not extended.
519
678
  */
520
- export const extendLocks = Redis.script(
679
+ export const extendLocks = (HELPERS: string) => Redis.script(
521
680
  (prefix: string, locksJson: string, durationMs: number, now: number) => [prefix, locksJson, durationMs, now],
522
681
  {
523
682
  numberOfKeys: 0,
@@ -546,7 +705,7 @@ return cjson.encode({ lost = lost, cancel = cancel })
546
705
  * recoverStalled(prefix, maxStalledCount, now) -> recovered [{id, failed}]
547
706
  * A pending cancel finishes the job as cancelled (not reported as recovered).
548
707
  */
549
- export const recoverStalled = Redis.script(
708
+ export const recoverStalled = (HELPERS: string) => Redis.script(
550
709
  (prefix: string, maxStalledCount: number, now: number) => [prefix, maxStalledCount, now],
551
710
  {
552
711
  numberOfKeys: 0,
@@ -576,6 +735,7 @@ for _, id in ipairs(expired) do
576
735
  countsAdd(queue, "failed", 1)
577
736
  redis.call("ZADD", prefix .. ":finished:failed", now, id)
578
737
  redis.call("ZADD", terminalKey(name, "failed"), now, id)
738
+ appendOutbox(id, "failed")
579
739
  releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
580
740
  recovered[#recovered + 1] = { id = id, failed = true }
581
741
  else
@@ -597,7 +757,7 @@ return cjson.encode(recovered)
597
757
  ).withReturnType<string>()
598
758
 
599
759
  /** getJob(prefix, id) -> HGETALL pairs (empty array when missing). */
600
- export const getJob = Redis.script(
760
+ export const getJob = (HELPERS: string) => Redis.script(
601
761
  (prefix: string, id: string) => [prefix, id],
602
762
  {
603
763
  numberOfKeys: 0,
@@ -610,46 +770,117 @@ return cjson.encode(record)
610
770
  ).withReturnType<string>()
611
771
 
612
772
  /**
613
- * list(prefix, filtersJson, cursor, limit)
614
- * Keyset pagination over p:all, newest first (enqueuedAt DESC, id DESC).
773
+ * list(prefix, sourcesJson, order, filtersJson, cursor, limit)
774
+ *
775
+ * Indexed list. The DRIVER routes the query to the narrowest zset(s) — see
776
+ * `RedisJobStore`'s routing matrix — and this script merges those sorted
777
+ * sources by (score, id) in the requested direction, pages past the
778
+ * exclusive `<orderValue>:<id>` keyset cursor, loads each candidate row, and
779
+ * applies the residual predicates until `limit` matches accumulate. Sources
780
+ * must be plain-id-membered zsets whose score IS the requested order value
781
+ * (`all`/`byname:`/`byqueue:` = enqueuedAt, `delayed:<queue>` = runAt,
782
+ * `finished:`/`terminal:` = finishedAt) and must be pairwise disjoint; the
783
+ * waiting zsets (seq-prefixed members, priority scores) are never routed
784
+ * here. A member whose job hash is gone is an orphan: it is ZREM'd from the
785
+ * source being scanned (self-heal) and skipped.
615
786
  */
616
- export const list = Redis.script(
617
- (prefix: string, filtersJson: string, cursor: string, limit: number) => [prefix, filtersJson, cursor, limit],
618
- {
619
- numberOfKeys: 0,
620
- lua: `${HELPERS}
621
- -- A filter that Redis's cjson cannot decode (e.g. lone-surrogate escapes)
622
- -- degrades to an empty page instead of a script error.
623
- local okFilters, filters = pcall(cjson.decode, ARGV[2])
624
- if not okFilters then return '{"items":[],"more":false}' end
787
+ export const list = (HELPERS: string) =>
788
+ Redis.script(
789
+ (prefix: string, sourcesJson: string, order: string, filtersJson: string, cursor: string, limit: number) => [
790
+ prefix,
791
+ sourcesJson,
792
+ order,
793
+ filtersJson,
794
+ cursor,
795
+ limit
796
+ ],
797
+ {
798
+ numberOfKeys: 0,
799
+ lua: `${HELPERS}
800
+ -- Input that Redis's cjson cannot decode (e.g. lone-surrogate escapes in a
801
+ -- filter or key name) degrades to an empty page instead of a script error.
802
+ local okSources, sources = pcall(cjson.decode, ARGV[2])
803
+ local okFilters, filters = pcall(cjson.decode, ARGV[4])
804
+ if not okSources or not okFilters then return '{"items":[],"more":false}' end
805
+ local desc = ARGV[3] == "desc"
625
806
  local stateSet = nil
626
807
  if filters.states ~= nil then
627
808
  stateSet = {}
628
809
  for _, s in ipairs(filters.states) do stateSet[s] = true end
629
810
  end
630
811
  local cursorAt, cursorId = nil, nil
631
- if ARGV[3] ~= "" then
632
- local split = string.find(ARGV[3], ":", 1, true)
812
+ if ARGV[5] ~= "" then
813
+ local split = string.find(ARGV[5], ":", 1, true)
633
814
  if split ~= nil then
634
- cursorAt = tonumber(string.sub(ARGV[3], 1, split - 1))
635
- cursorId = string.sub(ARGV[3], split + 1)
815
+ cursorAt = tonumber(string.sub(ARGV[5], 1, split - 1))
816
+ cursorId = string.sub(ARGV[5], split + 1)
636
817
  end
637
818
  if cursorAt == nil then cursorId = nil end
638
819
  end
639
- local limit = tonumber(ARGV[4])
820
+ local limit = tonumber(ARGV[6])
821
+ -- One buffered iterator per source. offset counts consumed members still in
822
+ -- the zset — a self-healed orphan decrements it, because its ZREM shifts
823
+ -- every later rank down by one — so refills stay exact under in-script
824
+ -- removals. The score bound is the cursor score (inclusive; the per-item
825
+ -- check below excludes ids at or before the cursor within that score).
826
+ local iters = {}
827
+ for i = 1, #sources do
828
+ iters[i] = { key = sources[i], offset = 0, buf = {}, pos = 1, exhausted = false }
829
+ end
830
+ local function head(it)
831
+ if it.pos > #it.buf then
832
+ if it.exhausted then return nil end
833
+ if desc then
834
+ it.buf = redis.call("ZREVRANGEBYSCORE", it.key, cursorAt == nil and "+inf" or fmt(cursorAt), "-inf",
835
+ "WITHSCORES", "LIMIT", it.offset, 100)
836
+ else
837
+ it.buf = redis.call("ZRANGEBYSCORE", it.key, cursorAt == nil and "-inf" or fmt(cursorAt), "+inf",
838
+ "WITHSCORES", "LIMIT", it.offset, 100)
839
+ end
840
+ it.pos = 1
841
+ if #it.buf == 0 then
842
+ it.exhausted = true
843
+ return nil
844
+ end
845
+ end
846
+ return it.buf[it.pos], tonumber(it.buf[it.pos + 1])
847
+ end
640
848
  local items = {}
641
- local moreMatches = false
642
- local max = cursorAt == nil and "+inf" or fmt(cursorAt)
643
- local offset = 0
849
+ local more = false
644
850
  while true do
645
- local batch = redis.call("ZREVRANGEBYSCORE", prefix .. ":all", max, "-inf", "WITHSCORES", "LIMIT", offset, 100)
646
- if #batch == 0 then break end
647
- for i = 1, #batch, 2 do
648
- local id = batch[i]
649
- local at = tonumber(batch[i + 1])
650
- -- Skip up to and including the cursor position within its score.
651
- if cursorAt == nil or at < cursorAt or (at == cursorAt and id < cursorId) then
652
- local jk = jobKey(id)
851
+ -- The best head across sources: (score, id) in the requested direction (a
852
+ -- score-tied range comes back in member-lex order, so ids line up too).
853
+ local best, bestId, bestAt = nil, nil, nil
854
+ for i = 1, #iters do
855
+ local id, at = head(iters[i])
856
+ if id ~= nil then
857
+ local wins = best == nil
858
+ if not wins then
859
+ if at ~= bestAt then
860
+ wins = (desc and at > bestAt) or (not desc and at < bestAt)
861
+ else
862
+ wins = (desc and id > bestId) or (not desc and id < bestId)
863
+ end
864
+ end
865
+ if wins then
866
+ best, bestId, bestAt = iters[i], id, at
867
+ end
868
+ end
869
+ end
870
+ if best == nil then break end
871
+ best.pos = best.pos + 2
872
+ best.offset = best.offset + 1
873
+ -- Exclusive keyset cursor: skip up to and including the cursor position.
874
+ local past = cursorAt == nil
875
+ or (desc and (bestAt < cursorAt or (bestAt == cursorAt and bestId < cursorId)))
876
+ or (not desc and (bestAt > cursorAt or (bestAt == cursorAt and bestId > cursorId)))
877
+ if past then
878
+ local jk = jobKey(bestId)
879
+ if redis.call("EXISTS", jk) == 0 then
880
+ -- Orphaned index member (hash removed out of band): self-heal.
881
+ redis.call("ZREM", best.key, bestId)
882
+ best.offset = best.offset - 1
883
+ else
653
884
  local matches = true
654
885
  if filters.queue ~= nil and redis.call("HGET", jk, "queue") ~= filters.queue then matches = false end
655
886
  if matches and filters.name ~= nil and redis.call("HGET", jk, "name") ~= filters.name then matches = false end
@@ -669,24 +900,76 @@ while true do
669
900
  end
670
901
  if matches then
671
902
  if #items >= limit then
672
- moreMatches = true
903
+ more = true
673
904
  break
674
905
  end
675
906
  items[#items + 1] = redis.call("HGETALL", jk)
676
907
  end
677
908
  end
678
909
  end
679
- if moreMatches then break end
680
- offset = offset + 100
681
910
  end
682
911
  if #items == 0 then return '{"items":[],"more":false}' end
683
- return cjson.encode({ items = items, more = moreMatches })
912
+ return cjson.encode({ items = items, more = more })
913
+ `
914
+ }
915
+ ).withReturnType<string>()
916
+
917
+ /**
918
+ * indexMembers(prefix, kind, idsJson) -> "1"
919
+ *
920
+ * Index one ZSCAN chunk of `p:all` members during a full rebuild. The driver
921
+ * owns the ZSCAN cursor loop (linear, tie-immune, guaranteed to terminate,
922
+ * and guaranteed to return every element present for the whole scan); this
923
+ * script only does the per-chunk work, so the single-threaded server is
924
+ * never held for the whole keyspace. Re-visited members and rows indexed
925
+ * live by insertJobRow mid-scan are idempotent ZADDs.
926
+ */
927
+ export const indexMembers = (HELPERS: string) => Redis.script(
928
+ (prefix: string, kind: string, idsJson: string) => [prefix, kind, idsJson],
929
+ {
930
+ numberOfKeys: 0,
931
+ lua: `${HELPERS}
932
+ local kind = ARGV[2]
933
+ for _, id in ipairs(cjson.decode(ARGV[3])) do
934
+ indexInto(kind, id)
935
+ end
936
+ return "1"
937
+ `
938
+ }
939
+ ).withReturnType<string>()
940
+
941
+ /**
942
+ * indexTailPage(prefix, kind, min, offset, pageSize) -> scanned count
943
+ *
944
+ * One bounded page of the boot-time tail heal: index every `p:all` member
945
+ * with score >= min (the previous marker minus a safety margin). Plain
946
+ * LIMIT offset paging — tails are small, and a rank-shift skip from a
947
+ * concurrent delete is covered by the margin plus the next boot's heal.
948
+ */
949
+ export const indexTailPage = (HELPERS: string) => Redis.script(
950
+ (prefix: string, kind: string, min: string, offset: number, pageSize: number) => [
951
+ prefix,
952
+ kind,
953
+ min,
954
+ offset,
955
+ pageSize
956
+ ],
957
+ {
958
+ numberOfKeys: 0,
959
+ lua: `${HELPERS}
960
+ local kind = ARGV[2]
961
+ local batch = redis.call("ZRANGEBYSCORE", prefix .. ":all", ARGV[3], "+inf",
962
+ "LIMIT", tonumber(ARGV[4]), tonumber(ARGV[5]))
963
+ for _, id in ipairs(batch) do
964
+ indexInto(kind, id)
965
+ end
966
+ return tostring(#batch)
684
967
  `
685
968
  }
686
969
  ).withReturnType<string>()
687
970
 
688
971
  /** counts(prefix) -> HGETALL pairs of p:counts. */
689
- export const counts = Redis.script(
972
+ export const counts = (HELPERS: string) => Redis.script(
690
973
  (prefix: string) => [prefix],
691
974
  {
692
975
  numberOfKeys: 0,
@@ -698,15 +981,16 @@ return cjson.encode(pairs_)
698
981
  }
699
982
  ).withReturnType<string>()
700
983
 
701
- /** remove(prefix, id) -> removed boolean (active jobs are refused). */
702
- export const remove = Redis.script(
984
+ /** remove(prefix, id) -> removed boolean (active/waiting-children refused). */
985
+ export const remove = (HELPERS: string) => Redis.script(
703
986
  (prefix: string, id: string) => [prefix, id],
704
987
  {
705
988
  numberOfKeys: 0,
706
989
  lua: `${HELPERS}
707
990
  local id = ARGV[2]
708
991
  local jk = jobKey(id)
709
- if redis.call("EXISTS", jk) == 0 or redis.call("HGET", jk, "state") == "active" then
992
+ local state = redis.call("HGET", jk, "state")
993
+ if redis.call("EXISTS", jk) == 0 or state == "active" or state == "waiting-children" then
710
994
  return "0"
711
995
  end
712
996
  deleteJob(id)
@@ -716,7 +1000,7 @@ return "1"
716
1000
  ).withReturnType<string>()
717
1001
 
718
1002
  /** retry(prefix, id, now) — failed -> waiting with a fresh budget. */
719
- export const retry = Redis.script(
1003
+ export const retry = (HELPERS: string) => Redis.script(
720
1004
  (prefix: string, id: string, now: number) => [prefix, id, now],
721
1005
  {
722
1006
  numberOfKeys: 0,
@@ -746,10 +1030,12 @@ return '{"ok":true,"queue":' .. cjson.encode(queue) .. '}'
746
1030
  ).withReturnType<string>()
747
1031
 
748
1032
  /**
749
- * cancel(prefix, id, now) — waiting/delayed become terminal; active gets the
750
- * cancel-request flag; terminal states are refused.
1033
+ * cancel(prefix, id, now) — waiting/delayed/waiting-children become terminal
1034
+ * (a parked flow parent also flips its remaining pending rows to cancelled,
1035
+ * handing them to the cascade sweep); active gets the cancel-request flag;
1036
+ * terminal states are refused.
751
1037
  */
752
- export const cancel = Redis.script(
1038
+ export const cancel = (HELPERS: string) => Redis.script(
753
1039
  (prefix: string, id: string, now: number) => [prefix, id, now],
754
1040
  {
755
1041
  numberOfKeys: 0,
@@ -762,13 +1048,16 @@ if state == "active" then
762
1048
  redis.call("HSET", jk, "cancelRequested", "1")
763
1049
  return '{"ok":true}'
764
1050
  end
765
- if state ~= "waiting" and state ~= "delayed" then
1051
+ if state ~= "waiting" and state ~= "delayed" and state ~= "waiting-children" then
766
1052
  return '{"error":"state","state":' .. cjson.encode(state) .. '}'
767
1053
  end
768
1054
  local nowStr = ARGV[3]
769
1055
  local now = tonumber(nowStr)
770
1056
  local queue = redis.call("HGET", jk, "queue")
771
1057
  local name = redis.call("HGET", jk, "name")
1058
+ if state == "waiting-children" then
1059
+ settleMarkRows(id)
1060
+ end
772
1061
  remWaiting(queue, id)
773
1062
  redis.call("ZREM", delayedKey(queue), id)
774
1063
  redis.call("HSET", jk, "state", "cancelled", "finishedAt", nowStr, "cancelRequested", "0")
@@ -777,6 +1066,7 @@ countsAdd(queue, "cancelled", 1)
777
1066
  redis.call("ZADD", prefix .. ":finished:cancelled", now, id)
778
1067
  redis.call("ZADD", terminalKey(name, "cancelled"), now, id)
779
1068
  appendAttempt(id, "cancelled", redis.call("HGET", jk, "processedAt"), nowStr, "")
1069
+ appendOutbox(id, "cancelled")
780
1070
  releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
781
1071
  applyKeep(name, "cancelled", redis.call("HGET", jk, "keep"), now)
782
1072
  return '{"ok":true}'
@@ -785,7 +1075,7 @@ return '{"ok":true}'
785
1075
  ).withReturnType<string>()
786
1076
 
787
1077
  /** promote(prefix, id, now) — delayed -> waiting now. */
788
- export const promote = Redis.script(
1078
+ export const promote = (HELPERS: string) => Redis.script(
789
1079
  (prefix: string, id: string, now: number) => [prefix, id, now],
790
1080
  {
791
1081
  numberOfKeys: 0,
@@ -817,7 +1107,7 @@ return '{"ok":true,"queue":' .. cjson.encode(queue) .. '}'
817
1107
  * digits) and empty arrays ({}). An unchanged cadence (cron/tz/everyMs)
818
1108
  * preserves the stored nextRunAt.
819
1109
  */
820
- export const upsertSchedule = Redis.script(
1110
+ export const upsertSchedule = (HELPERS: string) => Redis.script(
821
1111
  (
822
1112
  prefix: string,
823
1113
  key: string,
@@ -882,7 +1172,7 @@ return '{"ok":true}'
882
1172
  ).withReturnType<string>()
883
1173
 
884
1174
  /** removeSchedule(prefix, key) -> existed boolean. */
885
- export const removeSchedule = Redis.script(
1175
+ export const removeSchedule = (HELPERS: string) => Redis.script(
886
1176
  (prefix: string, key: string) => [prefix, key],
887
1177
  {
888
1178
  numberOfKeys: 0,
@@ -895,7 +1185,7 @@ return tostring(removed)
895
1185
  ).withReturnType<string>()
896
1186
 
897
1187
  /** listSchedules(prefix, filtersJson) ordered by nextRunAt ascending. */
898
- export const listSchedules = Redis.script(
1188
+ export const listSchedules = (HELPERS: string) => Redis.script(
899
1189
  (prefix: string, filtersJson: string) => [prefix, filtersJson],
900
1190
  {
901
1191
  numberOfKeys: 0,
@@ -920,7 +1210,7 @@ return cjson.encode(out)
920
1210
  ).withReturnType<string>()
921
1211
 
922
1212
  /** dueSchedules(prefix, now) ordered by nextRunAt ascending. */
923
- export const dueSchedules = Redis.script(
1213
+ export const dueSchedules = (HELPERS: string) => Redis.script(
924
1214
  (prefix: string, now: number) => [prefix, now],
925
1215
  {
926
1216
  numberOfKeys: 0,
@@ -937,7 +1227,7 @@ return cjson.encode(out)
937
1227
  ).withReturnType<string>()
938
1228
 
939
1229
  /** advanceSchedule(prefix, key, expectedRunAt, nextRunAt) — conditional CAS. */
940
- export const advanceSchedule = Redis.script(
1230
+ export const advanceSchedule = (HELPERS: string) => Redis.script(
941
1231
  (prefix: string, key: string, expectedRunAt: number, nextRunAt: number) => [prefix, key, expectedRunAt, nextRunAt],
942
1232
  {
943
1233
  numberOfKeys: 0,
@@ -956,12 +1246,12 @@ return "1"
956
1246
  /**
957
1247
  * tickSchedule(prefix, key, expectedRunAt, nextRunAt, id, name, queue,
958
1248
  * payloadJson, metadataJson, priority, attemptsMax, backoffJson, keepJson,
959
- * timeoutMs, traceJson, delayMs, now) -> "1" fired | "0"
1249
+ * timeoutMs, traceJson, parentJson, delayMs, now) -> "1" fired | "0"
960
1250
  * Atomic occurrence claim: the nextRunAt CAS and the tick job's insert run
961
1251
  * in one script, so a stale sweeper can never re-fire a slot — even after
962
1252
  * retention pruned the previous slot's job row.
963
1253
  */
964
- export const tickSchedule = Redis.script(
1254
+ export const tickSchedule = (HELPERS: string) => Redis.script(
965
1255
  (
966
1256
  prefix: string,
967
1257
  key: string,
@@ -978,6 +1268,7 @@ export const tickSchedule = Redis.script(
978
1268
  keepJson: string,
979
1269
  timeoutMs: string,
980
1270
  traceJson: string,
1271
+ parentJson: string,
981
1272
  delayMs: number,
982
1273
  now: number
983
1274
  ) => [
@@ -996,6 +1287,7 @@ export const tickSchedule = Redis.script(
996
1287
  keepJson,
997
1288
  timeoutMs,
998
1289
  traceJson,
1290
+ parentJson,
999
1291
  delayMs,
1000
1292
  now
1001
1293
  ],
@@ -1013,7 +1305,7 @@ local id = ARGV[5]
1013
1305
  -- schedule still advances, but nothing new fires.
1014
1306
  if redis.call("EXISTS", jobKey(id)) == 1 then return "0" end
1015
1307
  insertJobRow(id, ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12], ARGV[13],
1016
- ARGV[14], "", ARGV[15], tonumber(ARGV[16]), tonumber(ARGV[17]), ARGV[17])
1308
+ ARGV[14], "", ARGV[15], ARGV[16], tonumber(ARGV[17]), tonumber(ARGV[18]), ARGV[18])
1017
1309
  return "1"
1018
1310
  `
1019
1311
  }
@@ -1021,12 +1313,12 @@ return "1"
1021
1313
 
1022
1314
  /**
1023
1315
  * enqueueMany(prefix, now, count, ...items) -> JSON array of per-item results
1024
- * ({id, duplicate} | {collision} | {error}). Items are 13-ARGV strides:
1316
+ * ({id, duplicate} | {collision} | {error}). Items are 14-ARGV strides:
1025
1317
  * idMode, id, name, queue, payloadJson, metadataJson, priority, attemptsMax,
1026
- * backoffJson, keepJson, timeoutMs, traceJson, delayMs. Plain (non-dedup)
1027
- * items only — the caller routes dedup items through \`enqueue\`.
1318
+ * backoffJson, keepJson, timeoutMs, traceJson, parentJson, delayMs. Plain
1319
+ * (non-dedup) items only — the caller routes dedup items through \`enqueue\`.
1028
1320
  */
1029
- export const enqueueMany = Redis.script(
1321
+ export const enqueueMany = (HELPERS: string) => Redis.script(
1030
1322
  (prefix: string, now: number, count: number, items: ReadonlyArray<string>) => [prefix, now, count, ...items],
1031
1323
  {
1032
1324
  numberOfKeys: 0,
@@ -1036,7 +1328,7 @@ local nowStr = ARGV[2]
1036
1328
  local count = tonumber(ARGV[3])
1037
1329
  local out = {}
1038
1330
  for i = 0, count - 1 do
1039
- local base = 3 + i * 13
1331
+ local base = 3 + i * 14
1040
1332
  local idMode = ARGV[base + 1]
1041
1333
  local id = ARGV[base + 2]
1042
1334
  local result
@@ -1064,7 +1356,7 @@ for i = 0, count - 1 do
1064
1356
  else
1065
1357
  insertJobRow(id, ARGV[base + 3], ARGV[base + 4], ARGV[base + 5], ARGV[base + 6],
1066
1358
  ARGV[base + 7], ARGV[base + 8], ARGV[base + 9], ARGV[base + 10], ARGV[base + 11],
1067
- "", ARGV[base + 12], tonumber(ARGV[base + 13]), now, nowStr)
1359
+ "", ARGV[base + 12], ARGV[base + 13], tonumber(ARGV[base + 14]), now, nowStr)
1068
1360
  result = '{"id":' .. cjson.encode(id) .. ',"duplicate":false}'
1069
1361
  end
1070
1362
  end
@@ -1081,7 +1373,7 @@ return "[" .. table.concat(out, ",") .. "]"
1081
1373
  * min(store ceiling, per-row keep.age). The caller advances the offset by
1082
1374
  * (scanned - deleted) and stops when a page comes back short.
1083
1375
  */
1084
- export const sweepState = Redis.script(
1376
+ export const sweepState = (HELPERS: string) => Redis.script(
1085
1377
  (prefix: string, state: string, ttlMs: string, limit: number, offset: number, now: number) => [
1086
1378
  prefix,
1087
1379
  state,
@@ -1130,7 +1422,9 @@ for i = 1, #batch, 2 do
1130
1422
  end
1131
1423
  end
1132
1424
  end
1133
- if cutoffAge ~= nil and finishedAt <= now - cutoffAge then
1425
+ -- Parents still owing cascade cancels are exempt from automatic
1426
+ -- retention; they count as scanned so the offset cursor walks past them.
1427
+ if cutoffAge ~= nil and finishedAt <= now - cutoffAge and not owesCascades(id) then
1134
1428
  deleteJob(id)
1135
1429
  deleted = deleted + 1
1136
1430
  end
@@ -1146,7 +1440,7 @@ return cjson.encode({ scanned = scanned, deleted = deleted })
1146
1440
  * caller loops until 0): expired windows, then pending pointers (+inf)
1147
1441
  * whose job is gone or terminal.
1148
1442
  */
1149
- export const sweepDedupes = Redis.script(
1443
+ export const sweepDedupes = (HELPERS: string) => Redis.script(
1150
1444
  (prefix: string, limit: number, now: number) => [prefix, limit, now],
1151
1445
  {
1152
1446
  numberOfKeys: 0,
@@ -1188,3 +1482,382 @@ return tostring(migrated + #expired + removedPending)
1188
1482
  `
1189
1483
  }
1190
1484
  ).withReturnType<string>()
1485
+
1486
+ /**
1487
+ * fanOut(prefix, id, token, final, clearStaged, failFast, total, now, count,
1488
+ * ...items) — the FanOut ack, chunked like enqueueMany. Items are 5-ARGV
1489
+ * strides: childKey, storeKey, childJobId, name, specJson.
1490
+ *
1491
+ * Every chunk is lock-token-guarded. Non-final chunks ONLY stage dependency
1492
+ * rows; the final chunk stages its rows, appends the "fanned-out" ledger
1493
+ * entry (no attempt consumed), persists the manifest (flowFailFast,
1494
+ * flowPending = total, zeroed outcome counters), and transitions the parent:
1495
+ * pending > 0 parks it in
1496
+ * waiting-children (no pending zset — never claimable), pending == 0 settles
1497
+ * it straight to runnable collect. A parent whose manifest already landed
1498
+ * keeps it untouched (rows are not re-created; the transition follows the
1499
+ * persisted pending count), so a double fan-out cannot duplicate children.
1500
+ * When staging starts with no manifest, the FIRST chunk clears previously
1501
+ * staged rows — a crashed earlier attempt may have staged different keys. A
1502
+ * raced cancelRequested wins: the parent settles cancelled and its pending
1503
+ * rows flip to cancelled (cascade work for the flow sweeper).
1504
+ */
1505
+ export const fanOut = (HELPERS: string) => Redis.script(
1506
+ (
1507
+ prefix: string,
1508
+ id: string,
1509
+ token: string,
1510
+ final: string,
1511
+ clearStaged: string,
1512
+ failFast: string,
1513
+ total: number,
1514
+ now: number,
1515
+ count: number,
1516
+ items: ReadonlyArray<string>
1517
+ ) => [prefix, id, token, final, clearStaged, failFast, total, now, count, ...items],
1518
+ {
1519
+ numberOfKeys: 0,
1520
+ lua: `${HELPERS}
1521
+ local id = ARGV[2]
1522
+ local jk = jobKey(id)
1523
+ if redis.call("EXISTS", jk) == 0 then return '{"error":"notfound"}' end
1524
+ if redis.call("HGET", jk, "state") ~= "active" or redis.call("HGET", jk, "lockToken") ~= ARGV[3] then
1525
+ return '{"error":"locklost"}'
1526
+ end
1527
+ local final = ARGV[4] == "1"
1528
+ local clearStaged = ARGV[5] == "1"
1529
+ local failFast = ARGV[6]
1530
+ local total = tonumber(ARGV[7])
1531
+ local nowStr = ARGV[8]
1532
+ local now = tonumber(nowStr)
1533
+ local count = tonumber(ARGV[9])
1534
+ local pendingStr = redis.call("HGET", jk, "flowPending")
1535
+ local hasManifest = pendingStr ~= false and pendingStr ~= ""
1536
+ if not hasManifest then
1537
+ if clearStaged then
1538
+ local staged = redis.call("ZRANGE", flowIndexKey(id), 0, -1)
1539
+ for i = 1, #staged do
1540
+ redis.call("DEL", flowChildKey(id, staged[i]))
1541
+ redis.call("ZREM", prefix .. ":flowpending", flowMember(id, staged[i]))
1542
+ redis.call("ZREM", prefix .. ":flowcascade", flowMember(id, staged[i]))
1543
+ end
1544
+ redis.call("DEL", flowIndexKey(id))
1545
+ end
1546
+ for i = 0, count - 1 do
1547
+ local base = 9 + i * 5
1548
+ local childKey = ARGV[base + 1]
1549
+ local rk = flowChildKey(id, childKey)
1550
+ redis.call("DEL", rk)
1551
+ redis.call("HSET", rk,
1552
+ "childKey", childKey, "storeKey", ARGV[base + 2], "childJobId", ARGV[base + 3],
1553
+ "name", ARGV[base + 4], "spec", ARGV[base + 5],
1554
+ "status", "pending", "exit", "", "failedReason", "", "cascaded", "0",
1555
+ "pendingSince", nowStr)
1556
+ redis.call("ZADD", flowIndexKey(id), 0, childKey)
1557
+ redis.call("ZADD", prefix .. ":flowpending", now, flowMember(id, childKey))
1558
+ end
1559
+ end
1560
+ if not final then return '{"ok":true}' end
1561
+ local queue = redis.call("HGET", jk, "queue")
1562
+ local name = redis.call("HGET", jk, "name")
1563
+ local startedAt = redis.call("HGET", jk, "processedAt")
1564
+ -- A fan-out is a phase transition, not a completed run: no attemptsMade.
1565
+ appendAttempt(id, "fanned-out", startedAt, nowStr, "")
1566
+ redis.call("ZREM", prefix .. ":active", id)
1567
+ local pending
1568
+ if hasManifest then
1569
+ pending = tonumber(pendingStr) or 0
1570
+ else
1571
+ redis.call("HSET", jk, "flowFailFast", failFast, "flowPending", fmt(total),
1572
+ "flowCompleted", "0", "flowFailed", "0", "flowCancelled", "0")
1573
+ pending = total
1574
+ end
1575
+ if redis.call("HGET", jk, "cancelRequested") == "1" then
1576
+ -- A cancel raced the fan-out: cancellation wins. The rows exist and get
1577
+ -- marked, so the sweeper cascades (mostly no-op cancels for
1578
+ -- never-enqueued children).
1579
+ settleMarkRows(id)
1580
+ finishCancelled(id, queue, name, startedAt, now, nowStr)
1581
+ return '{"ok":true}'
1582
+ end
1583
+ if pending > 0 then
1584
+ redis.call("HSET", jk, "state", "waiting-children", "lockToken", "", "lockExpiresAt", "")
1585
+ countsAdd(queue, "active", -1)
1586
+ countsAdd(queue, "waiting-children", 1)
1587
+ return '{"ok":true}'
1588
+ end
1589
+ -- Empty (or fully recorded) manifest: settle straight to runnable collect.
1590
+ local seq = redis.call("INCR", prefix .. ":seq")
1591
+ redis.call("HSET", jk, "state", "waiting", "runAt", nowStr, "seq", fmt(seq),
1592
+ "lockToken", "", "lockExpiresAt", "")
1593
+ local priority = tonumber(redis.call("HGET", jk, "priority")) or 0
1594
+ addWaiting(queue, priority, seq, id)
1595
+ countsAdd(queue, "active", -1)
1596
+ countsAdd(queue, "waiting", 1)
1597
+ return '{"ok":true,"wake":true,"queue":' .. cjson.encode(queue) .. '}'
1598
+ `
1599
+ }
1600
+ ).withReturnType<string>()
1601
+
1602
+ /**
1603
+ * recordChildResults(prefix, now, count, ...items) -> {results, wakes}.
1604
+ * Items are 5-ARGV strides: flowId, childKey, outcome, exitJson,
1605
+ * failedReason; results are positional {applied, parentSettled}; wakes names
1606
+ * the queues of parents that resumed runnable. One atomic batch; reports may
1607
+ * span flows.
1608
+ *
1609
+ * Phase 1 applies EVERY row update — idempotent, only while the row is
1610
+ * still pending; an applied report moves the child from the parent's `pending`
1611
+ * counter to its outcome counter and marks the row cascaded (the outcome
1612
+ * came FROM the child's store). Phase 2 settles each touched flow at most
1613
+ * once: the FIRST applied failed report in batch order under fail-fast
1614
+ * (terminal store-side failure; remaining rows flip to cancelled; wins the
1615
+ * tie over pending==0 — and this settle IS a nested parent's terminal
1616
+ * transition, so its own report goes to the outbox here), else pending==0
1617
+ * resumes the parent runnable at the flow's LAST applied report's index.
1618
+ */
1619
+ export const recordChildResults = (HELPERS: string) => Redis.script(
1620
+ (prefix: string, now: number, count: number, items: ReadonlyArray<string>) => [prefix, now, count, ...items],
1621
+ {
1622
+ numberOfKeys: 0,
1623
+ lua: `${HELPERS}
1624
+ local nowStr = ARGV[2]
1625
+ local now = tonumber(nowStr)
1626
+ local count = tonumber(ARGV[3])
1627
+ local applied = {}
1628
+ local settled = {}
1629
+ -- Phase 1: every row update lands before any settle decision, so a
1630
+ -- completed batch-mate keeps its real outcome even when an earlier
1631
+ -- batch-mate settles the flow fail-fast.
1632
+ local touched = {}
1633
+ local touchedOrder = {}
1634
+ for i = 1, count do
1635
+ local base = 3 + (i - 1) * 5
1636
+ local flowId = ARGV[base + 1]
1637
+ local childKey = ARGV[base + 2]
1638
+ local outcome = ARGV[base + 3]
1639
+ applied[i] = false
1640
+ local rk = flowChildKey(flowId, childKey)
1641
+ if redis.call("HGET", rk, "status") == "pending" then
1642
+ redis.call("HSET", rk, "status", outcome, "exit", ARGV[base + 4],
1643
+ "failedReason", ARGV[base + 5], "cascaded", "1")
1644
+ redis.call("ZREM", prefix .. ":flowpending", flowMember(flowId, childKey))
1645
+ applied[i] = true
1646
+ local jk = jobKey(flowId)
1647
+ local pendingStr = redis.call("HGET", jk, "flowPending")
1648
+ if pendingStr ~= false and pendingStr ~= "" then
1649
+ redis.call("HSET", jk, "flowPending", fmt(math.max(0, (tonumber(pendingStr) or 0) - 1)))
1650
+ local bucket = outcome == "completed" and "flowCompleted"
1651
+ or outcome == "failed" and "flowFailed" or "flowCancelled"
1652
+ redis.call("HINCRBY", jk, bucket, 1)
1653
+ end
1654
+ local touch = touched[flowId]
1655
+ if touch == nil then
1656
+ touch = { last = i }
1657
+ touched[flowId] = touch
1658
+ touchedOrder[#touchedOrder + 1] = flowId
1659
+ end
1660
+ touch.last = i
1661
+ if outcome == "failed" and touch.firstFailed == nil then
1662
+ touch.firstFailed = i
1663
+ touch.failedKey = childKey
1664
+ end
1665
+ end
1666
+ end
1667
+ -- Phase 2: at most one settle per touched flow; fail-fast wins ties.
1668
+ local wakes = {}
1669
+ for _, flowId in ipairs(touchedOrder) do
1670
+ local touch = touched[flowId]
1671
+ local jk = jobKey(flowId)
1672
+ local pendingStr = redis.call("HGET", jk, "flowPending")
1673
+ if pendingStr ~= false and pendingStr ~= ""
1674
+ and redis.call("HGET", jk, "state") == "waiting-children" then
1675
+ local queue = redis.call("HGET", jk, "queue")
1676
+ if redis.call("HGET", jk, "flowFailFast") == "1" and touch.firstFailed ~= nil then
1677
+ -- First applied failure settles the parent terminally, store-side
1678
+ -- (failedReason, no exit — like stall exhaustion) and marks the
1679
+ -- remaining rows in the same op.
1680
+ local name = redis.call("HGET", jk, "name")
1681
+ local startedAt = redis.call("HGET", jk, "processedAt")
1682
+ settleMarkRows(flowId)
1683
+ redis.call("HSET", jk, "state", "failed", "finishedAt", nowStr, "cancelRequested", "0",
1684
+ "failedReason", 'effect-mq: flow child "' .. touch.failedKey .. '" failed')
1685
+ countsAdd(queue, "waiting-children", -1)
1686
+ countsAdd(queue, "failed", 1)
1687
+ redis.call("ZADD", prefix .. ":finished:failed", now, flowId)
1688
+ redis.call("ZADD", terminalKey(name, "failed"), now, flowId)
1689
+ appendAttempt(flowId, "failed", startedAt, nowStr, "")
1690
+ -- A nested parent reports upward: this settle IS its terminal
1691
+ -- transition, with no worker ack to hook.
1692
+ appendOutbox(flowId, "failed")
1693
+ releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), flowId, now)
1694
+ applyKeep(name, "failed", redis.call("HGET", jk, "keep"), now)
1695
+ settled[touch.firstFailed] = true
1696
+ elseif tonumber(pendingStr) == 0 then
1697
+ -- All children settled: the parent resumes runnable, phase collect.
1698
+ local seq = redis.call("INCR", prefix .. ":seq")
1699
+ redis.call("HSET", jk, "state", "waiting", "runAt", nowStr, "seq", fmt(seq))
1700
+ local priority = tonumber(redis.call("HGET", jk, "priority")) or 0
1701
+ addWaiting(queue, priority, seq, flowId)
1702
+ countsAdd(queue, "waiting-children", -1)
1703
+ countsAdd(queue, "waiting", 1)
1704
+ wakes[#wakes + 1] = queue
1705
+ settled[touch.last] = true
1706
+ end
1707
+ end
1708
+ end
1709
+ local out = {}
1710
+ for i = 1, count do
1711
+ out[i] = '{"applied":' .. (applied[i] and "true" or "false")
1712
+ .. ',"parentSettled":' .. (settled[i] and "true" or "false") .. '}'
1713
+ end
1714
+ local wakesJson = #wakes == 0 and "[]" or cjson.encode(wakes)
1715
+ return '{"results":[' .. table.concat(out, ",") .. '],"wakes":' .. wakesJson .. '}'
1716
+ `
1717
+ }
1718
+ ).withReturnType<string>()
1719
+
1720
+ /**
1721
+ * listChildResults(prefix, flowId, cursor, limit) — child-key order via
1722
+ * ZRANGEBYLEX over the per-flow index; cursor = last childKey (exclusive).
1723
+ * Items are positional HMGET tuples (the field list must stay in lockstep
1724
+ * with the driver's `toChildRecord`) — the full spec JSON stays server-side.
1725
+ */
1726
+ export const listChildResults = (HELPERS: string) => Redis.script(
1727
+ (prefix: string, flowId: string, cursor: string, limit: number) => [prefix, flowId, cursor, limit],
1728
+ {
1729
+ numberOfKeys: 0,
1730
+ lua: `${HELPERS}
1731
+ local flowId = ARGV[2]
1732
+ local min = ARGV[3] == "" and "-" or ("(" .. ARGV[3])
1733
+ local limit = tonumber(ARGV[4])
1734
+ local keys = redis.call("ZRANGEBYLEX", flowIndexKey(flowId), min, "+", "LIMIT", 0, limit + 1)
1735
+ local items = {}
1736
+ for i = 1, math.min(#keys, limit) do
1737
+ items[#items + 1] = redis.call("HMGET", flowChildKey(flowId, keys[i]),
1738
+ "childKey", "storeKey", "childJobId", "name", "status", "exit", "failedReason", "cascaded")
1739
+ end
1740
+ if #items == 0 then return '{"items":[],"more":false}' end
1741
+ return cjson.encode({ items = items, more = #keys > limit })
1742
+ `
1743
+ }
1744
+ ).withReturnType<string>()
1745
+
1746
+ /**
1747
+ * flowSweepWork(prefix, pendingAgeMs, limit, now) -> {reconcile, cascade}
1748
+ * grouped by flowId. Reconcile scans the flowpending zset (score = the row's
1749
+ * sweep-eligibility timestamp) and yields rows whose parent is still parked
1750
+ * in waiting-children. Every scanned member is re-armed or purged so no
1751
+ * member can pin the head of the page:
1752
+ *
1753
+ * - parent missing, or terminal with NO manifest: a crashed fan-out's
1754
+ * staged orphan — purge the row, its index member, and the flowpending
1755
+ * member (left alone their old scores would head-pin every page forever);
1756
+ * - parent alive but not waiting-children (mid-staging): re-arm to now so
1757
+ * it rotates behind fresher work;
1758
+ * - returned rows: re-arm to now (defer-on-return, per the contract) so a
1759
+ * full page rotates across sweeps;
1760
+ * - a non-pending row's membership is stale (rows never return to pending):
1761
+ * self-heal by removing the member.
1762
+ *
1763
+ * Cascade lists flowcascade members (cancels still owed to child stores).
1764
+ * Spec JSON strings pass through untouched — the script never cjson-decodes
1765
+ * stored payloads (precision, lone surrogates).
1766
+ */
1767
+ export const flowSweepWork = (HELPERS: string) => Redis.script(
1768
+ (prefix: string, pendingAgeMs: number, limit: number, now: number) => [prefix, pendingAgeMs, limit, now],
1769
+ {
1770
+ numberOfKeys: 0,
1771
+ lua: `${HELPERS}
1772
+ local pendingAgeMs = tonumber(ARGV[2])
1773
+ local limit = tonumber(ARGV[3])
1774
+ local now = tonumber(ARGV[4])
1775
+ local reconcile, rIndex = {}, {}
1776
+ local due = redis.call("ZRANGEBYSCORE", prefix .. ":flowpending", "-inf", now - pendingAgeMs,
1777
+ "LIMIT", 0, limit)
1778
+ for _, member in ipairs(due) do
1779
+ local sep = string.find(member, "\0", 1, true)
1780
+ local flowId = string.sub(member, 1, sep - 1)
1781
+ local childKey = string.sub(member, sep + 1)
1782
+ local jk = jobKey(flowId)
1783
+ local state = redis.call("HGET", jk, "state")
1784
+ local pendingField = redis.call("HGET", jk, "flowPending")
1785
+ local hasManifest = pendingField ~= false and pendingField ~= ""
1786
+ local terminal = state == "completed" or state == "failed" or state == "cancelled"
1787
+ if state == false or (terminal and not hasManifest) then
1788
+ -- Staged orphan (parent gone, or went terminal before a manifest ever
1789
+ -- landed): purge, or its old score head-pins every future page.
1790
+ redis.call("DEL", flowChildKey(flowId, childKey))
1791
+ redis.call("ZREM", flowIndexKey(flowId), childKey)
1792
+ redis.call("ZREM", prefix .. ":flowpending", member)
1793
+ elseif state ~= "waiting-children" then
1794
+ -- Alive but not parked (e.g. mid-staging): not this sweep's business —
1795
+ -- rotate it behind fresher work.
1796
+ redis.call("ZADD", prefix .. ":flowpending", now, member)
1797
+ elseif redis.call("HGET", flowChildKey(flowId, childKey), "status") ~= "pending" then
1798
+ -- Stale membership (rows never return to pending): self-heal.
1799
+ redis.call("ZREM", prefix .. ":flowpending", member)
1800
+ else
1801
+ local rk = flowChildKey(flowId, childKey)
1802
+ local group = rIndex[flowId]
1803
+ if group == nil then
1804
+ group = { flowId = flowId, children = {} }
1805
+ rIndex[flowId] = group
1806
+ reconcile[#reconcile + 1] = group
1807
+ end
1808
+ group.children[#group.children + 1] = {
1809
+ childKey = childKey,
1810
+ storeKey = redis.call("HGET", rk, "storeKey"),
1811
+ spec = redis.call("HGET", rk, "spec")
1812
+ }
1813
+ -- Returned work defers its own re-eligibility by one age: page rotation.
1814
+ redis.call("ZADD", prefix .. ":flowpending", now, member)
1815
+ end
1816
+ end
1817
+ local cascade, cIndex = {}, {}
1818
+ local owed = redis.call("ZRANGE", prefix .. ":flowcascade", 0, limit - 1)
1819
+ for _, member in ipairs(owed) do
1820
+ local sep = string.find(member, "\0", 1, true)
1821
+ local flowId = string.sub(member, 1, sep - 1)
1822
+ local childKey = string.sub(member, sep + 1)
1823
+ local rk = flowChildKey(flowId, childKey)
1824
+ if redis.call("EXISTS", rk) == 1 then
1825
+ local group = cIndex[flowId]
1826
+ if group == nil then
1827
+ group = { flowId = flowId, children = {} }
1828
+ cIndex[flowId] = group
1829
+ cascade[#cascade + 1] = group
1830
+ end
1831
+ group.children[#group.children + 1] = {
1832
+ childKey = childKey,
1833
+ storeKey = redis.call("HGET", rk, "storeKey"),
1834
+ childJobId = redis.call("HGET", rk, "childJobId")
1835
+ }
1836
+ end
1837
+ end
1838
+ return cjson.encode({ reconcile = reconcile, cascade = cascade })
1839
+ `
1840
+ }
1841
+ ).withReturnType<string>()
1842
+
1843
+ /**
1844
+ * markChildrenCascaded(prefix, flowId, childKeysJson) — idempotent; unknown
1845
+ * keys are ignored (their index members are still cleared).
1846
+ */
1847
+ export const markChildrenCascaded = (HELPERS: string) => Redis.script(
1848
+ (prefix: string, flowId: string, childKeysJson: string) => [prefix, flowId, childKeysJson],
1849
+ {
1850
+ numberOfKeys: 0,
1851
+ lua: `${HELPERS}
1852
+ local flowId = ARGV[2]
1853
+ for _, key in ipairs(cjson.decode(ARGV[3])) do
1854
+ local rk = flowChildKey(flowId, key)
1855
+ if redis.call("EXISTS", rk) == 1 then
1856
+ redis.call("HSET", rk, "cascaded", "1")
1857
+ end
1858
+ redis.call("ZREM", prefix .. ":flowcascade", flowMember(flowId, key))
1859
+ end
1860
+ return '{"ok":true}'
1861
+ `
1862
+ }
1863
+ ).withReturnType<string>()