effect-mq 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +85 -17
  2. package/dist/Flow.d.ts +381 -0
  3. package/dist/Flow.d.ts.map +1 -0
  4. package/dist/Flow.js +340 -0
  5. package/dist/Flow.js.map +1 -0
  6. package/dist/Job.d.ts +31 -6
  7. package/dist/Job.d.ts.map +1 -1
  8. package/dist/Job.js +16 -2
  9. package/dist/Job.js.map +1 -1
  10. package/dist/JobStore.d.ts +312 -10
  11. package/dist/JobStore.d.ts.map +1 -1
  12. package/dist/JobStore.js.map +1 -1
  13. package/dist/MemoryJobStore.d.ts.map +1 -1
  14. package/dist/MemoryJobStore.js +334 -7
  15. package/dist/MemoryJobStore.js.map +1 -1
  16. package/dist/Metrics.d.ts +31 -0
  17. package/dist/Metrics.d.ts.map +1 -1
  18. package/dist/Metrics.js +39 -0
  19. package/dist/Metrics.js.map +1 -1
  20. package/dist/Worker.d.ts +120 -11
  21. package/dist/Worker.d.ts.map +1 -1
  22. package/dist/Worker.js +452 -26
  23. package/dist/Worker.js.map +1 -1
  24. package/dist/drizzle-postgres/DrizzleJobStore.d.ts +19 -1
  25. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  26. package/dist/drizzle-postgres/DrizzleJobStore.js +653 -77
  27. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  28. package/dist/drizzle-postgres/schema.d.ts +293 -3
  29. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  30. package/dist/drizzle-postgres/schema.js +66 -1
  31. package/dist/drizzle-postgres/schema.js.map +1 -1
  32. package/dist/index.d.ts +7 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +7 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  37. package/dist/redis/RedisJobStore.js +219 -18
  38. package/dist/redis/RedisJobStore.js.map +1 -1
  39. package/dist/redis/scripts.d.ts +117 -10
  40. package/dist/redis/scripts.d.ts.map +1 -1
  41. package/dist/redis/scripts.js +492 -25
  42. package/dist/redis/scripts.js.map +1 -1
  43. package/dist/testing/conformance.d.ts +6 -0
  44. package/dist/testing/conformance.d.ts.map +1 -1
  45. package/dist/testing/conformance.js +728 -1
  46. package/dist/testing/conformance.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/Flow.ts +778 -0
  49. package/src/Job.ts +35 -11
  50. package/src/JobStore.ts +339 -9
  51. package/src/MemoryJobStore.ts +370 -7
  52. package/src/Metrics.ts +43 -0
  53. package/src/Worker.ts +726 -37
  54. package/src/drizzle-postgres/DrizzleJobStore.ts +817 -78
  55. package/src/drizzle-postgres/schema.ts +92 -0
  56. package/src/index.ts +8 -0
  57. package/src/redis/RedisJobStore.ts +289 -8
  58. package/src/redis/scripts.ts +524 -24
  59. package/src/testing/conformance.ts +945 -1
@@ -28,6 +28,23 @@
28
28
  * - `p:schedules` / `p:schedule:<key>` ZSET by nextRunAt + HASH per record
29
29
  * - `p:dedupe:<name>\0<key>` HASH {jobId, expiresAt} + `p:dedupes` index
30
30
  * ZSET (score = window expiry, +inf = pending)
31
+ * - `p:flowchild:<flowId>\0<childKey>` HASH of one flow dependency row
32
+ * - `p:flowchildren:<flowId>` ZSET (score 0, member = childKey; ZRANGEBYLEX
33
+ * gives child-key order + cursor pagination)
34
+ * - `p:flowpending` ZSET, member `<flowId>\0<childKey>`, score =
35
+ * sweep-eligibility timestamp (pendingSince at
36
+ * staging, re-armed on sweep return)
37
+ * - `p:flowcascade` ZSET, score 0, member `<flowId>\0<childKey>`
38
+ * (cancels still owed to child stores)
39
+ * - `p:flowoutbox` ZSET, score = seq from `p:flowoutbox:seq`,
40
+ * member `<seq>\0<report json>` — undelivered
41
+ * child-result reports (see OutboxEntry). The
42
+ * seq prefix makes every member id a peek
43
+ * cursor that survives deletions (see
44
+ * peekOutbox in RedisJobStore)
45
+ *
46
+ * `waiting-children` parents live only in the job hash, `p:all`, and
47
+ * `p:counts` — never in a pending zset, so `claim` can never return them.
31
48
  *
32
49
  * @since 0.2.0
33
50
  */
@@ -70,7 +87,79 @@ local function appendAttempt(id, outcome, startedAt, finishedAt, exitJson)
70
87
  '{"attempt":' .. n .. ',"startedAt":' .. started .. ',"finishedAt":' .. finishedAt ..
71
88
  ',"outcome":"' .. outcome .. '"' .. ex .. '}')
72
89
  end
73
- -- Remove a job and every index entry that references it.
90
+ -- The outbox invariant: every operation that moves a job carrying a parent
91
+ -- envelope INTO a terminal state appends its child-result report here, in
92
+ -- the same script. MUST run after the terminal fields (exit, failedReason)
93
+ -- are written — the report is built from the hash. The parent envelope and
94
+ -- exit are spliced as raw JSON (never cjson-decoded: precision, surrogates);
95
+ -- exit/failedReason keys are OMITTED when absent, like the attempts ledger.
96
+ local function appendOutbox(id, outcome)
97
+ local jk = jobKey(id)
98
+ local parentJson = redis.call("HGET", jk, "parent")
99
+ if parentJson == false or parentJson == "" then return end
100
+ local exitJson = redis.call("HGET", jk, "exit")
101
+ local failedReason = redis.call("HGET", jk, "failedReason")
102
+ local ex = (exitJson == false or exitJson == "") and "" or (',"exit":' .. exitJson)
103
+ local fr = (failedReason == false or failedReason == "") and ""
104
+ or (',"failedReason":' .. cjson.encode(failedReason))
105
+ local seq = redis.call("INCR", prefix .. ":flowoutbox:seq")
106
+ redis.call("ZADD", prefix .. ":flowoutbox", seq, fmt(seq) .. "\0" ..
107
+ '{"parent":' .. parentJson .. ',"outcome":"' .. outcome .. '"' .. ex .. fr .. '}')
108
+ end
109
+ -- Flow dependency rows live in the parent store, one hash per child plus a
110
+ -- per-flow member index (childKey order via ZRANGEBYLEX). Row keys and the
111
+ -- sweep-index members join flowId and childKey with NUL — ids and keys may
112
+ -- both contain ":", so a printable separator could alias two distinct
113
+ -- (flowId, childKey) pairs onto one row.
114
+ local function flowChildKey(flowId, childKey) return prefix .. ":flowchild:" .. flowId .. "\0" .. childKey end
115
+ local function flowIndexKey(flowId) return prefix .. ":flowchildren:" .. flowId end
116
+ local function flowMember(flowId, childKey) return flowId .. "\0" .. childKey end
117
+ -- Settle-time marking: remaining pending rows flip to cancelled (NOT
118
+ -- cascaded — the sweeper still owes the child stores real cancels), moving
119
+ -- from the pending sweep index to the cascade sweep index. Returns the
120
+ -- number of rows flipped, for the flow counters.
121
+ local function markPendingRowsCancelled(flowId)
122
+ local keys = redis.call("ZRANGE", flowIndexKey(flowId), 0, -1)
123
+ local marked = 0
124
+ for i = 1, #keys do
125
+ local rk = flowChildKey(flowId, keys[i])
126
+ if redis.call("HGET", rk, "status") == "pending" then
127
+ redis.call("HSET", rk, "status", "cancelled", "cascaded", "0")
128
+ redis.call("ZREM", prefix .. ":flowpending", flowMember(flowId, keys[i]))
129
+ redis.call("ZADD", prefix .. ":flowcascade", 0, flowMember(flowId, keys[i]))
130
+ marked = marked + 1
131
+ end
132
+ end
133
+ return marked
134
+ end
135
+ -- Settle-time marking plus the manifest counters: pending -> 0, cancelled +=
136
+ -- the rows flipped (the four counters always sum to the manifest size).
137
+ local function settleMarkRows(flowId)
138
+ local marked = markPendingRowsCancelled(flowId)
139
+ local jk = jobKey(flowId)
140
+ if redis.call("HEXISTS", jk, "flowPending") == 1 then
141
+ redis.call("HSET", jk, "flowPending", "0")
142
+ if marked > 0 then redis.call("HINCRBY", jk, "flowCancelled", marked) end
143
+ end
144
+ end
145
+ -- A settled flow parent whose rows still owe cascade cancels is exempt from
146
+ -- AUTOMATIC retention (keep policies, the history sweep): deleting it would
147
+ -- delete the only record that the child stores are still owed real cancels.
148
+ -- The explicit remove verb is the operator override and is NOT exempted.
149
+ -- Cheap for non-parents: the per-flow index only exists for fanned-out jobs.
150
+ local function owesCascades(id)
151
+ if redis.call("EXISTS", flowIndexKey(id)) == 0 then return false end
152
+ local keys = redis.call("ZRANGE", flowIndexKey(id), 0, -1)
153
+ for i = 1, #keys do
154
+ local rk = flowChildKey(id, keys[i])
155
+ if redis.call("HGET", rk, "status") == "cancelled" and redis.call("HGET", rk, "cascaded") == "0" then
156
+ return true
157
+ end
158
+ end
159
+ return false
160
+ end
161
+ -- Remove a job and every index entry that references it. A flow parent takes
162
+ -- its dependency rows and their sweep-index members with it (flowId = job id).
74
163
  local function deleteJob(id)
75
164
  local jk = jobKey(id)
76
165
  local queue = redis.call("HGET", jk, "queue")
@@ -84,6 +173,13 @@ local function deleteJob(id)
84
173
  redis.call("ZREM", terminalKey(name, state), id)
85
174
  remWaiting(queue, id)
86
175
  redis.call("ZREM", delayedKey(queue), id)
176
+ local children = redis.call("ZRANGE", flowIndexKey(id), 0, -1)
177
+ for i = 1, #children do
178
+ redis.call("DEL", flowChildKey(id, children[i]))
179
+ redis.call("ZREM", prefix .. ":flowpending", flowMember(id, children[i]))
180
+ redis.call("ZREM", prefix .. ":flowcascade", flowMember(id, children[i]))
181
+ end
182
+ redis.call("DEL", flowIndexKey(id))
87
183
  redis.call("DEL", jk, attemptsKey(id))
88
184
  end
89
185
  -- Terminal retention for one name+state group. Correctness over speed: the
@@ -106,9 +202,13 @@ local function applyKeep(name, state, keepJson, now)
106
202
  end
107
203
  end
108
204
  local tkey = terminalKey(name, state)
205
+ -- Retention exemption: parents still owing cascade cancels are spared
206
+ -- (they still occupy a keep-count slot, exactly like the memory driver).
109
207
  if keep.ageMs ~= nil then
110
208
  local old = redis.call("ZRANGEBYSCORE", tkey, "-inf", now - keep.ageMs)
111
- for i = 1, #old do deleteJob(old[i]) end
209
+ for i = 1, #old do
210
+ if not owesCascades(old[i]) then deleteJob(old[i]) end
211
+ end
112
212
  end
113
213
  -- Floor + clamp: a fractional/negative count must degrade like the memory
114
214
  -- driver's slice(), never error mid-script (writes before an error stick).
@@ -127,7 +227,9 @@ local function applyKeep(name, state, keepJson, now)
127
227
  if a.fa ~= b.fa then return a.fa > b.fa end
128
228
  return a.seq > b.seq
129
229
  end)
130
- for i = count + 1, #arr do deleteJob(arr[i].id) end
230
+ for i = count + 1, #arr do
231
+ if not owesCascades(arr[i].id) then deleteJob(arr[i].id) end
232
+ end
131
233
  end
132
234
  end
133
235
  local function dedupeStoreKey(name, key) return prefix .. ":dedupe:" .. name .. "\0" .. key end
@@ -155,14 +257,16 @@ local function finishCancelled(id, queue, name, startedAt, now, nowStr)
155
257
  redis.call("ZADD", prefix .. ":finished:cancelled", now, id)
156
258
  redis.call("ZADD", terminalKey(name, "cancelled"), now, id)
157
259
  appendAttempt(id, "cancelled", startedAt, nowStr, "")
260
+ appendOutbox(id, "cancelled")
158
261
  releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
159
262
  applyKeep(name, "cancelled", redis.call("HGET", jk, "keep"), now)
160
263
  end
161
264
  -- Insert one fresh job row plus every index entry. String params are stored
162
- -- verbatim (payload/metadata/backoff/keep/trace are pre-encoded JSON, "" =
163
- -- absent); priority/delayMs/now numeric-coercible.
265
+ -- verbatim (payload/metadata/backoff/keep/trace/parent are pre-encoded JSON,
266
+ -- "" = absent); priority/delayMs/now numeric-coercible. New jobs never carry
267
+ -- flow fields — only the FanOut ack writes those.
164
268
  local function insertJobRow(id, name, queue, payloadJson, metadataJson, priority,
165
- attemptsMax, backoffJson, keepJson, timeoutMs, dedupeKey, traceJson, delayMs, now, nowStr)
269
+ attemptsMax, backoffJson, keepJson, timeoutMs, dedupeKey, traceJson, parentJson, delayMs, now, nowStr)
166
270
  local seq = redis.call("INCR", prefix .. ":seq")
167
271
  local state = delayMs > 0 and "delayed" or "waiting"
168
272
  local runAt = now + delayMs
@@ -171,7 +275,8 @@ local function insertJobRow(id, name, queue, payloadJson, metadataJson, priority
171
275
  "payload", payloadJson, "metadata", metadataJson, "state", state,
172
276
  "priority", priority, "attemptsMax", attemptsMax, "attemptsMade", "0", "stalledCount", "0",
173
277
  "backoff", backoffJson, "keep", keepJson, "timeoutMs", timeoutMs,
174
- "cancelRequested", "0", "dedupeKey", dedupeKey, "trace", traceJson, "runAt", fmt(runAt), "enqueuedAt", nowStr,
278
+ "cancelRequested", "0", "dedupeKey", dedupeKey, "trace", traceJson, "parent", parentJson,
279
+ "runAt", fmt(runAt), "enqueuedAt", nowStr,
175
280
  "processedAt", "", "finishedAt", "", "exit", "", "failedReason", "",
176
281
  "lockToken", "", "lockExpiresAt", "", "seq", fmt(seq))
177
282
  redis.call("ZADD", prefix .. ":all", now, id)
@@ -185,11 +290,12 @@ end
185
290
  `;
186
291
  /**
187
292
  * enqueue(prefix, idMode, id, name, queue, payloadJson, metadataJson,
188
- * priority, attemptsMax, backoffJson, keepJson, timeoutMs, delayMs, now)
293
+ * priority, attemptsMax, backoffJson, keepJson, timeoutMs, delayMs,
294
+ * now, dedupe..., traceJson, parentJson)
189
295
  * idMode: "user" (dedup no-op), "generated" (collision -> retry sentinel),
190
296
  * "auto" (j-<seq>, in-script collision loop).
191
297
  */
192
- export const enqueue = Redis.script((prefix, idMode, id, name, queue, payloadJson, metadataJson, priority, attemptsMax, backoffJson, keepJson, timeoutMs, delayMs, now, dedupeKey, dedupeTtlMs, dedupeExtend, dedupeReplace, traceJson) => [
298
+ export const enqueue = Redis.script((prefix, idMode, id, name, queue, payloadJson, metadataJson, priority, attemptsMax, backoffJson, keepJson, timeoutMs, delayMs, now, dedupeKey, dedupeTtlMs, dedupeExtend, dedupeReplace, traceJson, parentJson) => [
193
299
  prefix,
194
300
  idMode,
195
301
  id,
@@ -208,7 +314,8 @@ export const enqueue = Redis.script((prefix, idMode, id, name, queue, payloadJso
208
314
  dedupeTtlMs,
209
315
  dedupeExtend,
210
316
  dedupeReplace,
211
- traceJson
317
+ traceJson,
318
+ parentJson
212
319
  ], {
213
320
  numberOfKeys: 0,
214
321
  lua: `${HELPERS}
@@ -281,7 +388,7 @@ if idMode == "auto" then
281
388
  if id == "" then return '{"error":"id"}' end
282
389
  end
283
390
  insertJobRow(id, ARGV[4], queue, ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11],
284
- ARGV[12], dKey, ARGV[19], delayMs, now, nowStr)
391
+ ARGV[12], dKey, ARGV[19], ARGV[20], delayMs, now, nowStr)
285
392
  if dKey ~= "" then
286
393
  local sk = dedupeStoreKey(name, dKey)
287
394
  redis.call("DEL", sk)
@@ -413,6 +520,7 @@ local function finish(newState, storeExit, outcome, ledgerExit)
413
520
  redis.call("ZADD", prefix .. ":finished:" .. newState, now, id)
414
521
  redis.call("ZADD", terminalKey(name, newState), now, id)
415
522
  appendAttempt(id, outcome, startedAt, nowStr, ledgerExit)
523
+ appendOutbox(id, newState)
416
524
  releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
417
525
  applyKeep(name, newState, redis.call("HGET", jk, "keep"), now)
418
526
  end
@@ -532,6 +640,7 @@ for _, id in ipairs(expired) do
532
640
  countsAdd(queue, "failed", 1)
533
641
  redis.call("ZADD", prefix .. ":finished:failed", now, id)
534
642
  redis.call("ZADD", terminalKey(name, "failed"), now, id)
643
+ appendOutbox(id, "failed")
535
644
  releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
536
645
  recovered[#recovered + 1] = { id = id, failed = true }
537
646
  else
@@ -640,13 +749,14 @@ if #pairs_ == 0 then return "[]" end
640
749
  return cjson.encode(pairs_)
641
750
  `
642
751
  }).withReturnType();
643
- /** remove(prefix, id) -> removed boolean (active jobs are refused). */
752
+ /** remove(prefix, id) -> removed boolean (active/waiting-children refused). */
644
753
  export const remove = Redis.script((prefix, id) => [prefix, id], {
645
754
  numberOfKeys: 0,
646
755
  lua: `${HELPERS}
647
756
  local id = ARGV[2]
648
757
  local jk = jobKey(id)
649
- if redis.call("EXISTS", jk) == 0 or redis.call("HGET", jk, "state") == "active" then
758
+ local state = redis.call("HGET", jk, "state")
759
+ if redis.call("EXISTS", jk) == 0 or state == "active" or state == "waiting-children" then
650
760
  return "0"
651
761
  end
652
762
  deleteJob(id)
@@ -680,8 +790,10 @@ return '{"ok":true,"queue":' .. cjson.encode(queue) .. '}'
680
790
  `
681
791
  }).withReturnType();
682
792
  /**
683
- * cancel(prefix, id, now) — waiting/delayed become terminal; active gets the
684
- * cancel-request flag; terminal states are refused.
793
+ * cancel(prefix, id, now) — waiting/delayed/waiting-children become terminal
794
+ * (a parked flow parent also flips its remaining pending rows to cancelled,
795
+ * handing them to the cascade sweep); active gets the cancel-request flag;
796
+ * terminal states are refused.
685
797
  */
686
798
  export const cancel = Redis.script((prefix, id, now) => [prefix, id, now], {
687
799
  numberOfKeys: 0,
@@ -694,13 +806,16 @@ if state == "active" then
694
806
  redis.call("HSET", jk, "cancelRequested", "1")
695
807
  return '{"ok":true}'
696
808
  end
697
- if state ~= "waiting" and state ~= "delayed" then
809
+ if state ~= "waiting" and state ~= "delayed" and state ~= "waiting-children" then
698
810
  return '{"error":"state","state":' .. cjson.encode(state) .. '}'
699
811
  end
700
812
  local nowStr = ARGV[3]
701
813
  local now = tonumber(nowStr)
702
814
  local queue = redis.call("HGET", jk, "queue")
703
815
  local name = redis.call("HGET", jk, "name")
816
+ if state == "waiting-children" then
817
+ settleMarkRows(id)
818
+ end
704
819
  remWaiting(queue, id)
705
820
  redis.call("ZREM", delayedKey(queue), id)
706
821
  redis.call("HSET", jk, "state", "cancelled", "finishedAt", nowStr, "cancelRequested", "0")
@@ -709,6 +824,7 @@ countsAdd(queue, "cancelled", 1)
709
824
  redis.call("ZADD", prefix .. ":finished:cancelled", now, id)
710
825
  redis.call("ZADD", terminalKey(name, "cancelled"), now, id)
711
826
  appendAttempt(id, "cancelled", redis.call("HGET", jk, "processedAt"), nowStr, "")
827
+ appendOutbox(id, "cancelled")
712
828
  releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
713
829
  applyKeep(name, "cancelled", redis.call("HGET", jk, "keep"), now)
714
830
  return '{"ok":true}'
@@ -845,12 +961,12 @@ return "1"
845
961
  /**
846
962
  * tickSchedule(prefix, key, expectedRunAt, nextRunAt, id, name, queue,
847
963
  * payloadJson, metadataJson, priority, attemptsMax, backoffJson, keepJson,
848
- * timeoutMs, traceJson, delayMs, now) -> "1" fired | "0"
964
+ * timeoutMs, traceJson, parentJson, delayMs, now) -> "1" fired | "0"
849
965
  * Atomic occurrence claim: the nextRunAt CAS and the tick job's insert run
850
966
  * in one script, so a stale sweeper can never re-fire a slot — even after
851
967
  * retention pruned the previous slot's job row.
852
968
  */
853
- export const tickSchedule = Redis.script((prefix, key, expectedRunAt, nextRunAt, id, name, queue, payloadJson, metadataJson, priority, attemptsMax, backoffJson, keepJson, timeoutMs, traceJson, delayMs, now) => [
969
+ export const tickSchedule = Redis.script((prefix, key, expectedRunAt, nextRunAt, id, name, queue, payloadJson, metadataJson, priority, attemptsMax, backoffJson, keepJson, timeoutMs, traceJson, parentJson, delayMs, now) => [
854
970
  prefix,
855
971
  key,
856
972
  expectedRunAt,
@@ -866,6 +982,7 @@ export const tickSchedule = Redis.script((prefix, key, expectedRunAt, nextRunAt,
866
982
  keepJson,
867
983
  timeoutMs,
868
984
  traceJson,
985
+ parentJson,
869
986
  delayMs,
870
987
  now
871
988
  ], {
@@ -882,16 +999,16 @@ local id = ARGV[5]
882
999
  -- schedule still advances, but nothing new fires.
883
1000
  if redis.call("EXISTS", jobKey(id)) == 1 then return "0" end
884
1001
  insertJobRow(id, ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12], ARGV[13],
885
- ARGV[14], "", ARGV[15], tonumber(ARGV[16]), tonumber(ARGV[17]), ARGV[17])
1002
+ ARGV[14], "", ARGV[15], ARGV[16], tonumber(ARGV[17]), tonumber(ARGV[18]), ARGV[18])
886
1003
  return "1"
887
1004
  `
888
1005
  }).withReturnType();
889
1006
  /**
890
1007
  * enqueueMany(prefix, now, count, ...items) -> JSON array of per-item results
891
- * ({id, duplicate} | {collision} | {error}). Items are 13-ARGV strides:
1008
+ * ({id, duplicate} | {collision} | {error}). Items are 14-ARGV strides:
892
1009
  * idMode, id, name, queue, payloadJson, metadataJson, priority, attemptsMax,
893
- * backoffJson, keepJson, timeoutMs, traceJson, delayMs. Plain (non-dedup)
894
- * items only — the caller routes dedup items through \`enqueue\`.
1010
+ * backoffJson, keepJson, timeoutMs, traceJson, parentJson, delayMs. Plain
1011
+ * (non-dedup) items only — the caller routes dedup items through \`enqueue\`.
895
1012
  */
896
1013
  export const enqueueMany = Redis.script((prefix, now, count, items) => [prefix, now, count, ...items], {
897
1014
  numberOfKeys: 0,
@@ -901,7 +1018,7 @@ local nowStr = ARGV[2]
901
1018
  local count = tonumber(ARGV[3])
902
1019
  local out = {}
903
1020
  for i = 0, count - 1 do
904
- local base = 3 + i * 13
1021
+ local base = 3 + i * 14
905
1022
  local idMode = ARGV[base + 1]
906
1023
  local id = ARGV[base + 2]
907
1024
  local result
@@ -929,7 +1046,7 @@ for i = 0, count - 1 do
929
1046
  else
930
1047
  insertJobRow(id, ARGV[base + 3], ARGV[base + 4], ARGV[base + 5], ARGV[base + 6],
931
1048
  ARGV[base + 7], ARGV[base + 8], ARGV[base + 9], ARGV[base + 10], ARGV[base + 11],
932
- "", ARGV[base + 12], tonumber(ARGV[base + 13]), now, nowStr)
1049
+ "", ARGV[base + 12], ARGV[base + 13], tonumber(ARGV[base + 14]), now, nowStr)
933
1050
  result = '{"id":' .. cjson.encode(id) .. ',"duplicate":false}'
934
1051
  end
935
1052
  end
@@ -991,7 +1108,9 @@ for i = 1, #batch, 2 do
991
1108
  end
992
1109
  end
993
1110
  end
994
- if cutoffAge ~= nil and finishedAt <= now - cutoffAge then
1111
+ -- Parents still owing cascade cancels are exempt from automatic
1112
+ -- retention; they count as scanned so the offset cursor walks past them.
1113
+ if cutoffAge ~= nil and finishedAt <= now - cutoffAge and not owesCascades(id) then
995
1114
  deleteJob(id)
996
1115
  deleted = deleted + 1
997
1116
  end
@@ -1044,4 +1163,352 @@ end
1044
1163
  return tostring(migrated + #expired + removedPending)
1045
1164
  `
1046
1165
  }).withReturnType();
1166
+ /**
1167
+ * fanOut(prefix, id, token, final, clearStaged, failFast, total, now, count,
1168
+ * ...items) — the FanOut ack, chunked like enqueueMany. Items are 5-ARGV
1169
+ * strides: childKey, storeKey, childJobId, name, specJson.
1170
+ *
1171
+ * Every chunk is lock-token-guarded. Non-final chunks ONLY stage dependency
1172
+ * rows; the final chunk stages its rows, appends the "fanned-out" ledger
1173
+ * entry (no attempt consumed), persists the manifest (flowFailFast,
1174
+ * flowPending = total, zeroed outcome counters), and transitions the parent:
1175
+ * pending > 0 parks it in
1176
+ * waiting-children (no pending zset — never claimable), pending == 0 settles
1177
+ * it straight to runnable collect. A parent whose manifest already landed
1178
+ * keeps it untouched (rows are not re-created; the transition follows the
1179
+ * persisted pending count), so a double fan-out cannot duplicate children.
1180
+ * When staging starts with no manifest, the FIRST chunk clears previously
1181
+ * staged rows — a crashed earlier attempt may have staged different keys. A
1182
+ * raced cancelRequested wins: the parent settles cancelled and its pending
1183
+ * rows flip to cancelled (cascade work for the flow sweeper).
1184
+ */
1185
+ export const fanOut = Redis.script((prefix, id, token, final, clearStaged, failFast, total, now, count, items) => [prefix, id, token, final, clearStaged, failFast, total, now, count, ...items], {
1186
+ numberOfKeys: 0,
1187
+ lua: `${HELPERS}
1188
+ local id = ARGV[2]
1189
+ local jk = jobKey(id)
1190
+ if redis.call("EXISTS", jk) == 0 then return '{"error":"notfound"}' end
1191
+ if redis.call("HGET", jk, "state") ~= "active" or redis.call("HGET", jk, "lockToken") ~= ARGV[3] then
1192
+ return '{"error":"locklost"}'
1193
+ end
1194
+ local final = ARGV[4] == "1"
1195
+ local clearStaged = ARGV[5] == "1"
1196
+ local failFast = ARGV[6]
1197
+ local total = tonumber(ARGV[7])
1198
+ local nowStr = ARGV[8]
1199
+ local now = tonumber(nowStr)
1200
+ local count = tonumber(ARGV[9])
1201
+ local pendingStr = redis.call("HGET", jk, "flowPending")
1202
+ local hasManifest = pendingStr ~= false and pendingStr ~= ""
1203
+ if not hasManifest then
1204
+ if clearStaged then
1205
+ local staged = redis.call("ZRANGE", flowIndexKey(id), 0, -1)
1206
+ for i = 1, #staged do
1207
+ redis.call("DEL", flowChildKey(id, staged[i]))
1208
+ redis.call("ZREM", prefix .. ":flowpending", flowMember(id, staged[i]))
1209
+ redis.call("ZREM", prefix .. ":flowcascade", flowMember(id, staged[i]))
1210
+ end
1211
+ redis.call("DEL", flowIndexKey(id))
1212
+ end
1213
+ for i = 0, count - 1 do
1214
+ local base = 9 + i * 5
1215
+ local childKey = ARGV[base + 1]
1216
+ local rk = flowChildKey(id, childKey)
1217
+ redis.call("DEL", rk)
1218
+ redis.call("HSET", rk,
1219
+ "childKey", childKey, "storeKey", ARGV[base + 2], "childJobId", ARGV[base + 3],
1220
+ "name", ARGV[base + 4], "spec", ARGV[base + 5],
1221
+ "status", "pending", "exit", "", "failedReason", "", "cascaded", "0",
1222
+ "pendingSince", nowStr)
1223
+ redis.call("ZADD", flowIndexKey(id), 0, childKey)
1224
+ redis.call("ZADD", prefix .. ":flowpending", now, flowMember(id, childKey))
1225
+ end
1226
+ end
1227
+ if not final then return '{"ok":true}' end
1228
+ local queue = redis.call("HGET", jk, "queue")
1229
+ local name = redis.call("HGET", jk, "name")
1230
+ local startedAt = redis.call("HGET", jk, "processedAt")
1231
+ -- A fan-out is a phase transition, not a completed run: no attemptsMade.
1232
+ appendAttempt(id, "fanned-out", startedAt, nowStr, "")
1233
+ redis.call("ZREM", prefix .. ":active", id)
1234
+ local pending
1235
+ if hasManifest then
1236
+ pending = tonumber(pendingStr) or 0
1237
+ else
1238
+ redis.call("HSET", jk, "flowFailFast", failFast, "flowPending", fmt(total),
1239
+ "flowCompleted", "0", "flowFailed", "0", "flowCancelled", "0")
1240
+ pending = total
1241
+ end
1242
+ if redis.call("HGET", jk, "cancelRequested") == "1" then
1243
+ -- A cancel raced the fan-out: cancellation wins. The rows exist and get
1244
+ -- marked, so the sweeper cascades (mostly no-op cancels for
1245
+ -- never-enqueued children).
1246
+ settleMarkRows(id)
1247
+ finishCancelled(id, queue, name, startedAt, now, nowStr)
1248
+ return '{"ok":true}'
1249
+ end
1250
+ if pending > 0 then
1251
+ redis.call("HSET", jk, "state", "waiting-children", "lockToken", "", "lockExpiresAt", "")
1252
+ countsAdd(queue, "active", -1)
1253
+ countsAdd(queue, "waiting-children", 1)
1254
+ return '{"ok":true}'
1255
+ end
1256
+ -- Empty (or fully recorded) manifest: settle straight to runnable collect.
1257
+ local seq = redis.call("INCR", prefix .. ":seq")
1258
+ redis.call("HSET", jk, "state", "waiting", "runAt", nowStr, "seq", fmt(seq),
1259
+ "lockToken", "", "lockExpiresAt", "")
1260
+ local priority = tonumber(redis.call("HGET", jk, "priority")) or 0
1261
+ addWaiting(queue, priority, seq, id)
1262
+ countsAdd(queue, "active", -1)
1263
+ countsAdd(queue, "waiting", 1)
1264
+ return '{"ok":true,"wake":true,"queue":' .. cjson.encode(queue) .. '}'
1265
+ `
1266
+ }).withReturnType();
1267
+ /**
1268
+ * recordChildResults(prefix, now, count, ...items) -> {results, wakes}.
1269
+ * Items are 5-ARGV strides: flowId, childKey, outcome, exitJson,
1270
+ * failedReason; results are positional {applied, parentSettled}; wakes names
1271
+ * the queues of parents that resumed runnable. One atomic batch; reports may
1272
+ * span flows.
1273
+ *
1274
+ * Phase 1 applies EVERY row update — idempotent, only while the row is
1275
+ * still pending; an applied report moves the child from the parent's `pending`
1276
+ * counter to its outcome counter and marks the row cascaded (the outcome
1277
+ * came FROM the child's store). Phase 2 settles each touched flow at most
1278
+ * once: the FIRST applied failed report in batch order under fail-fast
1279
+ * (terminal store-side failure; remaining rows flip to cancelled; wins the
1280
+ * tie over pending==0 — and this settle IS a nested parent's terminal
1281
+ * transition, so its own report goes to the outbox here), else pending==0
1282
+ * resumes the parent runnable at the flow's LAST applied report's index.
1283
+ */
1284
+ export const recordChildResults = Redis.script((prefix, now, count, items) => [prefix, now, count, ...items], {
1285
+ numberOfKeys: 0,
1286
+ lua: `${HELPERS}
1287
+ local nowStr = ARGV[2]
1288
+ local now = tonumber(nowStr)
1289
+ local count = tonumber(ARGV[3])
1290
+ local applied = {}
1291
+ local settled = {}
1292
+ -- Phase 1: every row update lands before any settle decision, so a
1293
+ -- completed batch-mate keeps its real outcome even when an earlier
1294
+ -- batch-mate settles the flow fail-fast.
1295
+ local touched = {}
1296
+ local touchedOrder = {}
1297
+ for i = 1, count do
1298
+ local base = 3 + (i - 1) * 5
1299
+ local flowId = ARGV[base + 1]
1300
+ local childKey = ARGV[base + 2]
1301
+ local outcome = ARGV[base + 3]
1302
+ applied[i] = false
1303
+ local rk = flowChildKey(flowId, childKey)
1304
+ if redis.call("HGET", rk, "status") == "pending" then
1305
+ redis.call("HSET", rk, "status", outcome, "exit", ARGV[base + 4],
1306
+ "failedReason", ARGV[base + 5], "cascaded", "1")
1307
+ redis.call("ZREM", prefix .. ":flowpending", flowMember(flowId, childKey))
1308
+ applied[i] = true
1309
+ local jk = jobKey(flowId)
1310
+ local pendingStr = redis.call("HGET", jk, "flowPending")
1311
+ if pendingStr ~= false and pendingStr ~= "" then
1312
+ redis.call("HSET", jk, "flowPending", fmt(math.max(0, (tonumber(pendingStr) or 0) - 1)))
1313
+ local bucket = outcome == "completed" and "flowCompleted"
1314
+ or outcome == "failed" and "flowFailed" or "flowCancelled"
1315
+ redis.call("HINCRBY", jk, bucket, 1)
1316
+ end
1317
+ local touch = touched[flowId]
1318
+ if touch == nil then
1319
+ touch = { last = i }
1320
+ touched[flowId] = touch
1321
+ touchedOrder[#touchedOrder + 1] = flowId
1322
+ end
1323
+ touch.last = i
1324
+ if outcome == "failed" and touch.firstFailed == nil then
1325
+ touch.firstFailed = i
1326
+ touch.failedKey = childKey
1327
+ end
1328
+ end
1329
+ end
1330
+ -- Phase 2: at most one settle per touched flow; fail-fast wins ties.
1331
+ local wakes = {}
1332
+ for _, flowId in ipairs(touchedOrder) do
1333
+ local touch = touched[flowId]
1334
+ local jk = jobKey(flowId)
1335
+ local pendingStr = redis.call("HGET", jk, "flowPending")
1336
+ if pendingStr ~= false and pendingStr ~= ""
1337
+ and redis.call("HGET", jk, "state") == "waiting-children" then
1338
+ local queue = redis.call("HGET", jk, "queue")
1339
+ if redis.call("HGET", jk, "flowFailFast") == "1" and touch.firstFailed ~= nil then
1340
+ -- First applied failure settles the parent terminally, store-side
1341
+ -- (failedReason, no exit — like stall exhaustion) and marks the
1342
+ -- remaining rows in the same op.
1343
+ local name = redis.call("HGET", jk, "name")
1344
+ local startedAt = redis.call("HGET", jk, "processedAt")
1345
+ settleMarkRows(flowId)
1346
+ redis.call("HSET", jk, "state", "failed", "finishedAt", nowStr, "cancelRequested", "0",
1347
+ "failedReason", 'effect-mq: flow child "' .. touch.failedKey .. '" failed')
1348
+ countsAdd(queue, "waiting-children", -1)
1349
+ countsAdd(queue, "failed", 1)
1350
+ redis.call("ZADD", prefix .. ":finished:failed", now, flowId)
1351
+ redis.call("ZADD", terminalKey(name, "failed"), now, flowId)
1352
+ appendAttempt(flowId, "failed", startedAt, nowStr, "")
1353
+ -- A nested parent reports upward: this settle IS its terminal
1354
+ -- transition, with no worker ack to hook.
1355
+ appendOutbox(flowId, "failed")
1356
+ releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), flowId, now)
1357
+ applyKeep(name, "failed", redis.call("HGET", jk, "keep"), now)
1358
+ settled[touch.firstFailed] = true
1359
+ elseif tonumber(pendingStr) == 0 then
1360
+ -- All children settled: the parent resumes runnable, phase collect.
1361
+ local seq = redis.call("INCR", prefix .. ":seq")
1362
+ redis.call("HSET", jk, "state", "waiting", "runAt", nowStr, "seq", fmt(seq))
1363
+ local priority = tonumber(redis.call("HGET", jk, "priority")) or 0
1364
+ addWaiting(queue, priority, seq, flowId)
1365
+ countsAdd(queue, "waiting-children", -1)
1366
+ countsAdd(queue, "waiting", 1)
1367
+ wakes[#wakes + 1] = queue
1368
+ settled[touch.last] = true
1369
+ end
1370
+ end
1371
+ end
1372
+ local out = {}
1373
+ for i = 1, count do
1374
+ out[i] = '{"applied":' .. (applied[i] and "true" or "false")
1375
+ .. ',"parentSettled":' .. (settled[i] and "true" or "false") .. '}'
1376
+ end
1377
+ local wakesJson = #wakes == 0 and "[]" or cjson.encode(wakes)
1378
+ return '{"results":[' .. table.concat(out, ",") .. '],"wakes":' .. wakesJson .. '}'
1379
+ `
1380
+ }).withReturnType();
1381
+ /**
1382
+ * listChildResults(prefix, flowId, cursor, limit) — child-key order via
1383
+ * ZRANGEBYLEX over the per-flow index; cursor = last childKey (exclusive).
1384
+ * Items are positional HMGET tuples (the field list must stay in lockstep
1385
+ * with the driver's `toChildRecord`) — the full spec JSON stays server-side.
1386
+ */
1387
+ export const listChildResults = Redis.script((prefix, flowId, cursor, limit) => [prefix, flowId, cursor, limit], {
1388
+ numberOfKeys: 0,
1389
+ lua: `${HELPERS}
1390
+ local flowId = ARGV[2]
1391
+ local min = ARGV[3] == "" and "-" or ("(" .. ARGV[3])
1392
+ local limit = tonumber(ARGV[4])
1393
+ local keys = redis.call("ZRANGEBYLEX", flowIndexKey(flowId), min, "+", "LIMIT", 0, limit + 1)
1394
+ local items = {}
1395
+ for i = 1, math.min(#keys, limit) do
1396
+ items[#items + 1] = redis.call("HMGET", flowChildKey(flowId, keys[i]),
1397
+ "childKey", "storeKey", "childJobId", "name", "status", "exit", "failedReason", "cascaded")
1398
+ end
1399
+ if #items == 0 then return '{"items":[],"more":false}' end
1400
+ return cjson.encode({ items = items, more = #keys > limit })
1401
+ `
1402
+ }).withReturnType();
1403
+ /**
1404
+ * flowSweepWork(prefix, pendingAgeMs, limit, now) -> {reconcile, cascade}
1405
+ * grouped by flowId. Reconcile scans the flowpending zset (score = the row's
1406
+ * sweep-eligibility timestamp) and yields rows whose parent is still parked
1407
+ * in waiting-children. Every scanned member is re-armed or purged so no
1408
+ * member can pin the head of the page:
1409
+ *
1410
+ * - parent missing, or terminal with NO manifest: a crashed fan-out's
1411
+ * staged orphan — purge the row, its index member, and the flowpending
1412
+ * member (left alone their old scores would head-pin every page forever);
1413
+ * - parent alive but not waiting-children (mid-staging): re-arm to now so
1414
+ * it rotates behind fresher work;
1415
+ * - returned rows: re-arm to now (defer-on-return, per the contract) so a
1416
+ * full page rotates across sweeps;
1417
+ * - a non-pending row's membership is stale (rows never return to pending):
1418
+ * self-heal by removing the member.
1419
+ *
1420
+ * Cascade lists flowcascade members (cancels still owed to child stores).
1421
+ * Spec JSON strings pass through untouched — the script never cjson-decodes
1422
+ * stored payloads (precision, lone surrogates).
1423
+ */
1424
+ export const flowSweepWork = Redis.script((prefix, pendingAgeMs, limit, now) => [prefix, pendingAgeMs, limit, now], {
1425
+ numberOfKeys: 0,
1426
+ lua: `${HELPERS}
1427
+ local pendingAgeMs = tonumber(ARGV[2])
1428
+ local limit = tonumber(ARGV[3])
1429
+ local now = tonumber(ARGV[4])
1430
+ local reconcile, rIndex = {}, {}
1431
+ local due = redis.call("ZRANGEBYSCORE", prefix .. ":flowpending", "-inf", now - pendingAgeMs,
1432
+ "LIMIT", 0, limit)
1433
+ for _, member in ipairs(due) do
1434
+ local sep = string.find(member, "\0", 1, true)
1435
+ local flowId = string.sub(member, 1, sep - 1)
1436
+ local childKey = string.sub(member, sep + 1)
1437
+ local jk = jobKey(flowId)
1438
+ local state = redis.call("HGET", jk, "state")
1439
+ local pendingField = redis.call("HGET", jk, "flowPending")
1440
+ local hasManifest = pendingField ~= false and pendingField ~= ""
1441
+ local terminal = state == "completed" or state == "failed" or state == "cancelled"
1442
+ if state == false or (terminal and not hasManifest) then
1443
+ -- Staged orphan (parent gone, or went terminal before a manifest ever
1444
+ -- landed): purge, or its old score head-pins every future page.
1445
+ redis.call("DEL", flowChildKey(flowId, childKey))
1446
+ redis.call("ZREM", flowIndexKey(flowId), childKey)
1447
+ redis.call("ZREM", prefix .. ":flowpending", member)
1448
+ elseif state ~= "waiting-children" then
1449
+ -- Alive but not parked (e.g. mid-staging): not this sweep's business —
1450
+ -- rotate it behind fresher work.
1451
+ redis.call("ZADD", prefix .. ":flowpending", now, member)
1452
+ elseif redis.call("HGET", flowChildKey(flowId, childKey), "status") ~= "pending" then
1453
+ -- Stale membership (rows never return to pending): self-heal.
1454
+ redis.call("ZREM", prefix .. ":flowpending", member)
1455
+ else
1456
+ local rk = flowChildKey(flowId, childKey)
1457
+ local group = rIndex[flowId]
1458
+ if group == nil then
1459
+ group = { flowId = flowId, children = {} }
1460
+ rIndex[flowId] = group
1461
+ reconcile[#reconcile + 1] = group
1462
+ end
1463
+ group.children[#group.children + 1] = {
1464
+ childKey = childKey,
1465
+ storeKey = redis.call("HGET", rk, "storeKey"),
1466
+ spec = redis.call("HGET", rk, "spec")
1467
+ }
1468
+ -- Returned work defers its own re-eligibility by one age: page rotation.
1469
+ redis.call("ZADD", prefix .. ":flowpending", now, member)
1470
+ end
1471
+ end
1472
+ local cascade, cIndex = {}, {}
1473
+ local owed = redis.call("ZRANGE", prefix .. ":flowcascade", 0, limit - 1)
1474
+ for _, member in ipairs(owed) do
1475
+ local sep = string.find(member, "\0", 1, true)
1476
+ local flowId = string.sub(member, 1, sep - 1)
1477
+ local childKey = string.sub(member, sep + 1)
1478
+ local rk = flowChildKey(flowId, childKey)
1479
+ if redis.call("EXISTS", rk) == 1 then
1480
+ local group = cIndex[flowId]
1481
+ if group == nil then
1482
+ group = { flowId = flowId, children = {} }
1483
+ cIndex[flowId] = group
1484
+ cascade[#cascade + 1] = group
1485
+ end
1486
+ group.children[#group.children + 1] = {
1487
+ childKey = childKey,
1488
+ storeKey = redis.call("HGET", rk, "storeKey"),
1489
+ childJobId = redis.call("HGET", rk, "childJobId")
1490
+ }
1491
+ end
1492
+ end
1493
+ return cjson.encode({ reconcile = reconcile, cascade = cascade })
1494
+ `
1495
+ }).withReturnType();
1496
+ /**
1497
+ * markChildrenCascaded(prefix, flowId, childKeysJson) — idempotent; unknown
1498
+ * keys are ignored (their index members are still cleared).
1499
+ */
1500
+ export const markChildrenCascaded = Redis.script((prefix, flowId, childKeysJson) => [prefix, flowId, childKeysJson], {
1501
+ numberOfKeys: 0,
1502
+ lua: `${HELPERS}
1503
+ local flowId = ARGV[2]
1504
+ for _, key in ipairs(cjson.decode(ARGV[3])) do
1505
+ local rk = flowChildKey(flowId, key)
1506
+ if redis.call("EXISTS", rk) == 1 then
1507
+ redis.call("HSET", rk, "cascaded", "1")
1508
+ end
1509
+ redis.call("ZREM", prefix .. ":flowcascade", flowMember(flowId, key))
1510
+ end
1511
+ return '{"ok":true}'
1512
+ `
1513
+ }).withReturnType();
1047
1514
  //# sourceMappingURL=scripts.js.map