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
  */
@@ -71,7 +88,79 @@ local function appendAttempt(id, outcome, startedAt, finishedAt, exitJson)
71
88
  '{"attempt":' .. n .. ',"startedAt":' .. started .. ',"finishedAt":' .. finishedAt ..
72
89
  ',"outcome":"' .. outcome .. '"' .. ex .. '}')
73
90
  end
74
- -- Remove a job and every index entry that references it.
91
+ -- The outbox invariant: every operation that moves a job carrying a parent
92
+ -- envelope INTO a terminal state appends its child-result report here, in
93
+ -- the same script. MUST run after the terminal fields (exit, failedReason)
94
+ -- are written — the report is built from the hash. The parent envelope and
95
+ -- exit are spliced as raw JSON (never cjson-decoded: precision, surrogates);
96
+ -- exit/failedReason keys are OMITTED when absent, like the attempts ledger.
97
+ local function appendOutbox(id, outcome)
98
+ local jk = jobKey(id)
99
+ local parentJson = redis.call("HGET", jk, "parent")
100
+ if parentJson == false or parentJson == "" then return end
101
+ local exitJson = redis.call("HGET", jk, "exit")
102
+ local failedReason = redis.call("HGET", jk, "failedReason")
103
+ local ex = (exitJson == false or exitJson == "") and "" or (',"exit":' .. exitJson)
104
+ local fr = (failedReason == false or failedReason == "") and ""
105
+ or (',"failedReason":' .. cjson.encode(failedReason))
106
+ local seq = redis.call("INCR", prefix .. ":flowoutbox:seq")
107
+ redis.call("ZADD", prefix .. ":flowoutbox", seq, fmt(seq) .. "\0" ..
108
+ '{"parent":' .. parentJson .. ',"outcome":"' .. outcome .. '"' .. ex .. fr .. '}')
109
+ end
110
+ -- Flow dependency rows live in the parent store, one hash per child plus a
111
+ -- per-flow member index (childKey order via ZRANGEBYLEX). Row keys and the
112
+ -- sweep-index members join flowId and childKey with NUL — ids and keys may
113
+ -- both contain ":", so a printable separator could alias two distinct
114
+ -- (flowId, childKey) pairs onto one row.
115
+ local function flowChildKey(flowId, childKey) return prefix .. ":flowchild:" .. flowId .. "\0" .. childKey end
116
+ local function flowIndexKey(flowId) return prefix .. ":flowchildren:" .. flowId end
117
+ local function flowMember(flowId, childKey) return flowId .. "\0" .. childKey end
118
+ -- Settle-time marking: remaining pending rows flip to cancelled (NOT
119
+ -- cascaded — the sweeper still owes the child stores real cancels), moving
120
+ -- from the pending sweep index to the cascade sweep index. Returns the
121
+ -- number of rows flipped, for the flow counters.
122
+ local function markPendingRowsCancelled(flowId)
123
+ local keys = redis.call("ZRANGE", flowIndexKey(flowId), 0, -1)
124
+ local marked = 0
125
+ for i = 1, #keys do
126
+ local rk = flowChildKey(flowId, keys[i])
127
+ if redis.call("HGET", rk, "status") == "pending" then
128
+ redis.call("HSET", rk, "status", "cancelled", "cascaded", "0")
129
+ redis.call("ZREM", prefix .. ":flowpending", flowMember(flowId, keys[i]))
130
+ redis.call("ZADD", prefix .. ":flowcascade", 0, flowMember(flowId, keys[i]))
131
+ marked = marked + 1
132
+ end
133
+ end
134
+ return marked
135
+ end
136
+ -- Settle-time marking plus the manifest counters: pending -> 0, cancelled +=
137
+ -- the rows flipped (the four counters always sum to the manifest size).
138
+ local function settleMarkRows(flowId)
139
+ local marked = markPendingRowsCancelled(flowId)
140
+ local jk = jobKey(flowId)
141
+ if redis.call("HEXISTS", jk, "flowPending") == 1 then
142
+ redis.call("HSET", jk, "flowPending", "0")
143
+ if marked > 0 then redis.call("HINCRBY", jk, "flowCancelled", marked) end
144
+ end
145
+ end
146
+ -- A settled flow parent whose rows still owe cascade cancels is exempt from
147
+ -- AUTOMATIC retention (keep policies, the history sweep): deleting it would
148
+ -- delete the only record that the child stores are still owed real cancels.
149
+ -- The explicit remove verb is the operator override and is NOT exempted.
150
+ -- Cheap for non-parents: the per-flow index only exists for fanned-out jobs.
151
+ local function owesCascades(id)
152
+ if redis.call("EXISTS", flowIndexKey(id)) == 0 then return false end
153
+ local keys = redis.call("ZRANGE", flowIndexKey(id), 0, -1)
154
+ for i = 1, #keys do
155
+ local rk = flowChildKey(id, keys[i])
156
+ if redis.call("HGET", rk, "status") == "cancelled" and redis.call("HGET", rk, "cascaded") == "0" then
157
+ return true
158
+ end
159
+ end
160
+ return false
161
+ end
162
+ -- Remove a job and every index entry that references it. A flow parent takes
163
+ -- its dependency rows and their sweep-index members with it (flowId = job id).
75
164
  local function deleteJob(id)
76
165
  local jk = jobKey(id)
77
166
  local queue = redis.call("HGET", jk, "queue")
@@ -85,6 +174,13 @@ local function deleteJob(id)
85
174
  redis.call("ZREM", terminalKey(name, state), id)
86
175
  remWaiting(queue, id)
87
176
  redis.call("ZREM", delayedKey(queue), id)
177
+ local children = redis.call("ZRANGE", flowIndexKey(id), 0, -1)
178
+ for i = 1, #children do
179
+ redis.call("DEL", flowChildKey(id, children[i]))
180
+ redis.call("ZREM", prefix .. ":flowpending", flowMember(id, children[i]))
181
+ redis.call("ZREM", prefix .. ":flowcascade", flowMember(id, children[i]))
182
+ end
183
+ redis.call("DEL", flowIndexKey(id))
88
184
  redis.call("DEL", jk, attemptsKey(id))
89
185
  end
90
186
  -- Terminal retention for one name+state group. Correctness over speed: the
@@ -107,9 +203,13 @@ local function applyKeep(name, state, keepJson, now)
107
203
  end
108
204
  end
109
205
  local tkey = terminalKey(name, state)
206
+ -- Retention exemption: parents still owing cascade cancels are spared
207
+ -- (they still occupy a keep-count slot, exactly like the memory driver).
110
208
  if keep.ageMs ~= nil then
111
209
  local old = redis.call("ZRANGEBYSCORE", tkey, "-inf", now - keep.ageMs)
112
- for i = 1, #old do deleteJob(old[i]) end
210
+ for i = 1, #old do
211
+ if not owesCascades(old[i]) then deleteJob(old[i]) end
212
+ end
113
213
  end
114
214
  -- Floor + clamp: a fractional/negative count must degrade like the memory
115
215
  -- driver's slice(), never error mid-script (writes before an error stick).
@@ -128,7 +228,9 @@ local function applyKeep(name, state, keepJson, now)
128
228
  if a.fa ~= b.fa then return a.fa > b.fa end
129
229
  return a.seq > b.seq
130
230
  end)
131
- for i = count + 1, #arr do deleteJob(arr[i].id) end
231
+ for i = count + 1, #arr do
232
+ if not owesCascades(arr[i].id) then deleteJob(arr[i].id) end
233
+ end
132
234
  end
133
235
  end
134
236
  local function dedupeStoreKey(name, key) return prefix .. ":dedupe:" .. name .. "\0" .. key end
@@ -156,14 +258,16 @@ local function finishCancelled(id, queue, name, startedAt, now, nowStr)
156
258
  redis.call("ZADD", prefix .. ":finished:cancelled", now, id)
157
259
  redis.call("ZADD", terminalKey(name, "cancelled"), now, id)
158
260
  appendAttempt(id, "cancelled", startedAt, nowStr, "")
261
+ appendOutbox(id, "cancelled")
159
262
  releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
160
263
  applyKeep(name, "cancelled", redis.call("HGET", jk, "keep"), now)
161
264
  end
162
265
  -- 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.
266
+ -- verbatim (payload/metadata/backoff/keep/trace/parent are pre-encoded JSON,
267
+ -- "" = absent); priority/delayMs/now numeric-coercible. New jobs never carry
268
+ -- flow fields — only the FanOut ack writes those.
165
269
  local function insertJobRow(id, name, queue, payloadJson, metadataJson, priority,
166
- attemptsMax, backoffJson, keepJson, timeoutMs, dedupeKey, traceJson, delayMs, now, nowStr)
270
+ attemptsMax, backoffJson, keepJson, timeoutMs, dedupeKey, traceJson, parentJson, delayMs, now, nowStr)
167
271
  local seq = redis.call("INCR", prefix .. ":seq")
168
272
  local state = delayMs > 0 and "delayed" or "waiting"
169
273
  local runAt = now + delayMs
@@ -172,7 +276,8 @@ local function insertJobRow(id, name, queue, payloadJson, metadataJson, priority
172
276
  "payload", payloadJson, "metadata", metadataJson, "state", state,
173
277
  "priority", priority, "attemptsMax", attemptsMax, "attemptsMade", "0", "stalledCount", "0",
174
278
  "backoff", backoffJson, "keep", keepJson, "timeoutMs", timeoutMs,
175
- "cancelRequested", "0", "dedupeKey", dedupeKey, "trace", traceJson, "runAt", fmt(runAt), "enqueuedAt", nowStr,
279
+ "cancelRequested", "0", "dedupeKey", dedupeKey, "trace", traceJson, "parent", parentJson,
280
+ "runAt", fmt(runAt), "enqueuedAt", nowStr,
176
281
  "processedAt", "", "finishedAt", "", "exit", "", "failedReason", "",
177
282
  "lockToken", "", "lockExpiresAt", "", "seq", fmt(seq))
178
283
  redis.call("ZADD", prefix .. ":all", now, id)
@@ -187,7 +292,8 @@ end
187
292
 
188
293
  /**
189
294
  * enqueue(prefix, idMode, id, name, queue, payloadJson, metadataJson,
190
- * priority, attemptsMax, backoffJson, keepJson, timeoutMs, delayMs, now)
295
+ * priority, attemptsMax, backoffJson, keepJson, timeoutMs, delayMs,
296
+ * now, dedupe..., traceJson, parentJson)
191
297
  * idMode: "user" (dedup no-op), "generated" (collision -> retry sentinel),
192
298
  * "auto" (j-<seq>, in-script collision loop).
193
299
  */
@@ -211,7 +317,8 @@ export const enqueue = Redis.script(
211
317
  dedupeTtlMs: string,
212
318
  dedupeExtend: string,
213
319
  dedupeReplace: string,
214
- traceJson: string
320
+ traceJson: string,
321
+ parentJson: string
215
322
  ) => [
216
323
  prefix,
217
324
  idMode,
@@ -231,7 +338,8 @@ export const enqueue = Redis.script(
231
338
  dedupeTtlMs,
232
339
  dedupeExtend,
233
340
  dedupeReplace,
234
- traceJson
341
+ traceJson,
342
+ parentJson
235
343
  ],
236
344
  {
237
345
  numberOfKeys: 0,
@@ -305,7 +413,7 @@ if idMode == "auto" then
305
413
  if id == "" then return '{"error":"id"}' end
306
414
  end
307
415
  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)
416
+ ARGV[12], dKey, ARGV[19], ARGV[20], delayMs, now, nowStr)
309
417
  if dKey ~= "" then
310
418
  local sk = dedupeStoreKey(name, dKey)
311
419
  redis.call("DEL", sk)
@@ -445,6 +553,7 @@ local function finish(newState, storeExit, outcome, ledgerExit)
445
553
  redis.call("ZADD", prefix .. ":finished:" .. newState, now, id)
446
554
  redis.call("ZADD", terminalKey(name, newState), now, id)
447
555
  appendAttempt(id, outcome, startedAt, nowStr, ledgerExit)
556
+ appendOutbox(id, newState)
448
557
  releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
449
558
  applyKeep(name, newState, redis.call("HGET", jk, "keep"), now)
450
559
  end
@@ -576,6 +685,7 @@ for _, id in ipairs(expired) do
576
685
  countsAdd(queue, "failed", 1)
577
686
  redis.call("ZADD", prefix .. ":finished:failed", now, id)
578
687
  redis.call("ZADD", terminalKey(name, "failed"), now, id)
688
+ appendOutbox(id, "failed")
579
689
  releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
580
690
  recovered[#recovered + 1] = { id = id, failed = true }
581
691
  else
@@ -698,7 +808,7 @@ return cjson.encode(pairs_)
698
808
  }
699
809
  ).withReturnType<string>()
700
810
 
701
- /** remove(prefix, id) -> removed boolean (active jobs are refused). */
811
+ /** remove(prefix, id) -> removed boolean (active/waiting-children refused). */
702
812
  export const remove = Redis.script(
703
813
  (prefix: string, id: string) => [prefix, id],
704
814
  {
@@ -706,7 +816,8 @@ export const remove = Redis.script(
706
816
  lua: `${HELPERS}
707
817
  local id = ARGV[2]
708
818
  local jk = jobKey(id)
709
- if redis.call("EXISTS", jk) == 0 or redis.call("HGET", jk, "state") == "active" then
819
+ local state = redis.call("HGET", jk, "state")
820
+ if redis.call("EXISTS", jk) == 0 or state == "active" or state == "waiting-children" then
710
821
  return "0"
711
822
  end
712
823
  deleteJob(id)
@@ -746,8 +857,10 @@ return '{"ok":true,"queue":' .. cjson.encode(queue) .. '}'
746
857
  ).withReturnType<string>()
747
858
 
748
859
  /**
749
- * cancel(prefix, id, now) — waiting/delayed become terminal; active gets the
750
- * cancel-request flag; terminal states are refused.
860
+ * cancel(prefix, id, now) — waiting/delayed/waiting-children become terminal
861
+ * (a parked flow parent also flips its remaining pending rows to cancelled,
862
+ * handing them to the cascade sweep); active gets the cancel-request flag;
863
+ * terminal states are refused.
751
864
  */
752
865
  export const cancel = Redis.script(
753
866
  (prefix: string, id: string, now: number) => [prefix, id, now],
@@ -762,13 +875,16 @@ if state == "active" then
762
875
  redis.call("HSET", jk, "cancelRequested", "1")
763
876
  return '{"ok":true}'
764
877
  end
765
- if state ~= "waiting" and state ~= "delayed" then
878
+ if state ~= "waiting" and state ~= "delayed" and state ~= "waiting-children" then
766
879
  return '{"error":"state","state":' .. cjson.encode(state) .. '}'
767
880
  end
768
881
  local nowStr = ARGV[3]
769
882
  local now = tonumber(nowStr)
770
883
  local queue = redis.call("HGET", jk, "queue")
771
884
  local name = redis.call("HGET", jk, "name")
885
+ if state == "waiting-children" then
886
+ settleMarkRows(id)
887
+ end
772
888
  remWaiting(queue, id)
773
889
  redis.call("ZREM", delayedKey(queue), id)
774
890
  redis.call("HSET", jk, "state", "cancelled", "finishedAt", nowStr, "cancelRequested", "0")
@@ -777,6 +893,7 @@ countsAdd(queue, "cancelled", 1)
777
893
  redis.call("ZADD", prefix .. ":finished:cancelled", now, id)
778
894
  redis.call("ZADD", terminalKey(name, "cancelled"), now, id)
779
895
  appendAttempt(id, "cancelled", redis.call("HGET", jk, "processedAt"), nowStr, "")
896
+ appendOutbox(id, "cancelled")
780
897
  releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
781
898
  applyKeep(name, "cancelled", redis.call("HGET", jk, "keep"), now)
782
899
  return '{"ok":true}'
@@ -833,6 +950,7 @@ export const upsertSchedule = Redis.script(
833
950
  backoffJson: string,
834
951
  keepJson: string,
835
952
  timeoutMs: string,
953
+ group: string,
836
954
  nextRunAt: number
837
955
  ) => [
838
956
  prefix,
@@ -849,6 +967,7 @@ export const upsertSchedule = Redis.script(
849
967
  backoffJson,
850
968
  keepJson,
851
969
  timeoutMs,
970
+ group,
852
971
  nextRunAt
853
972
  ],
854
973
  {
@@ -856,7 +975,7 @@ export const upsertSchedule = Redis.script(
856
975
  lua: `${HELPERS}
857
976
  local key = ARGV[2]
858
977
  local sk = prefix .. ":schedule:" .. key
859
- local nextRunAt = tonumber(ARGV[15])
978
+ local nextRunAt = tonumber(ARGV[16])
860
979
  local prevCron = redis.call("HGET", sk, "cron")
861
980
  if prevCron ~= false
862
981
  and prevCron == ARGV[5]
@@ -872,7 +991,7 @@ redis.call("HSET", sk,
872
991
  "payload", ARGV[8], "metadata", ARGV[9],
873
992
  "priority", ARGV[10], "attemptsMax", ARGV[11],
874
993
  "backoff", ARGV[12], "keep", ARGV[13], "timeoutMs", ARGV[14],
875
- "nextRunAt", fmt(nextRunAt))
994
+ "group", ARGV[15], "nextRunAt", fmt(nextRunAt))
876
995
  redis.call("ZADD", prefix .. ":schedules", nextRunAt, key)
877
996
  return '{"ok":true}'
878
997
  `
@@ -908,6 +1027,7 @@ for _, key in ipairs(keys) do
908
1027
  local matches = true
909
1028
  if filters.jobName ~= nil and byName.jobName ~= filters.jobName then matches = false end
910
1029
  if matches and filters.queue ~= nil and byName.queue ~= filters.queue then matches = false end
1030
+ if matches and filters.group ~= nil and byName.group ~= filters.group then matches = false end
911
1031
  if matches then out[#out + 1] = record end
912
1032
  end
913
1033
  if #out == 0 then return "[]" end
@@ -953,7 +1073,7 @@ return "1"
953
1073
  /**
954
1074
  * tickSchedule(prefix, key, expectedRunAt, nextRunAt, id, name, queue,
955
1075
  * payloadJson, metadataJson, priority, attemptsMax, backoffJson, keepJson,
956
- * timeoutMs, traceJson, delayMs, now) -> "1" fired | "0"
1076
+ * timeoutMs, traceJson, parentJson, delayMs, now) -> "1" fired | "0"
957
1077
  * Atomic occurrence claim: the nextRunAt CAS and the tick job's insert run
958
1078
  * in one script, so a stale sweeper can never re-fire a slot — even after
959
1079
  * retention pruned the previous slot's job row.
@@ -975,6 +1095,7 @@ export const tickSchedule = Redis.script(
975
1095
  keepJson: string,
976
1096
  timeoutMs: string,
977
1097
  traceJson: string,
1098
+ parentJson: string,
978
1099
  delayMs: number,
979
1100
  now: number
980
1101
  ) => [
@@ -993,6 +1114,7 @@ export const tickSchedule = Redis.script(
993
1114
  keepJson,
994
1115
  timeoutMs,
995
1116
  traceJson,
1117
+ parentJson,
996
1118
  delayMs,
997
1119
  now
998
1120
  ],
@@ -1010,7 +1132,7 @@ local id = ARGV[5]
1010
1132
  -- schedule still advances, but nothing new fires.
1011
1133
  if redis.call("EXISTS", jobKey(id)) == 1 then return "0" end
1012
1134
  insertJobRow(id, ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12], ARGV[13],
1013
- ARGV[14], "", ARGV[15], tonumber(ARGV[16]), tonumber(ARGV[17]), ARGV[17])
1135
+ ARGV[14], "", ARGV[15], ARGV[16], tonumber(ARGV[17]), tonumber(ARGV[18]), ARGV[18])
1014
1136
  return "1"
1015
1137
  `
1016
1138
  }
@@ -1018,10 +1140,10 @@ return "1"
1018
1140
 
1019
1141
  /**
1020
1142
  * enqueueMany(prefix, now, count, ...items) -> JSON array of per-item results
1021
- * ({id, duplicate} | {collision} | {error}). Items are 13-ARGV strides:
1143
+ * ({id, duplicate} | {collision} | {error}). Items are 14-ARGV strides:
1022
1144
  * idMode, id, name, queue, payloadJson, metadataJson, priority, attemptsMax,
1023
- * backoffJson, keepJson, timeoutMs, traceJson, delayMs. Plain (non-dedup)
1024
- * items only — the caller routes dedup items through \`enqueue\`.
1145
+ * backoffJson, keepJson, timeoutMs, traceJson, parentJson, delayMs. Plain
1146
+ * (non-dedup) items only — the caller routes dedup items through \`enqueue\`.
1025
1147
  */
1026
1148
  export const enqueueMany = Redis.script(
1027
1149
  (prefix: string, now: number, count: number, items: ReadonlyArray<string>) => [prefix, now, count, ...items],
@@ -1033,7 +1155,7 @@ local nowStr = ARGV[2]
1033
1155
  local count = tonumber(ARGV[3])
1034
1156
  local out = {}
1035
1157
  for i = 0, count - 1 do
1036
- local base = 3 + i * 13
1158
+ local base = 3 + i * 14
1037
1159
  local idMode = ARGV[base + 1]
1038
1160
  local id = ARGV[base + 2]
1039
1161
  local result
@@ -1061,7 +1183,7 @@ for i = 0, count - 1 do
1061
1183
  else
1062
1184
  insertJobRow(id, ARGV[base + 3], ARGV[base + 4], ARGV[base + 5], ARGV[base + 6],
1063
1185
  ARGV[base + 7], ARGV[base + 8], ARGV[base + 9], ARGV[base + 10], ARGV[base + 11],
1064
- "", ARGV[base + 12], tonumber(ARGV[base + 13]), now, nowStr)
1186
+ "", ARGV[base + 12], ARGV[base + 13], tonumber(ARGV[base + 14]), now, nowStr)
1065
1187
  result = '{"id":' .. cjson.encode(id) .. ',"duplicate":false}'
1066
1188
  end
1067
1189
  end
@@ -1127,7 +1249,9 @@ for i = 1, #batch, 2 do
1127
1249
  end
1128
1250
  end
1129
1251
  end
1130
- if cutoffAge ~= nil and finishedAt <= now - cutoffAge then
1252
+ -- Parents still owing cascade cancels are exempt from automatic
1253
+ -- retention; they count as scanned so the offset cursor walks past them.
1254
+ if cutoffAge ~= nil and finishedAt <= now - cutoffAge and not owesCascades(id) then
1131
1255
  deleteJob(id)
1132
1256
  deleted = deleted + 1
1133
1257
  end
@@ -1185,3 +1309,382 @@ return tostring(migrated + #expired + removedPending)
1185
1309
  `
1186
1310
  }
1187
1311
  ).withReturnType<string>()
1312
+
1313
+ /**
1314
+ * fanOut(prefix, id, token, final, clearStaged, failFast, total, now, count,
1315
+ * ...items) — the FanOut ack, chunked like enqueueMany. Items are 5-ARGV
1316
+ * strides: childKey, storeKey, childJobId, name, specJson.
1317
+ *
1318
+ * Every chunk is lock-token-guarded. Non-final chunks ONLY stage dependency
1319
+ * rows; the final chunk stages its rows, appends the "fanned-out" ledger
1320
+ * entry (no attempt consumed), persists the manifest (flowFailFast,
1321
+ * flowPending = total, zeroed outcome counters), and transitions the parent:
1322
+ * pending > 0 parks it in
1323
+ * waiting-children (no pending zset — never claimable), pending == 0 settles
1324
+ * it straight to runnable collect. A parent whose manifest already landed
1325
+ * keeps it untouched (rows are not re-created; the transition follows the
1326
+ * persisted pending count), so a double fan-out cannot duplicate children.
1327
+ * When staging starts with no manifest, the FIRST chunk clears previously
1328
+ * staged rows — a crashed earlier attempt may have staged different keys. A
1329
+ * raced cancelRequested wins: the parent settles cancelled and its pending
1330
+ * rows flip to cancelled (cascade work for the flow sweeper).
1331
+ */
1332
+ export const fanOut = Redis.script(
1333
+ (
1334
+ prefix: string,
1335
+ id: string,
1336
+ token: string,
1337
+ final: string,
1338
+ clearStaged: string,
1339
+ failFast: string,
1340
+ total: number,
1341
+ now: number,
1342
+ count: number,
1343
+ items: ReadonlyArray<string>
1344
+ ) => [prefix, id, token, final, clearStaged, failFast, total, now, count, ...items],
1345
+ {
1346
+ numberOfKeys: 0,
1347
+ lua: `${HELPERS}
1348
+ local id = ARGV[2]
1349
+ local jk = jobKey(id)
1350
+ if redis.call("EXISTS", jk) == 0 then return '{"error":"notfound"}' end
1351
+ if redis.call("HGET", jk, "state") ~= "active" or redis.call("HGET", jk, "lockToken") ~= ARGV[3] then
1352
+ return '{"error":"locklost"}'
1353
+ end
1354
+ local final = ARGV[4] == "1"
1355
+ local clearStaged = ARGV[5] == "1"
1356
+ local failFast = ARGV[6]
1357
+ local total = tonumber(ARGV[7])
1358
+ local nowStr = ARGV[8]
1359
+ local now = tonumber(nowStr)
1360
+ local count = tonumber(ARGV[9])
1361
+ local pendingStr = redis.call("HGET", jk, "flowPending")
1362
+ local hasManifest = pendingStr ~= false and pendingStr ~= ""
1363
+ if not hasManifest then
1364
+ if clearStaged then
1365
+ local staged = redis.call("ZRANGE", flowIndexKey(id), 0, -1)
1366
+ for i = 1, #staged do
1367
+ redis.call("DEL", flowChildKey(id, staged[i]))
1368
+ redis.call("ZREM", prefix .. ":flowpending", flowMember(id, staged[i]))
1369
+ redis.call("ZREM", prefix .. ":flowcascade", flowMember(id, staged[i]))
1370
+ end
1371
+ redis.call("DEL", flowIndexKey(id))
1372
+ end
1373
+ for i = 0, count - 1 do
1374
+ local base = 9 + i * 5
1375
+ local childKey = ARGV[base + 1]
1376
+ local rk = flowChildKey(id, childKey)
1377
+ redis.call("DEL", rk)
1378
+ redis.call("HSET", rk,
1379
+ "childKey", childKey, "storeKey", ARGV[base + 2], "childJobId", ARGV[base + 3],
1380
+ "name", ARGV[base + 4], "spec", ARGV[base + 5],
1381
+ "status", "pending", "exit", "", "failedReason", "", "cascaded", "0",
1382
+ "pendingSince", nowStr)
1383
+ redis.call("ZADD", flowIndexKey(id), 0, childKey)
1384
+ redis.call("ZADD", prefix .. ":flowpending", now, flowMember(id, childKey))
1385
+ end
1386
+ end
1387
+ if not final then return '{"ok":true}' end
1388
+ local queue = redis.call("HGET", jk, "queue")
1389
+ local name = redis.call("HGET", jk, "name")
1390
+ local startedAt = redis.call("HGET", jk, "processedAt")
1391
+ -- A fan-out is a phase transition, not a completed run: no attemptsMade.
1392
+ appendAttempt(id, "fanned-out", startedAt, nowStr, "")
1393
+ redis.call("ZREM", prefix .. ":active", id)
1394
+ local pending
1395
+ if hasManifest then
1396
+ pending = tonumber(pendingStr) or 0
1397
+ else
1398
+ redis.call("HSET", jk, "flowFailFast", failFast, "flowPending", fmt(total),
1399
+ "flowCompleted", "0", "flowFailed", "0", "flowCancelled", "0")
1400
+ pending = total
1401
+ end
1402
+ if redis.call("HGET", jk, "cancelRequested") == "1" then
1403
+ -- A cancel raced the fan-out: cancellation wins. The rows exist and get
1404
+ -- marked, so the sweeper cascades (mostly no-op cancels for
1405
+ -- never-enqueued children).
1406
+ settleMarkRows(id)
1407
+ finishCancelled(id, queue, name, startedAt, now, nowStr)
1408
+ return '{"ok":true}'
1409
+ end
1410
+ if pending > 0 then
1411
+ redis.call("HSET", jk, "state", "waiting-children", "lockToken", "", "lockExpiresAt", "")
1412
+ countsAdd(queue, "active", -1)
1413
+ countsAdd(queue, "waiting-children", 1)
1414
+ return '{"ok":true}'
1415
+ end
1416
+ -- Empty (or fully recorded) manifest: settle straight to runnable collect.
1417
+ local seq = redis.call("INCR", prefix .. ":seq")
1418
+ redis.call("HSET", jk, "state", "waiting", "runAt", nowStr, "seq", fmt(seq),
1419
+ "lockToken", "", "lockExpiresAt", "")
1420
+ local priority = tonumber(redis.call("HGET", jk, "priority")) or 0
1421
+ addWaiting(queue, priority, seq, id)
1422
+ countsAdd(queue, "active", -1)
1423
+ countsAdd(queue, "waiting", 1)
1424
+ return '{"ok":true,"wake":true,"queue":' .. cjson.encode(queue) .. '}'
1425
+ `
1426
+ }
1427
+ ).withReturnType<string>()
1428
+
1429
+ /**
1430
+ * recordChildResults(prefix, now, count, ...items) -> {results, wakes}.
1431
+ * Items are 5-ARGV strides: flowId, childKey, outcome, exitJson,
1432
+ * failedReason; results are positional {applied, parentSettled}; wakes names
1433
+ * the queues of parents that resumed runnable. One atomic batch; reports may
1434
+ * span flows.
1435
+ *
1436
+ * Phase 1 applies EVERY row update — idempotent, only while the row is
1437
+ * still pending; an applied report moves the child from the parent's `pending`
1438
+ * counter to its outcome counter and marks the row cascaded (the outcome
1439
+ * came FROM the child's store). Phase 2 settles each touched flow at most
1440
+ * once: the FIRST applied failed report in batch order under fail-fast
1441
+ * (terminal store-side failure; remaining rows flip to cancelled; wins the
1442
+ * tie over pending==0 — and this settle IS a nested parent's terminal
1443
+ * transition, so its own report goes to the outbox here), else pending==0
1444
+ * resumes the parent runnable at the flow's LAST applied report's index.
1445
+ */
1446
+ export const recordChildResults = Redis.script(
1447
+ (prefix: string, now: number, count: number, items: ReadonlyArray<string>) => [prefix, now, count, ...items],
1448
+ {
1449
+ numberOfKeys: 0,
1450
+ lua: `${HELPERS}
1451
+ local nowStr = ARGV[2]
1452
+ local now = tonumber(nowStr)
1453
+ local count = tonumber(ARGV[3])
1454
+ local applied = {}
1455
+ local settled = {}
1456
+ -- Phase 1: every row update lands before any settle decision, so a
1457
+ -- completed batch-mate keeps its real outcome even when an earlier
1458
+ -- batch-mate settles the flow fail-fast.
1459
+ local touched = {}
1460
+ local touchedOrder = {}
1461
+ for i = 1, count do
1462
+ local base = 3 + (i - 1) * 5
1463
+ local flowId = ARGV[base + 1]
1464
+ local childKey = ARGV[base + 2]
1465
+ local outcome = ARGV[base + 3]
1466
+ applied[i] = false
1467
+ local rk = flowChildKey(flowId, childKey)
1468
+ if redis.call("HGET", rk, "status") == "pending" then
1469
+ redis.call("HSET", rk, "status", outcome, "exit", ARGV[base + 4],
1470
+ "failedReason", ARGV[base + 5], "cascaded", "1")
1471
+ redis.call("ZREM", prefix .. ":flowpending", flowMember(flowId, childKey))
1472
+ applied[i] = true
1473
+ local jk = jobKey(flowId)
1474
+ local pendingStr = redis.call("HGET", jk, "flowPending")
1475
+ if pendingStr ~= false and pendingStr ~= "" then
1476
+ redis.call("HSET", jk, "flowPending", fmt(math.max(0, (tonumber(pendingStr) or 0) - 1)))
1477
+ local bucket = outcome == "completed" and "flowCompleted"
1478
+ or outcome == "failed" and "flowFailed" or "flowCancelled"
1479
+ redis.call("HINCRBY", jk, bucket, 1)
1480
+ end
1481
+ local touch = touched[flowId]
1482
+ if touch == nil then
1483
+ touch = { last = i }
1484
+ touched[flowId] = touch
1485
+ touchedOrder[#touchedOrder + 1] = flowId
1486
+ end
1487
+ touch.last = i
1488
+ if outcome == "failed" and touch.firstFailed == nil then
1489
+ touch.firstFailed = i
1490
+ touch.failedKey = childKey
1491
+ end
1492
+ end
1493
+ end
1494
+ -- Phase 2: at most one settle per touched flow; fail-fast wins ties.
1495
+ local wakes = {}
1496
+ for _, flowId in ipairs(touchedOrder) do
1497
+ local touch = touched[flowId]
1498
+ local jk = jobKey(flowId)
1499
+ local pendingStr = redis.call("HGET", jk, "flowPending")
1500
+ if pendingStr ~= false and pendingStr ~= ""
1501
+ and redis.call("HGET", jk, "state") == "waiting-children" then
1502
+ local queue = redis.call("HGET", jk, "queue")
1503
+ if redis.call("HGET", jk, "flowFailFast") == "1" and touch.firstFailed ~= nil then
1504
+ -- First applied failure settles the parent terminally, store-side
1505
+ -- (failedReason, no exit — like stall exhaustion) and marks the
1506
+ -- remaining rows in the same op.
1507
+ local name = redis.call("HGET", jk, "name")
1508
+ local startedAt = redis.call("HGET", jk, "processedAt")
1509
+ settleMarkRows(flowId)
1510
+ redis.call("HSET", jk, "state", "failed", "finishedAt", nowStr, "cancelRequested", "0",
1511
+ "failedReason", 'effect-mq: flow child "' .. touch.failedKey .. '" failed')
1512
+ countsAdd(queue, "waiting-children", -1)
1513
+ countsAdd(queue, "failed", 1)
1514
+ redis.call("ZADD", prefix .. ":finished:failed", now, flowId)
1515
+ redis.call("ZADD", terminalKey(name, "failed"), now, flowId)
1516
+ appendAttempt(flowId, "failed", startedAt, nowStr, "")
1517
+ -- A nested parent reports upward: this settle IS its terminal
1518
+ -- transition, with no worker ack to hook.
1519
+ appendOutbox(flowId, "failed")
1520
+ releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), flowId, now)
1521
+ applyKeep(name, "failed", redis.call("HGET", jk, "keep"), now)
1522
+ settled[touch.firstFailed] = true
1523
+ elseif tonumber(pendingStr) == 0 then
1524
+ -- All children settled: the parent resumes runnable, phase collect.
1525
+ local seq = redis.call("INCR", prefix .. ":seq")
1526
+ redis.call("HSET", jk, "state", "waiting", "runAt", nowStr, "seq", fmt(seq))
1527
+ local priority = tonumber(redis.call("HGET", jk, "priority")) or 0
1528
+ addWaiting(queue, priority, seq, flowId)
1529
+ countsAdd(queue, "waiting-children", -1)
1530
+ countsAdd(queue, "waiting", 1)
1531
+ wakes[#wakes + 1] = queue
1532
+ settled[touch.last] = true
1533
+ end
1534
+ end
1535
+ end
1536
+ local out = {}
1537
+ for i = 1, count do
1538
+ out[i] = '{"applied":' .. (applied[i] and "true" or "false")
1539
+ .. ',"parentSettled":' .. (settled[i] and "true" or "false") .. '}'
1540
+ end
1541
+ local wakesJson = #wakes == 0 and "[]" or cjson.encode(wakes)
1542
+ return '{"results":[' .. table.concat(out, ",") .. '],"wakes":' .. wakesJson .. '}'
1543
+ `
1544
+ }
1545
+ ).withReturnType<string>()
1546
+
1547
+ /**
1548
+ * listChildResults(prefix, flowId, cursor, limit) — child-key order via
1549
+ * ZRANGEBYLEX over the per-flow index; cursor = last childKey (exclusive).
1550
+ * Items are positional HMGET tuples (the field list must stay in lockstep
1551
+ * with the driver's `toChildRecord`) — the full spec JSON stays server-side.
1552
+ */
1553
+ export const listChildResults = Redis.script(
1554
+ (prefix: string, flowId: string, cursor: string, limit: number) => [prefix, flowId, cursor, limit],
1555
+ {
1556
+ numberOfKeys: 0,
1557
+ lua: `${HELPERS}
1558
+ local flowId = ARGV[2]
1559
+ local min = ARGV[3] == "" and "-" or ("(" .. ARGV[3])
1560
+ local limit = tonumber(ARGV[4])
1561
+ local keys = redis.call("ZRANGEBYLEX", flowIndexKey(flowId), min, "+", "LIMIT", 0, limit + 1)
1562
+ local items = {}
1563
+ for i = 1, math.min(#keys, limit) do
1564
+ items[#items + 1] = redis.call("HMGET", flowChildKey(flowId, keys[i]),
1565
+ "childKey", "storeKey", "childJobId", "name", "status", "exit", "failedReason", "cascaded")
1566
+ end
1567
+ if #items == 0 then return '{"items":[],"more":false}' end
1568
+ return cjson.encode({ items = items, more = #keys > limit })
1569
+ `
1570
+ }
1571
+ ).withReturnType<string>()
1572
+
1573
+ /**
1574
+ * flowSweepWork(prefix, pendingAgeMs, limit, now) -> {reconcile, cascade}
1575
+ * grouped by flowId. Reconcile scans the flowpending zset (score = the row's
1576
+ * sweep-eligibility timestamp) and yields rows whose parent is still parked
1577
+ * in waiting-children. Every scanned member is re-armed or purged so no
1578
+ * member can pin the head of the page:
1579
+ *
1580
+ * - parent missing, or terminal with NO manifest: a crashed fan-out's
1581
+ * staged orphan — purge the row, its index member, and the flowpending
1582
+ * member (left alone their old scores would head-pin every page forever);
1583
+ * - parent alive but not waiting-children (mid-staging): re-arm to now so
1584
+ * it rotates behind fresher work;
1585
+ * - returned rows: re-arm to now (defer-on-return, per the contract) so a
1586
+ * full page rotates across sweeps;
1587
+ * - a non-pending row's membership is stale (rows never return to pending):
1588
+ * self-heal by removing the member.
1589
+ *
1590
+ * Cascade lists flowcascade members (cancels still owed to child stores).
1591
+ * Spec JSON strings pass through untouched — the script never cjson-decodes
1592
+ * stored payloads (precision, lone surrogates).
1593
+ */
1594
+ export const flowSweepWork = Redis.script(
1595
+ (prefix: string, pendingAgeMs: number, limit: number, now: number) => [prefix, pendingAgeMs, limit, now],
1596
+ {
1597
+ numberOfKeys: 0,
1598
+ lua: `${HELPERS}
1599
+ local pendingAgeMs = tonumber(ARGV[2])
1600
+ local limit = tonumber(ARGV[3])
1601
+ local now = tonumber(ARGV[4])
1602
+ local reconcile, rIndex = {}, {}
1603
+ local due = redis.call("ZRANGEBYSCORE", prefix .. ":flowpending", "-inf", now - pendingAgeMs,
1604
+ "LIMIT", 0, limit)
1605
+ for _, member in ipairs(due) do
1606
+ local sep = string.find(member, "\0", 1, true)
1607
+ local flowId = string.sub(member, 1, sep - 1)
1608
+ local childKey = string.sub(member, sep + 1)
1609
+ local jk = jobKey(flowId)
1610
+ local state = redis.call("HGET", jk, "state")
1611
+ local pendingField = redis.call("HGET", jk, "flowPending")
1612
+ local hasManifest = pendingField ~= false and pendingField ~= ""
1613
+ local terminal = state == "completed" or state == "failed" or state == "cancelled"
1614
+ if state == false or (terminal and not hasManifest) then
1615
+ -- Staged orphan (parent gone, or went terminal before a manifest ever
1616
+ -- landed): purge, or its old score head-pins every future page.
1617
+ redis.call("DEL", flowChildKey(flowId, childKey))
1618
+ redis.call("ZREM", flowIndexKey(flowId), childKey)
1619
+ redis.call("ZREM", prefix .. ":flowpending", member)
1620
+ elseif state ~= "waiting-children" then
1621
+ -- Alive but not parked (e.g. mid-staging): not this sweep's business —
1622
+ -- rotate it behind fresher work.
1623
+ redis.call("ZADD", prefix .. ":flowpending", now, member)
1624
+ elseif redis.call("HGET", flowChildKey(flowId, childKey), "status") ~= "pending" then
1625
+ -- Stale membership (rows never return to pending): self-heal.
1626
+ redis.call("ZREM", prefix .. ":flowpending", member)
1627
+ else
1628
+ local rk = flowChildKey(flowId, childKey)
1629
+ local group = rIndex[flowId]
1630
+ if group == nil then
1631
+ group = { flowId = flowId, children = {} }
1632
+ rIndex[flowId] = group
1633
+ reconcile[#reconcile + 1] = group
1634
+ end
1635
+ group.children[#group.children + 1] = {
1636
+ childKey = childKey,
1637
+ storeKey = redis.call("HGET", rk, "storeKey"),
1638
+ spec = redis.call("HGET", rk, "spec")
1639
+ }
1640
+ -- Returned work defers its own re-eligibility by one age: page rotation.
1641
+ redis.call("ZADD", prefix .. ":flowpending", now, member)
1642
+ end
1643
+ end
1644
+ local cascade, cIndex = {}, {}
1645
+ local owed = redis.call("ZRANGE", prefix .. ":flowcascade", 0, limit - 1)
1646
+ for _, member in ipairs(owed) do
1647
+ local sep = string.find(member, "\0", 1, true)
1648
+ local flowId = string.sub(member, 1, sep - 1)
1649
+ local childKey = string.sub(member, sep + 1)
1650
+ local rk = flowChildKey(flowId, childKey)
1651
+ if redis.call("EXISTS", rk) == 1 then
1652
+ local group = cIndex[flowId]
1653
+ if group == nil then
1654
+ group = { flowId = flowId, children = {} }
1655
+ cIndex[flowId] = group
1656
+ cascade[#cascade + 1] = group
1657
+ end
1658
+ group.children[#group.children + 1] = {
1659
+ childKey = childKey,
1660
+ storeKey = redis.call("HGET", rk, "storeKey"),
1661
+ childJobId = redis.call("HGET", rk, "childJobId")
1662
+ }
1663
+ end
1664
+ end
1665
+ return cjson.encode({ reconcile = reconcile, cascade = cascade })
1666
+ `
1667
+ }
1668
+ ).withReturnType<string>()
1669
+
1670
+ /**
1671
+ * markChildrenCascaded(prefix, flowId, childKeysJson) — idempotent; unknown
1672
+ * keys are ignored (their index members are still cleared).
1673
+ */
1674
+ export const markChildrenCascaded = Redis.script(
1675
+ (prefix: string, flowId: string, childKeysJson: string) => [prefix, flowId, childKeysJson],
1676
+ {
1677
+ numberOfKeys: 0,
1678
+ lua: `${HELPERS}
1679
+ local flowId = ARGV[2]
1680
+ for _, key in ipairs(cjson.decode(ARGV[3])) do
1681
+ local rk = flowChildKey(flowId, key)
1682
+ if redis.call("EXISTS", rk) == 1 then
1683
+ redis.call("HSET", rk, "cascaded", "1")
1684
+ end
1685
+ redis.call("ZREM", prefix .. ":flowcascade", flowMember(flowId, key))
1686
+ end
1687
+ return '{"ok":true}'
1688
+ `
1689
+ }
1690
+ ).withReturnType<string>()