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/dist/redis/scripts.js
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
|
*/
|
|
@@ -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
|
-
--
|
|
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
|
|
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
|
|
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, "
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
684
|
-
*
|
|
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[
|
|
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[
|
|
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
|
|
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
|
|
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 *
|
|
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 +
|
|
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
|
-
|
|
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
|