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.
- package/README.md +85 -17
- package/dist/Flow.d.ts +381 -0
- package/dist/Flow.d.ts.map +1 -0
- package/dist/Flow.js +340 -0
- package/dist/Flow.js.map +1 -0
- package/dist/Job.d.ts +37 -6
- package/dist/Job.d.ts.map +1 -1
- package/dist/Job.js +17 -2
- package/dist/Job.js.map +1 -1
- package/dist/JobSchedules.d.ts +112 -0
- package/dist/JobSchedules.d.ts.map +1 -0
- package/dist/JobSchedules.js +106 -0
- package/dist/JobSchedules.js.map +1 -0
- package/dist/JobStore.d.ts +320 -10
- package/dist/JobStore.d.ts.map +1 -1
- package/dist/JobStore.js.map +1 -1
- package/dist/MemoryJobStore.d.ts.map +1 -1
- package/dist/MemoryJobStore.js +336 -8
- package/dist/MemoryJobStore.js.map +1 -1
- package/dist/Metrics.d.ts +31 -0
- package/dist/Metrics.d.ts.map +1 -1
- package/dist/Metrics.js +39 -0
- package/dist/Metrics.js.map +1 -1
- package/dist/Worker.d.ts +120 -11
- package/dist/Worker.d.ts.map +1 -1
- package/dist/Worker.js +452 -26
- package/dist/Worker.js.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts +19 -1
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.js +662 -81
- package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
- package/dist/drizzle-postgres/schema.d.ts +310 -3
- package/dist/drizzle-postgres/schema.d.ts.map +1 -1
- package/dist/drizzle-postgres/schema.js +68 -1
- package/dist/drizzle-postgres/schema.js.map +1 -1
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -1
- package/dist/redis/RedisJobStore.d.ts.map +1 -1
- package/dist/redis/RedisJobStore.js +221 -19
- package/dist/redis/RedisJobStore.js.map +1 -1
- package/dist/redis/scripts.d.ts +118 -11
- package/dist/redis/scripts.d.ts.map +1 -1
- package/dist/redis/scripts.js +497 -28
- package/dist/redis/scripts.js.map +1 -1
- package/dist/testing/conformance.d.ts +6 -0
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +765 -1
- package/dist/testing/conformance.js.map +1 -1
- package/package.json +1 -1
- package/src/Flow.ts +778 -0
- package/src/Job.ts +42 -11
- package/src/JobSchedules.ts +223 -0
- package/src/JobStore.ts +347 -9
- package/src/MemoryJobStore.ts +372 -8
- package/src/Metrics.ts +43 -0
- package/src/Worker.ts +726 -37
- package/src/drizzle-postgres/DrizzleJobStore.ts +827 -82
- package/src/drizzle-postgres/schema.ts +94 -0
- package/src/index.ts +16 -0
- package/src/redis/RedisJobStore.ts +291 -8
- package/src/redis/scripts.ts +529 -26
- package/src/testing/conformance.ts +989 -1
package/src/redis/scripts.ts
CHANGED
|
@@ -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
|
-
--
|
|
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
|
|
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
|
|
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, "
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
750
|
-
*
|
|
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[
|
|
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[
|
|
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
|
|
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
|
|
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 *
|
|
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 +
|
|
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
|
-
|
|
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>()
|