effect-mq 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +85 -17
  2. package/dist/Flow.d.ts +381 -0
  3. package/dist/Flow.d.ts.map +1 -0
  4. package/dist/Flow.js +340 -0
  5. package/dist/Flow.js.map +1 -0
  6. package/dist/Job.d.ts +37 -6
  7. package/dist/Job.d.ts.map +1 -1
  8. package/dist/Job.js +17 -2
  9. package/dist/Job.js.map +1 -1
  10. package/dist/JobSchedules.d.ts +112 -0
  11. package/dist/JobSchedules.d.ts.map +1 -0
  12. package/dist/JobSchedules.js +106 -0
  13. package/dist/JobSchedules.js.map +1 -0
  14. package/dist/JobStore.d.ts +320 -10
  15. package/dist/JobStore.d.ts.map +1 -1
  16. package/dist/JobStore.js.map +1 -1
  17. package/dist/MemoryJobStore.d.ts.map +1 -1
  18. package/dist/MemoryJobStore.js +336 -8
  19. package/dist/MemoryJobStore.js.map +1 -1
  20. package/dist/Metrics.d.ts +31 -0
  21. package/dist/Metrics.d.ts.map +1 -1
  22. package/dist/Metrics.js +39 -0
  23. package/dist/Metrics.js.map +1 -1
  24. package/dist/Worker.d.ts +120 -11
  25. package/dist/Worker.d.ts.map +1 -1
  26. package/dist/Worker.js +452 -26
  27. package/dist/Worker.js.map +1 -1
  28. package/dist/drizzle-postgres/DrizzleJobStore.d.ts +19 -1
  29. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  30. package/dist/drizzle-postgres/DrizzleJobStore.js +662 -81
  31. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  32. package/dist/drizzle-postgres/schema.d.ts +310 -3
  33. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  34. package/dist/drizzle-postgres/schema.js +68 -1
  35. package/dist/drizzle-postgres/schema.js.map +1 -1
  36. package/dist/index.d.ts +14 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +14 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  41. package/dist/redis/RedisJobStore.js +221 -19
  42. package/dist/redis/RedisJobStore.js.map +1 -1
  43. package/dist/redis/scripts.d.ts +118 -11
  44. package/dist/redis/scripts.d.ts.map +1 -1
  45. package/dist/redis/scripts.js +497 -28
  46. package/dist/redis/scripts.js.map +1 -1
  47. package/dist/testing/conformance.d.ts +6 -0
  48. package/dist/testing/conformance.d.ts.map +1 -1
  49. package/dist/testing/conformance.js +765 -1
  50. package/dist/testing/conformance.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/Flow.ts +778 -0
  53. package/src/Job.ts +42 -11
  54. package/src/JobSchedules.ts +223 -0
  55. package/src/JobStore.ts +347 -9
  56. package/src/MemoryJobStore.ts +372 -8
  57. package/src/Metrics.ts +43 -0
  58. package/src/Worker.ts +726 -37
  59. package/src/drizzle-postgres/DrizzleJobStore.ts +827 -82
  60. package/src/drizzle-postgres/schema.ts +94 -0
  61. package/src/index.ts +16 -0
  62. package/src/redis/RedisJobStore.ts +291 -8
  63. package/src/redis/scripts.ts +529 -26
  64. package/src/testing/conformance.ts +989 -1
@@ -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}'
@@ -743,7 +859,7 @@ return '{"ok":true,"queue":' .. cjson.encode(queue) .. '}'
743
859
  * digits) and empty arrays ({}). An unchanged cadence (cron/tz/everyMs)
744
860
  * preserves the stored nextRunAt.
745
861
  */
746
- export const upsertSchedule = Redis.script((prefix, key, jobName, queue, cron, tz, everyMs, payloadJson, metadataJson, priority, attemptsMax, backoffJson, keepJson, timeoutMs, nextRunAt) => [
862
+ export const upsertSchedule = Redis.script((prefix, key, jobName, queue, cron, tz, everyMs, payloadJson, metadataJson, priority, attemptsMax, backoffJson, keepJson, timeoutMs, group, nextRunAt) => [
747
863
  prefix,
748
864
  key,
749
865
  jobName,
@@ -758,13 +874,14 @@ export const upsertSchedule = Redis.script((prefix, key, jobName, queue, cron, t
758
874
  backoffJson,
759
875
  keepJson,
760
876
  timeoutMs,
877
+ group,
761
878
  nextRunAt
762
879
  ], {
763
880
  numberOfKeys: 0,
764
881
  lua: `${HELPERS}
765
882
  local key = ARGV[2]
766
883
  local sk = prefix .. ":schedule:" .. key
767
- local nextRunAt = tonumber(ARGV[15])
884
+ local nextRunAt = tonumber(ARGV[16])
768
885
  local prevCron = redis.call("HGET", sk, "cron")
769
886
  if prevCron ~= false
770
887
  and prevCron == ARGV[5]
@@ -780,7 +897,7 @@ redis.call("HSET", sk,
780
897
  "payload", ARGV[8], "metadata", ARGV[9],
781
898
  "priority", ARGV[10], "attemptsMax", ARGV[11],
782
899
  "backoff", ARGV[12], "keep", ARGV[13], "timeoutMs", ARGV[14],
783
- "nextRunAt", fmt(nextRunAt))
900
+ "group", ARGV[15], "nextRunAt", fmt(nextRunAt))
784
901
  redis.call("ZADD", prefix .. ":schedules", nextRunAt, key)
785
902
  return '{"ok":true}'
786
903
  `
@@ -808,6 +925,7 @@ for _, key in ipairs(keys) do
808
925
  local matches = true
809
926
  if filters.jobName ~= nil and byName.jobName ~= filters.jobName then matches = false end
810
927
  if matches and filters.queue ~= nil and byName.queue ~= filters.queue then matches = false end
928
+ if matches and filters.group ~= nil and byName.group ~= filters.group then matches = false end
811
929
  if matches then out[#out + 1] = record end
812
930
  end
813
931
  if #out == 0 then return "[]" end
@@ -843,12 +961,12 @@ return "1"
843
961
  /**
844
962
  * tickSchedule(prefix, key, expectedRunAt, nextRunAt, id, name, queue,
845
963
  * payloadJson, metadataJson, priority, attemptsMax, backoffJson, keepJson,
846
- * timeoutMs, traceJson, delayMs, now) -> "1" fired | "0"
964
+ * timeoutMs, traceJson, parentJson, delayMs, now) -> "1" fired | "0"
847
965
  * Atomic occurrence claim: the nextRunAt CAS and the tick job's insert run
848
966
  * in one script, so a stale sweeper can never re-fire a slot — even after
849
967
  * retention pruned the previous slot's job row.
850
968
  */
851
- 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) => [
852
970
  prefix,
853
971
  key,
854
972
  expectedRunAt,
@@ -864,6 +982,7 @@ export const tickSchedule = Redis.script((prefix, key, expectedRunAt, nextRunAt,
864
982
  keepJson,
865
983
  timeoutMs,
866
984
  traceJson,
985
+ parentJson,
867
986
  delayMs,
868
987
  now
869
988
  ], {
@@ -880,16 +999,16 @@ local id = ARGV[5]
880
999
  -- schedule still advances, but nothing new fires.
881
1000
  if redis.call("EXISTS", jobKey(id)) == 1 then return "0" end
882
1001
  insertJobRow(id, ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12], ARGV[13],
883
- 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])
884
1003
  return "1"
885
1004
  `
886
1005
  }).withReturnType();
887
1006
  /**
888
1007
  * enqueueMany(prefix, now, count, ...items) -> JSON array of per-item results
889
- * ({id, duplicate} | {collision} | {error}). Items are 13-ARGV strides:
1008
+ * ({id, duplicate} | {collision} | {error}). Items are 14-ARGV strides:
890
1009
  * idMode, id, name, queue, payloadJson, metadataJson, priority, attemptsMax,
891
- * backoffJson, keepJson, timeoutMs, traceJson, delayMs. Plain (non-dedup)
892
- * 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\`.
893
1012
  */
894
1013
  export const enqueueMany = Redis.script((prefix, now, count, items) => [prefix, now, count, ...items], {
895
1014
  numberOfKeys: 0,
@@ -899,7 +1018,7 @@ local nowStr = ARGV[2]
899
1018
  local count = tonumber(ARGV[3])
900
1019
  local out = {}
901
1020
  for i = 0, count - 1 do
902
- local base = 3 + i * 13
1021
+ local base = 3 + i * 14
903
1022
  local idMode = ARGV[base + 1]
904
1023
  local id = ARGV[base + 2]
905
1024
  local result
@@ -927,7 +1046,7 @@ for i = 0, count - 1 do
927
1046
  else
928
1047
  insertJobRow(id, ARGV[base + 3], ARGV[base + 4], ARGV[base + 5], ARGV[base + 6],
929
1048
  ARGV[base + 7], ARGV[base + 8], ARGV[base + 9], ARGV[base + 10], ARGV[base + 11],
930
- "", ARGV[base + 12], tonumber(ARGV[base + 13]), now, nowStr)
1049
+ "", ARGV[base + 12], ARGV[base + 13], tonumber(ARGV[base + 14]), now, nowStr)
931
1050
  result = '{"id":' .. cjson.encode(id) .. ',"duplicate":false}'
932
1051
  end
933
1052
  end
@@ -989,7 +1108,9 @@ for i = 1, #batch, 2 do
989
1108
  end
990
1109
  end
991
1110
  end
992
- 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
993
1114
  deleteJob(id)
994
1115
  deleted = deleted + 1
995
1116
  end
@@ -1042,4 +1163,352 @@ end
1042
1163
  return tostring(migrated + #expired + removedPending)
1043
1164
  `
1044
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();
1045
1514
  //# sourceMappingURL=scripts.js.map