effect-mq 0.5.0 → 0.7.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 +31 -6
- package/dist/Job.d.ts.map +1 -1
- package/dist/Job.js +16 -2
- package/dist/Job.js.map +1 -1
- package/dist/JobStore.d.ts +353 -13
- package/dist/JobStore.d.ts.map +1 -1
- package/dist/JobStore.js +10 -0
- package/dist/JobStore.js.map +1 -1
- package/dist/MemoryJobStore.d.ts.map +1 -1
- package/dist/MemoryJobStore.js +361 -21
- 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 +678 -80
- package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
- package/dist/drizzle-postgres/schema.d.ts +293 -3
- package/dist/drizzle-postgres/schema.d.ts.map +1 -1
- package/dist/drizzle-postgres/schema.js +66 -1
- package/dist/drizzle-postgres/schema.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/redis/RedisJobStore.d.ts +53 -0
- package/dist/redis/RedisJobStore.d.ts.map +1 -1
- package/dist/redis/RedisJobStore.js +402 -50
- package/dist/redis/RedisJobStore.js.map +1 -1
- package/dist/redis/scripts.d.ts +213 -35
- package/dist/redis/scripts.d.ts.map +1 -1
- package/dist/redis/scripts.js +689 -73
- 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 +855 -12
- package/dist/testing/conformance.js.map +1 -1
- package/package.json +1 -1
- package/src/Flow.ts +778 -0
- package/src/Job.ts +35 -11
- package/src/JobStore.ts +377 -12
- package/src/MemoryJobStore.ts +396 -25
- package/src/Metrics.ts +43 -0
- package/src/Worker.ts +726 -37
- package/src/drizzle-postgres/DrizzleJobStore.ts +844 -81
- package/src/drizzle-postgres/schema.ts +92 -0
- package/src/index.ts +8 -0
- package/src/redis/RedisJobStore.ts +540 -39
- package/src/redis/scripts.ts +751 -78
- package/src/testing/conformance.ts +1088 -12
package/src/redis/scripts.ts
CHANGED
|
@@ -21,6 +21,19 @@
|
|
|
21
21
|
* - `p:delayed:<queue>` ZSET, score `runAt`
|
|
22
22
|
* - `p:active` ZSET, score `lockExpiresAt`
|
|
23
23
|
* - `p:all` ZSET, score `enqueuedAt` (list pagination)
|
|
24
|
+
* - `p:byname:<name>` ZSET, score `enqueuedAt` (list name index;
|
|
25
|
+
* optional, `RedisJobStoreOptions.indexes.name`)
|
|
26
|
+
* - `p:byqueue:<queue>` ZSET, score `enqueuedAt` (list queue index;
|
|
27
|
+
* optional, `RedisJobStoreOptions.indexes.queue`)
|
|
28
|
+
* - `p:index:<kind>:ready` STRING, millis timestamp of the last index
|
|
29
|
+
* reconcile's start for `<kind>` (name |
|
|
30
|
+
* queue). Absent: the next enabled boot does a
|
|
31
|
+
* full ZSCAN rebuild. Present: enabled boots
|
|
32
|
+
* heal the tail (rows enqueued since the value
|
|
33
|
+
* minus a safety margin) and re-stamp it —
|
|
34
|
+
* closing the rolling-deploy window where
|
|
35
|
+
* index-less writers inserted rows after the
|
|
36
|
+
* marker landed
|
|
24
37
|
* - `p:finished:<state>` ZSET, score `finishedAt` (history TTL)
|
|
25
38
|
* - `p:terminal:<name>:<state>` ZSET, score `finishedAt` (keep pruning)
|
|
26
39
|
* - `p:counts` HASH `<queue>|<state>` -> integer
|
|
@@ -28,16 +41,50 @@
|
|
|
28
41
|
* - `p:schedules` / `p:schedule:<key>` ZSET by nextRunAt + HASH per record
|
|
29
42
|
* - `p:dedupe:<name>\0<key>` HASH {jobId, expiresAt} + `p:dedupes` index
|
|
30
43
|
* ZSET (score = window expiry, +inf = pending)
|
|
44
|
+
* - `p:flowchild:<flowId>\0<childKey>` HASH of one flow dependency row
|
|
45
|
+
* - `p:flowchildren:<flowId>` ZSET (score 0, member = childKey; ZRANGEBYLEX
|
|
46
|
+
* gives child-key order + cursor pagination)
|
|
47
|
+
* - `p:flowpending` ZSET, member `<flowId>\0<childKey>`, score =
|
|
48
|
+
* sweep-eligibility timestamp (pendingSince at
|
|
49
|
+
* staging, re-armed on sweep return)
|
|
50
|
+
* - `p:flowcascade` ZSET, score 0, member `<flowId>\0<childKey>`
|
|
51
|
+
* (cancels still owed to child stores)
|
|
52
|
+
* - `p:flowoutbox` ZSET, score = seq from `p:flowoutbox:seq`,
|
|
53
|
+
* member `<seq>\0<report json>` — undelivered
|
|
54
|
+
* child-result reports (see OutboxEntry). The
|
|
55
|
+
* seq prefix makes every member id a peek
|
|
56
|
+
* cursor that survives deletions (see
|
|
57
|
+
* peekOutbox in RedisJobStore)
|
|
58
|
+
*
|
|
59
|
+
* `waiting-children` parents live only in the job hash, `p:all`, and
|
|
60
|
+
* `p:counts` — never in a pending zset, so `claim` can never return them.
|
|
31
61
|
*
|
|
32
62
|
* @since 0.2.0
|
|
33
63
|
*/
|
|
34
64
|
import { Redis } from "effect/unstable/persistence"
|
|
35
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Which optional list indexes (`p:byname:<name>` / `p:byqueue:<queue>`) this
|
|
68
|
+
* store maintains. The flags are baked into the script text (the helpers are
|
|
69
|
+
* shared by every script), so a store instance only ever runs scripts that
|
|
70
|
+
* match its configuration — disabled indexes cost no writes at all.
|
|
71
|
+
*
|
|
72
|
+
* @since 0.7.0
|
|
73
|
+
*/
|
|
74
|
+
export interface IndexConfig {
|
|
75
|
+
readonly name: boolean
|
|
76
|
+
readonly queue: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
36
79
|
/**
|
|
37
80
|
* Shared helpers textually prepended to every script (the `Redis.script`
|
|
38
81
|
* runner has no include mechanism). `ARGV[1]` is always the key prefix.
|
|
82
|
+
* Built once per store instance: `insertJobRow` writes only the list indexes
|
|
83
|
+
* enabled by `IndexConfig` (`deleteJob` clears both unconditionally — a ZREM
|
|
84
|
+
* on a missing key is free, and stray entries from an earlier configuration
|
|
85
|
+
* must never outlive their job).
|
|
39
86
|
*/
|
|
40
|
-
const
|
|
87
|
+
export const helpers = (indexes: IndexConfig): string => `
|
|
41
88
|
local prefix = ARGV[1]
|
|
42
89
|
local function fmt(x) return string.format("%.0f", x) end
|
|
43
90
|
local function jobKey(id) return prefix .. ":job:" .. id end
|
|
@@ -45,6 +92,8 @@ local function attemptsKey(id) return prefix .. ":attempts:" .. id end
|
|
|
45
92
|
local function waitingKey(queue) return prefix .. ":waiting:" .. queue end
|
|
46
93
|
local function delayedKey(queue) return prefix .. ":delayed:" .. queue end
|
|
47
94
|
local function terminalKey(name, state) return prefix .. ":terminal:" .. name .. ":" .. state end
|
|
95
|
+
local function bynameKey(name) return prefix .. ":byname:" .. name end
|
|
96
|
+
local function byqueueKey(queue) return prefix .. ":byqueue:" .. queue end
|
|
48
97
|
-- Waiting order: score = -priority (higher priority first, full number range);
|
|
49
98
|
-- FIFO within a priority via lexicographic members "<seq %016d>:<id>". A
|
|
50
99
|
-- composite numeric score would clip either priority or seq past float53.
|
|
@@ -71,7 +120,79 @@ local function appendAttempt(id, outcome, startedAt, finishedAt, exitJson)
|
|
|
71
120
|
'{"attempt":' .. n .. ',"startedAt":' .. started .. ',"finishedAt":' .. finishedAt ..
|
|
72
121
|
',"outcome":"' .. outcome .. '"' .. ex .. '}')
|
|
73
122
|
end
|
|
74
|
-
--
|
|
123
|
+
-- The outbox invariant: every operation that moves a job carrying a parent
|
|
124
|
+
-- envelope INTO a terminal state appends its child-result report here, in
|
|
125
|
+
-- the same script. MUST run after the terminal fields (exit, failedReason)
|
|
126
|
+
-- are written — the report is built from the hash. The parent envelope and
|
|
127
|
+
-- exit are spliced as raw JSON (never cjson-decoded: precision, surrogates);
|
|
128
|
+
-- exit/failedReason keys are OMITTED when absent, like the attempts ledger.
|
|
129
|
+
local function appendOutbox(id, outcome)
|
|
130
|
+
local jk = jobKey(id)
|
|
131
|
+
local parentJson = redis.call("HGET", jk, "parent")
|
|
132
|
+
if parentJson == false or parentJson == "" then return end
|
|
133
|
+
local exitJson = redis.call("HGET", jk, "exit")
|
|
134
|
+
local failedReason = redis.call("HGET", jk, "failedReason")
|
|
135
|
+
local ex = (exitJson == false or exitJson == "") and "" or (',"exit":' .. exitJson)
|
|
136
|
+
local fr = (failedReason == false or failedReason == "") and ""
|
|
137
|
+
or (',"failedReason":' .. cjson.encode(failedReason))
|
|
138
|
+
local seq = redis.call("INCR", prefix .. ":flowoutbox:seq")
|
|
139
|
+
redis.call("ZADD", prefix .. ":flowoutbox", seq, fmt(seq) .. "\0" ..
|
|
140
|
+
'{"parent":' .. parentJson .. ',"outcome":"' .. outcome .. '"' .. ex .. fr .. '}')
|
|
141
|
+
end
|
|
142
|
+
-- Flow dependency rows live in the parent store, one hash per child plus a
|
|
143
|
+
-- per-flow member index (childKey order via ZRANGEBYLEX). Row keys and the
|
|
144
|
+
-- sweep-index members join flowId and childKey with NUL — ids and keys may
|
|
145
|
+
-- both contain ":", so a printable separator could alias two distinct
|
|
146
|
+
-- (flowId, childKey) pairs onto one row.
|
|
147
|
+
local function flowChildKey(flowId, childKey) return prefix .. ":flowchild:" .. flowId .. "\0" .. childKey end
|
|
148
|
+
local function flowIndexKey(flowId) return prefix .. ":flowchildren:" .. flowId end
|
|
149
|
+
local function flowMember(flowId, childKey) return flowId .. "\0" .. childKey end
|
|
150
|
+
-- Settle-time marking: remaining pending rows flip to cancelled (NOT
|
|
151
|
+
-- cascaded — the sweeper still owes the child stores real cancels), moving
|
|
152
|
+
-- from the pending sweep index to the cascade sweep index. Returns the
|
|
153
|
+
-- number of rows flipped, for the flow counters.
|
|
154
|
+
local function markPendingRowsCancelled(flowId)
|
|
155
|
+
local keys = redis.call("ZRANGE", flowIndexKey(flowId), 0, -1)
|
|
156
|
+
local marked = 0
|
|
157
|
+
for i = 1, #keys do
|
|
158
|
+
local rk = flowChildKey(flowId, keys[i])
|
|
159
|
+
if redis.call("HGET", rk, "status") == "pending" then
|
|
160
|
+
redis.call("HSET", rk, "status", "cancelled", "cascaded", "0")
|
|
161
|
+
redis.call("ZREM", prefix .. ":flowpending", flowMember(flowId, keys[i]))
|
|
162
|
+
redis.call("ZADD", prefix .. ":flowcascade", 0, flowMember(flowId, keys[i]))
|
|
163
|
+
marked = marked + 1
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
return marked
|
|
167
|
+
end
|
|
168
|
+
-- Settle-time marking plus the manifest counters: pending -> 0, cancelled +=
|
|
169
|
+
-- the rows flipped (the four counters always sum to the manifest size).
|
|
170
|
+
local function settleMarkRows(flowId)
|
|
171
|
+
local marked = markPendingRowsCancelled(flowId)
|
|
172
|
+
local jk = jobKey(flowId)
|
|
173
|
+
if redis.call("HEXISTS", jk, "flowPending") == 1 then
|
|
174
|
+
redis.call("HSET", jk, "flowPending", "0")
|
|
175
|
+
if marked > 0 then redis.call("HINCRBY", jk, "flowCancelled", marked) end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
-- A settled flow parent whose rows still owe cascade cancels is exempt from
|
|
179
|
+
-- AUTOMATIC retention (keep policies, the history sweep): deleting it would
|
|
180
|
+
-- delete the only record that the child stores are still owed real cancels.
|
|
181
|
+
-- The explicit remove verb is the operator override and is NOT exempted.
|
|
182
|
+
-- Cheap for non-parents: the per-flow index only exists for fanned-out jobs.
|
|
183
|
+
local function owesCascades(id)
|
|
184
|
+
if redis.call("EXISTS", flowIndexKey(id)) == 0 then return false end
|
|
185
|
+
local keys = redis.call("ZRANGE", flowIndexKey(id), 0, -1)
|
|
186
|
+
for i = 1, #keys do
|
|
187
|
+
local rk = flowChildKey(id, keys[i])
|
|
188
|
+
if redis.call("HGET", rk, "status") == "cancelled" and redis.call("HGET", rk, "cascaded") == "0" then
|
|
189
|
+
return true
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
return false
|
|
193
|
+
end
|
|
194
|
+
-- Remove a job and every index entry that references it. A flow parent takes
|
|
195
|
+
-- its dependency rows and their sweep-index members with it (flowId = job id).
|
|
75
196
|
local function deleteJob(id)
|
|
76
197
|
local jk = jobKey(id)
|
|
77
198
|
local queue = redis.call("HGET", jk, "queue")
|
|
@@ -80,11 +201,20 @@ local function deleteJob(id)
|
|
|
80
201
|
local name = redis.call("HGET", jk, "name")
|
|
81
202
|
countsAdd(queue, state, -1)
|
|
82
203
|
redis.call("ZREM", prefix .. ":all", id)
|
|
204
|
+
redis.call("ZREM", bynameKey(name), id)
|
|
205
|
+
redis.call("ZREM", byqueueKey(queue), id)
|
|
83
206
|
redis.call("ZREM", prefix .. ":finished:" .. state, id)
|
|
84
207
|
redis.call("ZREM", prefix .. ":active", id)
|
|
85
208
|
redis.call("ZREM", terminalKey(name, state), id)
|
|
86
209
|
remWaiting(queue, id)
|
|
87
210
|
redis.call("ZREM", delayedKey(queue), id)
|
|
211
|
+
local children = redis.call("ZRANGE", flowIndexKey(id), 0, -1)
|
|
212
|
+
for i = 1, #children do
|
|
213
|
+
redis.call("DEL", flowChildKey(id, children[i]))
|
|
214
|
+
redis.call("ZREM", prefix .. ":flowpending", flowMember(id, children[i]))
|
|
215
|
+
redis.call("ZREM", prefix .. ":flowcascade", flowMember(id, children[i]))
|
|
216
|
+
end
|
|
217
|
+
redis.call("DEL", flowIndexKey(id))
|
|
88
218
|
redis.call("DEL", jk, attemptsKey(id))
|
|
89
219
|
end
|
|
90
220
|
-- Terminal retention for one name+state group. Correctness over speed: the
|
|
@@ -107,9 +237,13 @@ local function applyKeep(name, state, keepJson, now)
|
|
|
107
237
|
end
|
|
108
238
|
end
|
|
109
239
|
local tkey = terminalKey(name, state)
|
|
240
|
+
-- Retention exemption: parents still owing cascade cancels are spared
|
|
241
|
+
-- (they still occupy a keep-count slot, exactly like the memory driver).
|
|
110
242
|
if keep.ageMs ~= nil then
|
|
111
243
|
local old = redis.call("ZRANGEBYSCORE", tkey, "-inf", now - keep.ageMs)
|
|
112
|
-
for i = 1, #old do
|
|
244
|
+
for i = 1, #old do
|
|
245
|
+
if not owesCascades(old[i]) then deleteJob(old[i]) end
|
|
246
|
+
end
|
|
113
247
|
end
|
|
114
248
|
-- Floor + clamp: a fractional/negative count must degrade like the memory
|
|
115
249
|
-- driver's slice(), never error mid-script (writes before an error stick).
|
|
@@ -128,7 +262,9 @@ local function applyKeep(name, state, keepJson, now)
|
|
|
128
262
|
if a.fa ~= b.fa then return a.fa > b.fa end
|
|
129
263
|
return a.seq > b.seq
|
|
130
264
|
end)
|
|
131
|
-
for i = count + 1, #arr do
|
|
265
|
+
for i = count + 1, #arr do
|
|
266
|
+
if not owesCascades(arr[i].id) then deleteJob(arr[i].id) end
|
|
267
|
+
end
|
|
132
268
|
end
|
|
133
269
|
end
|
|
134
270
|
local function dedupeStoreKey(name, key) return prefix .. ":dedupe:" .. name .. "\0" .. key end
|
|
@@ -156,14 +292,30 @@ local function finishCancelled(id, queue, name, startedAt, now, nowStr)
|
|
|
156
292
|
redis.call("ZADD", prefix .. ":finished:cancelled", now, id)
|
|
157
293
|
redis.call("ZADD", terminalKey(name, "cancelled"), now, id)
|
|
158
294
|
appendAttempt(id, "cancelled", startedAt, nowStr, "")
|
|
295
|
+
appendOutbox(id, "cancelled")
|
|
159
296
|
releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
|
|
160
297
|
applyKeep(name, "cancelled", redis.call("HGET", jk, "keep"), now)
|
|
161
298
|
end
|
|
299
|
+
-- Index one EXISTING job row into a list index (backfill and tail heal;
|
|
300
|
+
-- insertJobRow covers live writes). A missing row is skipped — nothing to
|
|
301
|
+
-- index, and the read path self-heals whatever stale member pointed here.
|
|
302
|
+
local function indexInto(kind, id)
|
|
303
|
+
local row = redis.call("HMGET", jobKey(id), "name", "queue", "enqueuedAt")
|
|
304
|
+
if row[1] then
|
|
305
|
+
local enqueuedAt = tonumber(row[3]) or 0
|
|
306
|
+
if kind == "name" then
|
|
307
|
+
redis.call("ZADD", bynameKey(row[1]), enqueuedAt, id)
|
|
308
|
+
else
|
|
309
|
+
redis.call("ZADD", byqueueKey(row[2]), enqueuedAt, id)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
162
313
|
-- 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.
|
|
314
|
+
-- verbatim (payload/metadata/backoff/keep/trace/parent are pre-encoded JSON,
|
|
315
|
+
-- "" = absent); priority/delayMs/now numeric-coercible. New jobs never carry
|
|
316
|
+
-- flow fields — only the FanOut ack writes those.
|
|
165
317
|
local function insertJobRow(id, name, queue, payloadJson, metadataJson, priority,
|
|
166
|
-
attemptsMax, backoffJson, keepJson, timeoutMs, dedupeKey, traceJson, delayMs, now, nowStr)
|
|
318
|
+
attemptsMax, backoffJson, keepJson, timeoutMs, dedupeKey, traceJson, parentJson, delayMs, now, nowStr)
|
|
167
319
|
local seq = redis.call("INCR", prefix .. ":seq")
|
|
168
320
|
local state = delayMs > 0 and "delayed" or "waiting"
|
|
169
321
|
local runAt = now + delayMs
|
|
@@ -172,11 +324,14 @@ local function insertJobRow(id, name, queue, payloadJson, metadataJson, priority
|
|
|
172
324
|
"payload", payloadJson, "metadata", metadataJson, "state", state,
|
|
173
325
|
"priority", priority, "attemptsMax", attemptsMax, "attemptsMade", "0", "stalledCount", "0",
|
|
174
326
|
"backoff", backoffJson, "keep", keepJson, "timeoutMs", timeoutMs,
|
|
175
|
-
"cancelRequested", "0", "dedupeKey", dedupeKey, "trace", traceJson, "
|
|
327
|
+
"cancelRequested", "0", "dedupeKey", dedupeKey, "trace", traceJson, "parent", parentJson,
|
|
328
|
+
"runAt", fmt(runAt), "enqueuedAt", nowStr,
|
|
176
329
|
"processedAt", "", "finishedAt", "", "exit", "", "failedReason", "",
|
|
177
330
|
"lockToken", "", "lockExpiresAt", "", "seq", fmt(seq))
|
|
178
331
|
redis.call("ZADD", prefix .. ":all", now, id)
|
|
179
|
-
|
|
332
|
+
${indexes.name ? ` redis.call("ZADD", bynameKey(name), now, id)\n` : ""}${
|
|
333
|
+
indexes.queue ? ` redis.call("ZADD", byqueueKey(queue), now, id)\n` : ""
|
|
334
|
+
} if state == "waiting" then
|
|
180
335
|
addWaiting(queue, tonumber(priority), seq, id)
|
|
181
336
|
else
|
|
182
337
|
redis.call("ZADD", delayedKey(queue), runAt, id)
|
|
@@ -187,11 +342,12 @@ end
|
|
|
187
342
|
|
|
188
343
|
/**
|
|
189
344
|
* enqueue(prefix, idMode, id, name, queue, payloadJson, metadataJson,
|
|
190
|
-
* priority, attemptsMax, backoffJson, keepJson, timeoutMs, delayMs,
|
|
345
|
+
* priority, attemptsMax, backoffJson, keepJson, timeoutMs, delayMs,
|
|
346
|
+
* now, dedupe..., traceJson, parentJson)
|
|
191
347
|
* idMode: "user" (dedup no-op), "generated" (collision -> retry sentinel),
|
|
192
348
|
* "auto" (j-<seq>, in-script collision loop).
|
|
193
349
|
*/
|
|
194
|
-
export const enqueue = Redis.script(
|
|
350
|
+
export const enqueue = (HELPERS: string) => Redis.script(
|
|
195
351
|
(
|
|
196
352
|
prefix: string,
|
|
197
353
|
idMode: string,
|
|
@@ -211,7 +367,8 @@ export const enqueue = Redis.script(
|
|
|
211
367
|
dedupeTtlMs: string,
|
|
212
368
|
dedupeExtend: string,
|
|
213
369
|
dedupeReplace: string,
|
|
214
|
-
traceJson: string
|
|
370
|
+
traceJson: string,
|
|
371
|
+
parentJson: string
|
|
215
372
|
) => [
|
|
216
373
|
prefix,
|
|
217
374
|
idMode,
|
|
@@ -231,7 +388,8 @@ export const enqueue = Redis.script(
|
|
|
231
388
|
dedupeTtlMs,
|
|
232
389
|
dedupeExtend,
|
|
233
390
|
dedupeReplace,
|
|
234
|
-
traceJson
|
|
391
|
+
traceJson,
|
|
392
|
+
parentJson
|
|
235
393
|
],
|
|
236
394
|
{
|
|
237
395
|
numberOfKeys: 0,
|
|
@@ -305,7 +463,7 @@ if idMode == "auto" then
|
|
|
305
463
|
if id == "" then return '{"error":"id"}' end
|
|
306
464
|
end
|
|
307
465
|
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)
|
|
466
|
+
ARGV[12], dKey, ARGV[19], ARGV[20], delayMs, now, nowStr)
|
|
309
467
|
if dKey ~= "" then
|
|
310
468
|
local sk = dedupeStoreKey(name, dKey)
|
|
311
469
|
redis.call("DEL", sk)
|
|
@@ -327,7 +485,7 @@ return '{"id":' .. cjson.encode(id) .. ',"duplicate":false,"wake":true}'
|
|
|
327
485
|
* waiting job whose name matches. Returns the claimed record (HGETALL pairs)
|
|
328
486
|
* or an Empty result with the earliest matching delayed runAt.
|
|
329
487
|
*/
|
|
330
|
-
export const claim = Redis.script(
|
|
488
|
+
export const claim = (HELPERS: string) => Redis.script(
|
|
331
489
|
(prefix: string, queue: string, namesJson: string, token: string, lockDurationMs: number, now: number) => [
|
|
332
490
|
prefix,
|
|
333
491
|
queue,
|
|
@@ -404,7 +562,7 @@ return cjson.encode({ empty = true, nextRunAt = nextRunAt })
|
|
|
404
562
|
* Token-guarded. Retry on a cancel-requested job finishes it as cancelled
|
|
405
563
|
* (cancellation wins over revival, mirroring release/recoverStalled).
|
|
406
564
|
*/
|
|
407
|
-
export const ack = Redis.script(
|
|
565
|
+
export const ack = (HELPERS: string) => Redis.script(
|
|
408
566
|
(prefix: string, id: string, token: string, outcomeTag: string, exitJson: string, delayMs: number, now: number) => [
|
|
409
567
|
prefix,
|
|
410
568
|
id,
|
|
@@ -445,6 +603,7 @@ local function finish(newState, storeExit, outcome, ledgerExit)
|
|
|
445
603
|
redis.call("ZADD", prefix .. ":finished:" .. newState, now, id)
|
|
446
604
|
redis.call("ZADD", terminalKey(name, newState), now, id)
|
|
447
605
|
appendAttempt(id, outcome, startedAt, nowStr, ledgerExit)
|
|
606
|
+
appendOutbox(id, newState)
|
|
448
607
|
releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
|
|
449
608
|
applyKeep(name, newState, redis.call("HGET", jk, "keep"), now)
|
|
450
609
|
end
|
|
@@ -483,7 +642,7 @@ return '{"ok":true}'
|
|
|
483
642
|
* release(prefix, id, token, now) — hand the job back without consuming an
|
|
484
643
|
* attempt; a pending cancel wins and finishes the job instead.
|
|
485
644
|
*/
|
|
486
|
-
export const release = Redis.script(
|
|
645
|
+
export const release = (HELPERS: string) => Redis.script(
|
|
487
646
|
(prefix: string, id: string, token: string, now: number) => [prefix, id, token, now],
|
|
488
647
|
{
|
|
489
648
|
numberOfKeys: 0,
|
|
@@ -517,7 +676,7 @@ return '{"ok":true,"wake":true,"queue":' .. cjson.encode(queue) .. '}'
|
|
|
517
676
|
* extendLocks(prefix, locksJson, durationMs, now) -> { lost, cancel }
|
|
518
677
|
* Cancel-requested locks are reported, not extended.
|
|
519
678
|
*/
|
|
520
|
-
export const extendLocks = Redis.script(
|
|
679
|
+
export const extendLocks = (HELPERS: string) => Redis.script(
|
|
521
680
|
(prefix: string, locksJson: string, durationMs: number, now: number) => [prefix, locksJson, durationMs, now],
|
|
522
681
|
{
|
|
523
682
|
numberOfKeys: 0,
|
|
@@ -546,7 +705,7 @@ return cjson.encode({ lost = lost, cancel = cancel })
|
|
|
546
705
|
* recoverStalled(prefix, maxStalledCount, now) -> recovered [{id, failed}]
|
|
547
706
|
* A pending cancel finishes the job as cancelled (not reported as recovered).
|
|
548
707
|
*/
|
|
549
|
-
export const recoverStalled = Redis.script(
|
|
708
|
+
export const recoverStalled = (HELPERS: string) => Redis.script(
|
|
550
709
|
(prefix: string, maxStalledCount: number, now: number) => [prefix, maxStalledCount, now],
|
|
551
710
|
{
|
|
552
711
|
numberOfKeys: 0,
|
|
@@ -576,6 +735,7 @@ for _, id in ipairs(expired) do
|
|
|
576
735
|
countsAdd(queue, "failed", 1)
|
|
577
736
|
redis.call("ZADD", prefix .. ":finished:failed", now, id)
|
|
578
737
|
redis.call("ZADD", terminalKey(name, "failed"), now, id)
|
|
738
|
+
appendOutbox(id, "failed")
|
|
579
739
|
releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
|
|
580
740
|
recovered[#recovered + 1] = { id = id, failed = true }
|
|
581
741
|
else
|
|
@@ -597,7 +757,7 @@ return cjson.encode(recovered)
|
|
|
597
757
|
).withReturnType<string>()
|
|
598
758
|
|
|
599
759
|
/** getJob(prefix, id) -> HGETALL pairs (empty array when missing). */
|
|
600
|
-
export const getJob = Redis.script(
|
|
760
|
+
export const getJob = (HELPERS: string) => Redis.script(
|
|
601
761
|
(prefix: string, id: string) => [prefix, id],
|
|
602
762
|
{
|
|
603
763
|
numberOfKeys: 0,
|
|
@@ -610,46 +770,117 @@ return cjson.encode(record)
|
|
|
610
770
|
).withReturnType<string>()
|
|
611
771
|
|
|
612
772
|
/**
|
|
613
|
-
* list(prefix, filtersJson, cursor, limit)
|
|
614
|
-
*
|
|
773
|
+
* list(prefix, sourcesJson, order, filtersJson, cursor, limit)
|
|
774
|
+
*
|
|
775
|
+
* Indexed list. The DRIVER routes the query to the narrowest zset(s) — see
|
|
776
|
+
* `RedisJobStore`'s routing matrix — and this script merges those sorted
|
|
777
|
+
* sources by (score, id) in the requested direction, pages past the
|
|
778
|
+
* exclusive `<orderValue>:<id>` keyset cursor, loads each candidate row, and
|
|
779
|
+
* applies the residual predicates until `limit` matches accumulate. Sources
|
|
780
|
+
* must be plain-id-membered zsets whose score IS the requested order value
|
|
781
|
+
* (`all`/`byname:`/`byqueue:` = enqueuedAt, `delayed:<queue>` = runAt,
|
|
782
|
+
* `finished:`/`terminal:` = finishedAt) and must be pairwise disjoint; the
|
|
783
|
+
* waiting zsets (seq-prefixed members, priority scores) are never routed
|
|
784
|
+
* here. A member whose job hash is gone is an orphan: it is ZREM'd from the
|
|
785
|
+
* source being scanned (self-heal) and skipped.
|
|
615
786
|
*/
|
|
616
|
-
export const list =
|
|
617
|
-
(
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
787
|
+
export const list = (HELPERS: string) =>
|
|
788
|
+
Redis.script(
|
|
789
|
+
(prefix: string, sourcesJson: string, order: string, filtersJson: string, cursor: string, limit: number) => [
|
|
790
|
+
prefix,
|
|
791
|
+
sourcesJson,
|
|
792
|
+
order,
|
|
793
|
+
filtersJson,
|
|
794
|
+
cursor,
|
|
795
|
+
limit
|
|
796
|
+
],
|
|
797
|
+
{
|
|
798
|
+
numberOfKeys: 0,
|
|
799
|
+
lua: `${HELPERS}
|
|
800
|
+
-- Input that Redis's cjson cannot decode (e.g. lone-surrogate escapes in a
|
|
801
|
+
-- filter or key name) degrades to an empty page instead of a script error.
|
|
802
|
+
local okSources, sources = pcall(cjson.decode, ARGV[2])
|
|
803
|
+
local okFilters, filters = pcall(cjson.decode, ARGV[4])
|
|
804
|
+
if not okSources or not okFilters then return '{"items":[],"more":false}' end
|
|
805
|
+
local desc = ARGV[3] == "desc"
|
|
625
806
|
local stateSet = nil
|
|
626
807
|
if filters.states ~= nil then
|
|
627
808
|
stateSet = {}
|
|
628
809
|
for _, s in ipairs(filters.states) do stateSet[s] = true end
|
|
629
810
|
end
|
|
630
811
|
local cursorAt, cursorId = nil, nil
|
|
631
|
-
if ARGV[
|
|
632
|
-
local split = string.find(ARGV[
|
|
812
|
+
if ARGV[5] ~= "" then
|
|
813
|
+
local split = string.find(ARGV[5], ":", 1, true)
|
|
633
814
|
if split ~= nil then
|
|
634
|
-
cursorAt = tonumber(string.sub(ARGV[
|
|
635
|
-
cursorId = string.sub(ARGV[
|
|
815
|
+
cursorAt = tonumber(string.sub(ARGV[5], 1, split - 1))
|
|
816
|
+
cursorId = string.sub(ARGV[5], split + 1)
|
|
636
817
|
end
|
|
637
818
|
if cursorAt == nil then cursorId = nil end
|
|
638
819
|
end
|
|
639
|
-
local limit = tonumber(ARGV[
|
|
820
|
+
local limit = tonumber(ARGV[6])
|
|
821
|
+
-- One buffered iterator per source. offset counts consumed members still in
|
|
822
|
+
-- the zset — a self-healed orphan decrements it, because its ZREM shifts
|
|
823
|
+
-- every later rank down by one — so refills stay exact under in-script
|
|
824
|
+
-- removals. The score bound is the cursor score (inclusive; the per-item
|
|
825
|
+
-- check below excludes ids at or before the cursor within that score).
|
|
826
|
+
local iters = {}
|
|
827
|
+
for i = 1, #sources do
|
|
828
|
+
iters[i] = { key = sources[i], offset = 0, buf = {}, pos = 1, exhausted = false }
|
|
829
|
+
end
|
|
830
|
+
local function head(it)
|
|
831
|
+
if it.pos > #it.buf then
|
|
832
|
+
if it.exhausted then return nil end
|
|
833
|
+
if desc then
|
|
834
|
+
it.buf = redis.call("ZREVRANGEBYSCORE", it.key, cursorAt == nil and "+inf" or fmt(cursorAt), "-inf",
|
|
835
|
+
"WITHSCORES", "LIMIT", it.offset, 100)
|
|
836
|
+
else
|
|
837
|
+
it.buf = redis.call("ZRANGEBYSCORE", it.key, cursorAt == nil and "-inf" or fmt(cursorAt), "+inf",
|
|
838
|
+
"WITHSCORES", "LIMIT", it.offset, 100)
|
|
839
|
+
end
|
|
840
|
+
it.pos = 1
|
|
841
|
+
if #it.buf == 0 then
|
|
842
|
+
it.exhausted = true
|
|
843
|
+
return nil
|
|
844
|
+
end
|
|
845
|
+
end
|
|
846
|
+
return it.buf[it.pos], tonumber(it.buf[it.pos + 1])
|
|
847
|
+
end
|
|
640
848
|
local items = {}
|
|
641
|
-
local
|
|
642
|
-
local max = cursorAt == nil and "+inf" or fmt(cursorAt)
|
|
643
|
-
local offset = 0
|
|
849
|
+
local more = false
|
|
644
850
|
while true do
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
local at =
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
851
|
+
-- The best head across sources: (score, id) in the requested direction (a
|
|
852
|
+
-- score-tied range comes back in member-lex order, so ids line up too).
|
|
853
|
+
local best, bestId, bestAt = nil, nil, nil
|
|
854
|
+
for i = 1, #iters do
|
|
855
|
+
local id, at = head(iters[i])
|
|
856
|
+
if id ~= nil then
|
|
857
|
+
local wins = best == nil
|
|
858
|
+
if not wins then
|
|
859
|
+
if at ~= bestAt then
|
|
860
|
+
wins = (desc and at > bestAt) or (not desc and at < bestAt)
|
|
861
|
+
else
|
|
862
|
+
wins = (desc and id > bestId) or (not desc and id < bestId)
|
|
863
|
+
end
|
|
864
|
+
end
|
|
865
|
+
if wins then
|
|
866
|
+
best, bestId, bestAt = iters[i], id, at
|
|
867
|
+
end
|
|
868
|
+
end
|
|
869
|
+
end
|
|
870
|
+
if best == nil then break end
|
|
871
|
+
best.pos = best.pos + 2
|
|
872
|
+
best.offset = best.offset + 1
|
|
873
|
+
-- Exclusive keyset cursor: skip up to and including the cursor position.
|
|
874
|
+
local past = cursorAt == nil
|
|
875
|
+
or (desc and (bestAt < cursorAt or (bestAt == cursorAt and bestId < cursorId)))
|
|
876
|
+
or (not desc and (bestAt > cursorAt or (bestAt == cursorAt and bestId > cursorId)))
|
|
877
|
+
if past then
|
|
878
|
+
local jk = jobKey(bestId)
|
|
879
|
+
if redis.call("EXISTS", jk) == 0 then
|
|
880
|
+
-- Orphaned index member (hash removed out of band): self-heal.
|
|
881
|
+
redis.call("ZREM", best.key, bestId)
|
|
882
|
+
best.offset = best.offset - 1
|
|
883
|
+
else
|
|
653
884
|
local matches = true
|
|
654
885
|
if filters.queue ~= nil and redis.call("HGET", jk, "queue") ~= filters.queue then matches = false end
|
|
655
886
|
if matches and filters.name ~= nil and redis.call("HGET", jk, "name") ~= filters.name then matches = false end
|
|
@@ -669,24 +900,76 @@ while true do
|
|
|
669
900
|
end
|
|
670
901
|
if matches then
|
|
671
902
|
if #items >= limit then
|
|
672
|
-
|
|
903
|
+
more = true
|
|
673
904
|
break
|
|
674
905
|
end
|
|
675
906
|
items[#items + 1] = redis.call("HGETALL", jk)
|
|
676
907
|
end
|
|
677
908
|
end
|
|
678
909
|
end
|
|
679
|
-
if moreMatches then break end
|
|
680
|
-
offset = offset + 100
|
|
681
910
|
end
|
|
682
911
|
if #items == 0 then return '{"items":[],"more":false}' end
|
|
683
|
-
return cjson.encode({ items = items, more =
|
|
912
|
+
return cjson.encode({ items = items, more = more })
|
|
913
|
+
`
|
|
914
|
+
}
|
|
915
|
+
).withReturnType<string>()
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* indexMembers(prefix, kind, idsJson) -> "1"
|
|
919
|
+
*
|
|
920
|
+
* Index one ZSCAN chunk of `p:all` members during a full rebuild. The driver
|
|
921
|
+
* owns the ZSCAN cursor loop (linear, tie-immune, guaranteed to terminate,
|
|
922
|
+
* and guaranteed to return every element present for the whole scan); this
|
|
923
|
+
* script only does the per-chunk work, so the single-threaded server is
|
|
924
|
+
* never held for the whole keyspace. Re-visited members and rows indexed
|
|
925
|
+
* live by insertJobRow mid-scan are idempotent ZADDs.
|
|
926
|
+
*/
|
|
927
|
+
export const indexMembers = (HELPERS: string) => Redis.script(
|
|
928
|
+
(prefix: string, kind: string, idsJson: string) => [prefix, kind, idsJson],
|
|
929
|
+
{
|
|
930
|
+
numberOfKeys: 0,
|
|
931
|
+
lua: `${HELPERS}
|
|
932
|
+
local kind = ARGV[2]
|
|
933
|
+
for _, id in ipairs(cjson.decode(ARGV[3])) do
|
|
934
|
+
indexInto(kind, id)
|
|
935
|
+
end
|
|
936
|
+
return "1"
|
|
937
|
+
`
|
|
938
|
+
}
|
|
939
|
+
).withReturnType<string>()
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* indexTailPage(prefix, kind, min, offset, pageSize) -> scanned count
|
|
943
|
+
*
|
|
944
|
+
* One bounded page of the boot-time tail heal: index every `p:all` member
|
|
945
|
+
* with score >= min (the previous marker minus a safety margin). Plain
|
|
946
|
+
* LIMIT offset paging — tails are small, and a rank-shift skip from a
|
|
947
|
+
* concurrent delete is covered by the margin plus the next boot's heal.
|
|
948
|
+
*/
|
|
949
|
+
export const indexTailPage = (HELPERS: string) => Redis.script(
|
|
950
|
+
(prefix: string, kind: string, min: string, offset: number, pageSize: number) => [
|
|
951
|
+
prefix,
|
|
952
|
+
kind,
|
|
953
|
+
min,
|
|
954
|
+
offset,
|
|
955
|
+
pageSize
|
|
956
|
+
],
|
|
957
|
+
{
|
|
958
|
+
numberOfKeys: 0,
|
|
959
|
+
lua: `${HELPERS}
|
|
960
|
+
local kind = ARGV[2]
|
|
961
|
+
local batch = redis.call("ZRANGEBYSCORE", prefix .. ":all", ARGV[3], "+inf",
|
|
962
|
+
"LIMIT", tonumber(ARGV[4]), tonumber(ARGV[5]))
|
|
963
|
+
for _, id in ipairs(batch) do
|
|
964
|
+
indexInto(kind, id)
|
|
965
|
+
end
|
|
966
|
+
return tostring(#batch)
|
|
684
967
|
`
|
|
685
968
|
}
|
|
686
969
|
).withReturnType<string>()
|
|
687
970
|
|
|
688
971
|
/** counts(prefix) -> HGETALL pairs of p:counts. */
|
|
689
|
-
export const counts = Redis.script(
|
|
972
|
+
export const counts = (HELPERS: string) => Redis.script(
|
|
690
973
|
(prefix: string) => [prefix],
|
|
691
974
|
{
|
|
692
975
|
numberOfKeys: 0,
|
|
@@ -698,15 +981,16 @@ return cjson.encode(pairs_)
|
|
|
698
981
|
}
|
|
699
982
|
).withReturnType<string>()
|
|
700
983
|
|
|
701
|
-
/** remove(prefix, id) -> removed boolean (active
|
|
702
|
-
export const remove = Redis.script(
|
|
984
|
+
/** remove(prefix, id) -> removed boolean (active/waiting-children refused). */
|
|
985
|
+
export const remove = (HELPERS: string) => Redis.script(
|
|
703
986
|
(prefix: string, id: string) => [prefix, id],
|
|
704
987
|
{
|
|
705
988
|
numberOfKeys: 0,
|
|
706
989
|
lua: `${HELPERS}
|
|
707
990
|
local id = ARGV[2]
|
|
708
991
|
local jk = jobKey(id)
|
|
709
|
-
|
|
992
|
+
local state = redis.call("HGET", jk, "state")
|
|
993
|
+
if redis.call("EXISTS", jk) == 0 or state == "active" or state == "waiting-children" then
|
|
710
994
|
return "0"
|
|
711
995
|
end
|
|
712
996
|
deleteJob(id)
|
|
@@ -716,7 +1000,7 @@ return "1"
|
|
|
716
1000
|
).withReturnType<string>()
|
|
717
1001
|
|
|
718
1002
|
/** retry(prefix, id, now) — failed -> waiting with a fresh budget. */
|
|
719
|
-
export const retry = Redis.script(
|
|
1003
|
+
export const retry = (HELPERS: string) => Redis.script(
|
|
720
1004
|
(prefix: string, id: string, now: number) => [prefix, id, now],
|
|
721
1005
|
{
|
|
722
1006
|
numberOfKeys: 0,
|
|
@@ -746,10 +1030,12 @@ return '{"ok":true,"queue":' .. cjson.encode(queue) .. '}'
|
|
|
746
1030
|
).withReturnType<string>()
|
|
747
1031
|
|
|
748
1032
|
/**
|
|
749
|
-
* cancel(prefix, id, now) — waiting/delayed become terminal
|
|
750
|
-
*
|
|
1033
|
+
* cancel(prefix, id, now) — waiting/delayed/waiting-children become terminal
|
|
1034
|
+
* (a parked flow parent also flips its remaining pending rows to cancelled,
|
|
1035
|
+
* handing them to the cascade sweep); active gets the cancel-request flag;
|
|
1036
|
+
* terminal states are refused.
|
|
751
1037
|
*/
|
|
752
|
-
export const cancel = Redis.script(
|
|
1038
|
+
export const cancel = (HELPERS: string) => Redis.script(
|
|
753
1039
|
(prefix: string, id: string, now: number) => [prefix, id, now],
|
|
754
1040
|
{
|
|
755
1041
|
numberOfKeys: 0,
|
|
@@ -762,13 +1048,16 @@ if state == "active" then
|
|
|
762
1048
|
redis.call("HSET", jk, "cancelRequested", "1")
|
|
763
1049
|
return '{"ok":true}'
|
|
764
1050
|
end
|
|
765
|
-
if state ~= "waiting" and state ~= "delayed" then
|
|
1051
|
+
if state ~= "waiting" and state ~= "delayed" and state ~= "waiting-children" then
|
|
766
1052
|
return '{"error":"state","state":' .. cjson.encode(state) .. '}'
|
|
767
1053
|
end
|
|
768
1054
|
local nowStr = ARGV[3]
|
|
769
1055
|
local now = tonumber(nowStr)
|
|
770
1056
|
local queue = redis.call("HGET", jk, "queue")
|
|
771
1057
|
local name = redis.call("HGET", jk, "name")
|
|
1058
|
+
if state == "waiting-children" then
|
|
1059
|
+
settleMarkRows(id)
|
|
1060
|
+
end
|
|
772
1061
|
remWaiting(queue, id)
|
|
773
1062
|
redis.call("ZREM", delayedKey(queue), id)
|
|
774
1063
|
redis.call("HSET", jk, "state", "cancelled", "finishedAt", nowStr, "cancelRequested", "0")
|
|
@@ -777,6 +1066,7 @@ countsAdd(queue, "cancelled", 1)
|
|
|
777
1066
|
redis.call("ZADD", prefix .. ":finished:cancelled", now, id)
|
|
778
1067
|
redis.call("ZADD", terminalKey(name, "cancelled"), now, id)
|
|
779
1068
|
appendAttempt(id, "cancelled", redis.call("HGET", jk, "processedAt"), nowStr, "")
|
|
1069
|
+
appendOutbox(id, "cancelled")
|
|
780
1070
|
releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
|
|
781
1071
|
applyKeep(name, "cancelled", redis.call("HGET", jk, "keep"), now)
|
|
782
1072
|
return '{"ok":true}'
|
|
@@ -785,7 +1075,7 @@ return '{"ok":true}'
|
|
|
785
1075
|
).withReturnType<string>()
|
|
786
1076
|
|
|
787
1077
|
/** promote(prefix, id, now) — delayed -> waiting now. */
|
|
788
|
-
export const promote = Redis.script(
|
|
1078
|
+
export const promote = (HELPERS: string) => Redis.script(
|
|
789
1079
|
(prefix: string, id: string, now: number) => [prefix, id, now],
|
|
790
1080
|
{
|
|
791
1081
|
numberOfKeys: 0,
|
|
@@ -817,7 +1107,7 @@ return '{"ok":true,"queue":' .. cjson.encode(queue) .. '}'
|
|
|
817
1107
|
* digits) and empty arrays ({}). An unchanged cadence (cron/tz/everyMs)
|
|
818
1108
|
* preserves the stored nextRunAt.
|
|
819
1109
|
*/
|
|
820
|
-
export const upsertSchedule = Redis.script(
|
|
1110
|
+
export const upsertSchedule = (HELPERS: string) => Redis.script(
|
|
821
1111
|
(
|
|
822
1112
|
prefix: string,
|
|
823
1113
|
key: string,
|
|
@@ -882,7 +1172,7 @@ return '{"ok":true}'
|
|
|
882
1172
|
).withReturnType<string>()
|
|
883
1173
|
|
|
884
1174
|
/** removeSchedule(prefix, key) -> existed boolean. */
|
|
885
|
-
export const removeSchedule = Redis.script(
|
|
1175
|
+
export const removeSchedule = (HELPERS: string) => Redis.script(
|
|
886
1176
|
(prefix: string, key: string) => [prefix, key],
|
|
887
1177
|
{
|
|
888
1178
|
numberOfKeys: 0,
|
|
@@ -895,7 +1185,7 @@ return tostring(removed)
|
|
|
895
1185
|
).withReturnType<string>()
|
|
896
1186
|
|
|
897
1187
|
/** listSchedules(prefix, filtersJson) ordered by nextRunAt ascending. */
|
|
898
|
-
export const listSchedules = Redis.script(
|
|
1188
|
+
export const listSchedules = (HELPERS: string) => Redis.script(
|
|
899
1189
|
(prefix: string, filtersJson: string) => [prefix, filtersJson],
|
|
900
1190
|
{
|
|
901
1191
|
numberOfKeys: 0,
|
|
@@ -920,7 +1210,7 @@ return cjson.encode(out)
|
|
|
920
1210
|
).withReturnType<string>()
|
|
921
1211
|
|
|
922
1212
|
/** dueSchedules(prefix, now) ordered by nextRunAt ascending. */
|
|
923
|
-
export const dueSchedules = Redis.script(
|
|
1213
|
+
export const dueSchedules = (HELPERS: string) => Redis.script(
|
|
924
1214
|
(prefix: string, now: number) => [prefix, now],
|
|
925
1215
|
{
|
|
926
1216
|
numberOfKeys: 0,
|
|
@@ -937,7 +1227,7 @@ return cjson.encode(out)
|
|
|
937
1227
|
).withReturnType<string>()
|
|
938
1228
|
|
|
939
1229
|
/** advanceSchedule(prefix, key, expectedRunAt, nextRunAt) — conditional CAS. */
|
|
940
|
-
export const advanceSchedule = Redis.script(
|
|
1230
|
+
export const advanceSchedule = (HELPERS: string) => Redis.script(
|
|
941
1231
|
(prefix: string, key: string, expectedRunAt: number, nextRunAt: number) => [prefix, key, expectedRunAt, nextRunAt],
|
|
942
1232
|
{
|
|
943
1233
|
numberOfKeys: 0,
|
|
@@ -956,12 +1246,12 @@ return "1"
|
|
|
956
1246
|
/**
|
|
957
1247
|
* tickSchedule(prefix, key, expectedRunAt, nextRunAt, id, name, queue,
|
|
958
1248
|
* payloadJson, metadataJson, priority, attemptsMax, backoffJson, keepJson,
|
|
959
|
-
* timeoutMs, traceJson, delayMs, now) -> "1" fired | "0"
|
|
1249
|
+
* timeoutMs, traceJson, parentJson, delayMs, now) -> "1" fired | "0"
|
|
960
1250
|
* Atomic occurrence claim: the nextRunAt CAS and the tick job's insert run
|
|
961
1251
|
* in one script, so a stale sweeper can never re-fire a slot — even after
|
|
962
1252
|
* retention pruned the previous slot's job row.
|
|
963
1253
|
*/
|
|
964
|
-
export const tickSchedule = Redis.script(
|
|
1254
|
+
export const tickSchedule = (HELPERS: string) => Redis.script(
|
|
965
1255
|
(
|
|
966
1256
|
prefix: string,
|
|
967
1257
|
key: string,
|
|
@@ -978,6 +1268,7 @@ export const tickSchedule = Redis.script(
|
|
|
978
1268
|
keepJson: string,
|
|
979
1269
|
timeoutMs: string,
|
|
980
1270
|
traceJson: string,
|
|
1271
|
+
parentJson: string,
|
|
981
1272
|
delayMs: number,
|
|
982
1273
|
now: number
|
|
983
1274
|
) => [
|
|
@@ -996,6 +1287,7 @@ export const tickSchedule = Redis.script(
|
|
|
996
1287
|
keepJson,
|
|
997
1288
|
timeoutMs,
|
|
998
1289
|
traceJson,
|
|
1290
|
+
parentJson,
|
|
999
1291
|
delayMs,
|
|
1000
1292
|
now
|
|
1001
1293
|
],
|
|
@@ -1013,7 +1305,7 @@ local id = ARGV[5]
|
|
|
1013
1305
|
-- schedule still advances, but nothing new fires.
|
|
1014
1306
|
if redis.call("EXISTS", jobKey(id)) == 1 then return "0" end
|
|
1015
1307
|
insertJobRow(id, ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12], ARGV[13],
|
|
1016
|
-
ARGV[14], "", ARGV[15], tonumber(ARGV[
|
|
1308
|
+
ARGV[14], "", ARGV[15], ARGV[16], tonumber(ARGV[17]), tonumber(ARGV[18]), ARGV[18])
|
|
1017
1309
|
return "1"
|
|
1018
1310
|
`
|
|
1019
1311
|
}
|
|
@@ -1021,12 +1313,12 @@ return "1"
|
|
|
1021
1313
|
|
|
1022
1314
|
/**
|
|
1023
1315
|
* enqueueMany(prefix, now, count, ...items) -> JSON array of per-item results
|
|
1024
|
-
* ({id, duplicate} | {collision} | {error}). Items are
|
|
1316
|
+
* ({id, duplicate} | {collision} | {error}). Items are 14-ARGV strides:
|
|
1025
1317
|
* idMode, id, name, queue, payloadJson, metadataJson, priority, attemptsMax,
|
|
1026
|
-
* backoffJson, keepJson, timeoutMs, traceJson, delayMs. Plain
|
|
1027
|
-
* items only — the caller routes dedup items through \`enqueue\`.
|
|
1318
|
+
* backoffJson, keepJson, timeoutMs, traceJson, parentJson, delayMs. Plain
|
|
1319
|
+
* (non-dedup) items only — the caller routes dedup items through \`enqueue\`.
|
|
1028
1320
|
*/
|
|
1029
|
-
export const enqueueMany = Redis.script(
|
|
1321
|
+
export const enqueueMany = (HELPERS: string) => Redis.script(
|
|
1030
1322
|
(prefix: string, now: number, count: number, items: ReadonlyArray<string>) => [prefix, now, count, ...items],
|
|
1031
1323
|
{
|
|
1032
1324
|
numberOfKeys: 0,
|
|
@@ -1036,7 +1328,7 @@ local nowStr = ARGV[2]
|
|
|
1036
1328
|
local count = tonumber(ARGV[3])
|
|
1037
1329
|
local out = {}
|
|
1038
1330
|
for i = 0, count - 1 do
|
|
1039
|
-
local base = 3 + i *
|
|
1331
|
+
local base = 3 + i * 14
|
|
1040
1332
|
local idMode = ARGV[base + 1]
|
|
1041
1333
|
local id = ARGV[base + 2]
|
|
1042
1334
|
local result
|
|
@@ -1064,7 +1356,7 @@ for i = 0, count - 1 do
|
|
|
1064
1356
|
else
|
|
1065
1357
|
insertJobRow(id, ARGV[base + 3], ARGV[base + 4], ARGV[base + 5], ARGV[base + 6],
|
|
1066
1358
|
ARGV[base + 7], ARGV[base + 8], ARGV[base + 9], ARGV[base + 10], ARGV[base + 11],
|
|
1067
|
-
"", ARGV[base + 12], tonumber(ARGV[base +
|
|
1359
|
+
"", ARGV[base + 12], ARGV[base + 13], tonumber(ARGV[base + 14]), now, nowStr)
|
|
1068
1360
|
result = '{"id":' .. cjson.encode(id) .. ',"duplicate":false}'
|
|
1069
1361
|
end
|
|
1070
1362
|
end
|
|
@@ -1081,7 +1373,7 @@ return "[" .. table.concat(out, ",") .. "]"
|
|
|
1081
1373
|
* min(store ceiling, per-row keep.age). The caller advances the offset by
|
|
1082
1374
|
* (scanned - deleted) and stops when a page comes back short.
|
|
1083
1375
|
*/
|
|
1084
|
-
export const sweepState = Redis.script(
|
|
1376
|
+
export const sweepState = (HELPERS: string) => Redis.script(
|
|
1085
1377
|
(prefix: string, state: string, ttlMs: string, limit: number, offset: number, now: number) => [
|
|
1086
1378
|
prefix,
|
|
1087
1379
|
state,
|
|
@@ -1130,7 +1422,9 @@ for i = 1, #batch, 2 do
|
|
|
1130
1422
|
end
|
|
1131
1423
|
end
|
|
1132
1424
|
end
|
|
1133
|
-
|
|
1425
|
+
-- Parents still owing cascade cancels are exempt from automatic
|
|
1426
|
+
-- retention; they count as scanned so the offset cursor walks past them.
|
|
1427
|
+
if cutoffAge ~= nil and finishedAt <= now - cutoffAge and not owesCascades(id) then
|
|
1134
1428
|
deleteJob(id)
|
|
1135
1429
|
deleted = deleted + 1
|
|
1136
1430
|
end
|
|
@@ -1146,7 +1440,7 @@ return cjson.encode({ scanned = scanned, deleted = deleted })
|
|
|
1146
1440
|
* caller loops until 0): expired windows, then pending pointers (+inf)
|
|
1147
1441
|
* whose job is gone or terminal.
|
|
1148
1442
|
*/
|
|
1149
|
-
export const sweepDedupes = Redis.script(
|
|
1443
|
+
export const sweepDedupes = (HELPERS: string) => Redis.script(
|
|
1150
1444
|
(prefix: string, limit: number, now: number) => [prefix, limit, now],
|
|
1151
1445
|
{
|
|
1152
1446
|
numberOfKeys: 0,
|
|
@@ -1188,3 +1482,382 @@ return tostring(migrated + #expired + removedPending)
|
|
|
1188
1482
|
`
|
|
1189
1483
|
}
|
|
1190
1484
|
).withReturnType<string>()
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* fanOut(prefix, id, token, final, clearStaged, failFast, total, now, count,
|
|
1488
|
+
* ...items) — the FanOut ack, chunked like enqueueMany. Items are 5-ARGV
|
|
1489
|
+
* strides: childKey, storeKey, childJobId, name, specJson.
|
|
1490
|
+
*
|
|
1491
|
+
* Every chunk is lock-token-guarded. Non-final chunks ONLY stage dependency
|
|
1492
|
+
* rows; the final chunk stages its rows, appends the "fanned-out" ledger
|
|
1493
|
+
* entry (no attempt consumed), persists the manifest (flowFailFast,
|
|
1494
|
+
* flowPending = total, zeroed outcome counters), and transitions the parent:
|
|
1495
|
+
* pending > 0 parks it in
|
|
1496
|
+
* waiting-children (no pending zset — never claimable), pending == 0 settles
|
|
1497
|
+
* it straight to runnable collect. A parent whose manifest already landed
|
|
1498
|
+
* keeps it untouched (rows are not re-created; the transition follows the
|
|
1499
|
+
* persisted pending count), so a double fan-out cannot duplicate children.
|
|
1500
|
+
* When staging starts with no manifest, the FIRST chunk clears previously
|
|
1501
|
+
* staged rows — a crashed earlier attempt may have staged different keys. A
|
|
1502
|
+
* raced cancelRequested wins: the parent settles cancelled and its pending
|
|
1503
|
+
* rows flip to cancelled (cascade work for the flow sweeper).
|
|
1504
|
+
*/
|
|
1505
|
+
export const fanOut = (HELPERS: string) => Redis.script(
|
|
1506
|
+
(
|
|
1507
|
+
prefix: string,
|
|
1508
|
+
id: string,
|
|
1509
|
+
token: string,
|
|
1510
|
+
final: string,
|
|
1511
|
+
clearStaged: string,
|
|
1512
|
+
failFast: string,
|
|
1513
|
+
total: number,
|
|
1514
|
+
now: number,
|
|
1515
|
+
count: number,
|
|
1516
|
+
items: ReadonlyArray<string>
|
|
1517
|
+
) => [prefix, id, token, final, clearStaged, failFast, total, now, count, ...items],
|
|
1518
|
+
{
|
|
1519
|
+
numberOfKeys: 0,
|
|
1520
|
+
lua: `${HELPERS}
|
|
1521
|
+
local id = ARGV[2]
|
|
1522
|
+
local jk = jobKey(id)
|
|
1523
|
+
if redis.call("EXISTS", jk) == 0 then return '{"error":"notfound"}' end
|
|
1524
|
+
if redis.call("HGET", jk, "state") ~= "active" or redis.call("HGET", jk, "lockToken") ~= ARGV[3] then
|
|
1525
|
+
return '{"error":"locklost"}'
|
|
1526
|
+
end
|
|
1527
|
+
local final = ARGV[4] == "1"
|
|
1528
|
+
local clearStaged = ARGV[5] == "1"
|
|
1529
|
+
local failFast = ARGV[6]
|
|
1530
|
+
local total = tonumber(ARGV[7])
|
|
1531
|
+
local nowStr = ARGV[8]
|
|
1532
|
+
local now = tonumber(nowStr)
|
|
1533
|
+
local count = tonumber(ARGV[9])
|
|
1534
|
+
local pendingStr = redis.call("HGET", jk, "flowPending")
|
|
1535
|
+
local hasManifest = pendingStr ~= false and pendingStr ~= ""
|
|
1536
|
+
if not hasManifest then
|
|
1537
|
+
if clearStaged then
|
|
1538
|
+
local staged = redis.call("ZRANGE", flowIndexKey(id), 0, -1)
|
|
1539
|
+
for i = 1, #staged do
|
|
1540
|
+
redis.call("DEL", flowChildKey(id, staged[i]))
|
|
1541
|
+
redis.call("ZREM", prefix .. ":flowpending", flowMember(id, staged[i]))
|
|
1542
|
+
redis.call("ZREM", prefix .. ":flowcascade", flowMember(id, staged[i]))
|
|
1543
|
+
end
|
|
1544
|
+
redis.call("DEL", flowIndexKey(id))
|
|
1545
|
+
end
|
|
1546
|
+
for i = 0, count - 1 do
|
|
1547
|
+
local base = 9 + i * 5
|
|
1548
|
+
local childKey = ARGV[base + 1]
|
|
1549
|
+
local rk = flowChildKey(id, childKey)
|
|
1550
|
+
redis.call("DEL", rk)
|
|
1551
|
+
redis.call("HSET", rk,
|
|
1552
|
+
"childKey", childKey, "storeKey", ARGV[base + 2], "childJobId", ARGV[base + 3],
|
|
1553
|
+
"name", ARGV[base + 4], "spec", ARGV[base + 5],
|
|
1554
|
+
"status", "pending", "exit", "", "failedReason", "", "cascaded", "0",
|
|
1555
|
+
"pendingSince", nowStr)
|
|
1556
|
+
redis.call("ZADD", flowIndexKey(id), 0, childKey)
|
|
1557
|
+
redis.call("ZADD", prefix .. ":flowpending", now, flowMember(id, childKey))
|
|
1558
|
+
end
|
|
1559
|
+
end
|
|
1560
|
+
if not final then return '{"ok":true}' end
|
|
1561
|
+
local queue = redis.call("HGET", jk, "queue")
|
|
1562
|
+
local name = redis.call("HGET", jk, "name")
|
|
1563
|
+
local startedAt = redis.call("HGET", jk, "processedAt")
|
|
1564
|
+
-- A fan-out is a phase transition, not a completed run: no attemptsMade.
|
|
1565
|
+
appendAttempt(id, "fanned-out", startedAt, nowStr, "")
|
|
1566
|
+
redis.call("ZREM", prefix .. ":active", id)
|
|
1567
|
+
local pending
|
|
1568
|
+
if hasManifest then
|
|
1569
|
+
pending = tonumber(pendingStr) or 0
|
|
1570
|
+
else
|
|
1571
|
+
redis.call("HSET", jk, "flowFailFast", failFast, "flowPending", fmt(total),
|
|
1572
|
+
"flowCompleted", "0", "flowFailed", "0", "flowCancelled", "0")
|
|
1573
|
+
pending = total
|
|
1574
|
+
end
|
|
1575
|
+
if redis.call("HGET", jk, "cancelRequested") == "1" then
|
|
1576
|
+
-- A cancel raced the fan-out: cancellation wins. The rows exist and get
|
|
1577
|
+
-- marked, so the sweeper cascades (mostly no-op cancels for
|
|
1578
|
+
-- never-enqueued children).
|
|
1579
|
+
settleMarkRows(id)
|
|
1580
|
+
finishCancelled(id, queue, name, startedAt, now, nowStr)
|
|
1581
|
+
return '{"ok":true}'
|
|
1582
|
+
end
|
|
1583
|
+
if pending > 0 then
|
|
1584
|
+
redis.call("HSET", jk, "state", "waiting-children", "lockToken", "", "lockExpiresAt", "")
|
|
1585
|
+
countsAdd(queue, "active", -1)
|
|
1586
|
+
countsAdd(queue, "waiting-children", 1)
|
|
1587
|
+
return '{"ok":true}'
|
|
1588
|
+
end
|
|
1589
|
+
-- Empty (or fully recorded) manifest: settle straight to runnable collect.
|
|
1590
|
+
local seq = redis.call("INCR", prefix .. ":seq")
|
|
1591
|
+
redis.call("HSET", jk, "state", "waiting", "runAt", nowStr, "seq", fmt(seq),
|
|
1592
|
+
"lockToken", "", "lockExpiresAt", "")
|
|
1593
|
+
local priority = tonumber(redis.call("HGET", jk, "priority")) or 0
|
|
1594
|
+
addWaiting(queue, priority, seq, id)
|
|
1595
|
+
countsAdd(queue, "active", -1)
|
|
1596
|
+
countsAdd(queue, "waiting", 1)
|
|
1597
|
+
return '{"ok":true,"wake":true,"queue":' .. cjson.encode(queue) .. '}'
|
|
1598
|
+
`
|
|
1599
|
+
}
|
|
1600
|
+
).withReturnType<string>()
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* recordChildResults(prefix, now, count, ...items) -> {results, wakes}.
|
|
1604
|
+
* Items are 5-ARGV strides: flowId, childKey, outcome, exitJson,
|
|
1605
|
+
* failedReason; results are positional {applied, parentSettled}; wakes names
|
|
1606
|
+
* the queues of parents that resumed runnable. One atomic batch; reports may
|
|
1607
|
+
* span flows.
|
|
1608
|
+
*
|
|
1609
|
+
* Phase 1 applies EVERY row update — idempotent, only while the row is
|
|
1610
|
+
* still pending; an applied report moves the child from the parent's `pending`
|
|
1611
|
+
* counter to its outcome counter and marks the row cascaded (the outcome
|
|
1612
|
+
* came FROM the child's store). Phase 2 settles each touched flow at most
|
|
1613
|
+
* once: the FIRST applied failed report in batch order under fail-fast
|
|
1614
|
+
* (terminal store-side failure; remaining rows flip to cancelled; wins the
|
|
1615
|
+
* tie over pending==0 — and this settle IS a nested parent's terminal
|
|
1616
|
+
* transition, so its own report goes to the outbox here), else pending==0
|
|
1617
|
+
* resumes the parent runnable at the flow's LAST applied report's index.
|
|
1618
|
+
*/
|
|
1619
|
+
export const recordChildResults = (HELPERS: string) => Redis.script(
|
|
1620
|
+
(prefix: string, now: number, count: number, items: ReadonlyArray<string>) => [prefix, now, count, ...items],
|
|
1621
|
+
{
|
|
1622
|
+
numberOfKeys: 0,
|
|
1623
|
+
lua: `${HELPERS}
|
|
1624
|
+
local nowStr = ARGV[2]
|
|
1625
|
+
local now = tonumber(nowStr)
|
|
1626
|
+
local count = tonumber(ARGV[3])
|
|
1627
|
+
local applied = {}
|
|
1628
|
+
local settled = {}
|
|
1629
|
+
-- Phase 1: every row update lands before any settle decision, so a
|
|
1630
|
+
-- completed batch-mate keeps its real outcome even when an earlier
|
|
1631
|
+
-- batch-mate settles the flow fail-fast.
|
|
1632
|
+
local touched = {}
|
|
1633
|
+
local touchedOrder = {}
|
|
1634
|
+
for i = 1, count do
|
|
1635
|
+
local base = 3 + (i - 1) * 5
|
|
1636
|
+
local flowId = ARGV[base + 1]
|
|
1637
|
+
local childKey = ARGV[base + 2]
|
|
1638
|
+
local outcome = ARGV[base + 3]
|
|
1639
|
+
applied[i] = false
|
|
1640
|
+
local rk = flowChildKey(flowId, childKey)
|
|
1641
|
+
if redis.call("HGET", rk, "status") == "pending" then
|
|
1642
|
+
redis.call("HSET", rk, "status", outcome, "exit", ARGV[base + 4],
|
|
1643
|
+
"failedReason", ARGV[base + 5], "cascaded", "1")
|
|
1644
|
+
redis.call("ZREM", prefix .. ":flowpending", flowMember(flowId, childKey))
|
|
1645
|
+
applied[i] = true
|
|
1646
|
+
local jk = jobKey(flowId)
|
|
1647
|
+
local pendingStr = redis.call("HGET", jk, "flowPending")
|
|
1648
|
+
if pendingStr ~= false and pendingStr ~= "" then
|
|
1649
|
+
redis.call("HSET", jk, "flowPending", fmt(math.max(0, (tonumber(pendingStr) or 0) - 1)))
|
|
1650
|
+
local bucket = outcome == "completed" and "flowCompleted"
|
|
1651
|
+
or outcome == "failed" and "flowFailed" or "flowCancelled"
|
|
1652
|
+
redis.call("HINCRBY", jk, bucket, 1)
|
|
1653
|
+
end
|
|
1654
|
+
local touch = touched[flowId]
|
|
1655
|
+
if touch == nil then
|
|
1656
|
+
touch = { last = i }
|
|
1657
|
+
touched[flowId] = touch
|
|
1658
|
+
touchedOrder[#touchedOrder + 1] = flowId
|
|
1659
|
+
end
|
|
1660
|
+
touch.last = i
|
|
1661
|
+
if outcome == "failed" and touch.firstFailed == nil then
|
|
1662
|
+
touch.firstFailed = i
|
|
1663
|
+
touch.failedKey = childKey
|
|
1664
|
+
end
|
|
1665
|
+
end
|
|
1666
|
+
end
|
|
1667
|
+
-- Phase 2: at most one settle per touched flow; fail-fast wins ties.
|
|
1668
|
+
local wakes = {}
|
|
1669
|
+
for _, flowId in ipairs(touchedOrder) do
|
|
1670
|
+
local touch = touched[flowId]
|
|
1671
|
+
local jk = jobKey(flowId)
|
|
1672
|
+
local pendingStr = redis.call("HGET", jk, "flowPending")
|
|
1673
|
+
if pendingStr ~= false and pendingStr ~= ""
|
|
1674
|
+
and redis.call("HGET", jk, "state") == "waiting-children" then
|
|
1675
|
+
local queue = redis.call("HGET", jk, "queue")
|
|
1676
|
+
if redis.call("HGET", jk, "flowFailFast") == "1" and touch.firstFailed ~= nil then
|
|
1677
|
+
-- First applied failure settles the parent terminally, store-side
|
|
1678
|
+
-- (failedReason, no exit — like stall exhaustion) and marks the
|
|
1679
|
+
-- remaining rows in the same op.
|
|
1680
|
+
local name = redis.call("HGET", jk, "name")
|
|
1681
|
+
local startedAt = redis.call("HGET", jk, "processedAt")
|
|
1682
|
+
settleMarkRows(flowId)
|
|
1683
|
+
redis.call("HSET", jk, "state", "failed", "finishedAt", nowStr, "cancelRequested", "0",
|
|
1684
|
+
"failedReason", 'effect-mq: flow child "' .. touch.failedKey .. '" failed')
|
|
1685
|
+
countsAdd(queue, "waiting-children", -1)
|
|
1686
|
+
countsAdd(queue, "failed", 1)
|
|
1687
|
+
redis.call("ZADD", prefix .. ":finished:failed", now, flowId)
|
|
1688
|
+
redis.call("ZADD", terminalKey(name, "failed"), now, flowId)
|
|
1689
|
+
appendAttempt(flowId, "failed", startedAt, nowStr, "")
|
|
1690
|
+
-- A nested parent reports upward: this settle IS its terminal
|
|
1691
|
+
-- transition, with no worker ack to hook.
|
|
1692
|
+
appendOutbox(flowId, "failed")
|
|
1693
|
+
releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), flowId, now)
|
|
1694
|
+
applyKeep(name, "failed", redis.call("HGET", jk, "keep"), now)
|
|
1695
|
+
settled[touch.firstFailed] = true
|
|
1696
|
+
elseif tonumber(pendingStr) == 0 then
|
|
1697
|
+
-- All children settled: the parent resumes runnable, phase collect.
|
|
1698
|
+
local seq = redis.call("INCR", prefix .. ":seq")
|
|
1699
|
+
redis.call("HSET", jk, "state", "waiting", "runAt", nowStr, "seq", fmt(seq))
|
|
1700
|
+
local priority = tonumber(redis.call("HGET", jk, "priority")) or 0
|
|
1701
|
+
addWaiting(queue, priority, seq, flowId)
|
|
1702
|
+
countsAdd(queue, "waiting-children", -1)
|
|
1703
|
+
countsAdd(queue, "waiting", 1)
|
|
1704
|
+
wakes[#wakes + 1] = queue
|
|
1705
|
+
settled[touch.last] = true
|
|
1706
|
+
end
|
|
1707
|
+
end
|
|
1708
|
+
end
|
|
1709
|
+
local out = {}
|
|
1710
|
+
for i = 1, count do
|
|
1711
|
+
out[i] = '{"applied":' .. (applied[i] and "true" or "false")
|
|
1712
|
+
.. ',"parentSettled":' .. (settled[i] and "true" or "false") .. '}'
|
|
1713
|
+
end
|
|
1714
|
+
local wakesJson = #wakes == 0 and "[]" or cjson.encode(wakes)
|
|
1715
|
+
return '{"results":[' .. table.concat(out, ",") .. '],"wakes":' .. wakesJson .. '}'
|
|
1716
|
+
`
|
|
1717
|
+
}
|
|
1718
|
+
).withReturnType<string>()
|
|
1719
|
+
|
|
1720
|
+
/**
|
|
1721
|
+
* listChildResults(prefix, flowId, cursor, limit) — child-key order via
|
|
1722
|
+
* ZRANGEBYLEX over the per-flow index; cursor = last childKey (exclusive).
|
|
1723
|
+
* Items are positional HMGET tuples (the field list must stay in lockstep
|
|
1724
|
+
* with the driver's `toChildRecord`) — the full spec JSON stays server-side.
|
|
1725
|
+
*/
|
|
1726
|
+
export const listChildResults = (HELPERS: string) => Redis.script(
|
|
1727
|
+
(prefix: string, flowId: string, cursor: string, limit: number) => [prefix, flowId, cursor, limit],
|
|
1728
|
+
{
|
|
1729
|
+
numberOfKeys: 0,
|
|
1730
|
+
lua: `${HELPERS}
|
|
1731
|
+
local flowId = ARGV[2]
|
|
1732
|
+
local min = ARGV[3] == "" and "-" or ("(" .. ARGV[3])
|
|
1733
|
+
local limit = tonumber(ARGV[4])
|
|
1734
|
+
local keys = redis.call("ZRANGEBYLEX", flowIndexKey(flowId), min, "+", "LIMIT", 0, limit + 1)
|
|
1735
|
+
local items = {}
|
|
1736
|
+
for i = 1, math.min(#keys, limit) do
|
|
1737
|
+
items[#items + 1] = redis.call("HMGET", flowChildKey(flowId, keys[i]),
|
|
1738
|
+
"childKey", "storeKey", "childJobId", "name", "status", "exit", "failedReason", "cascaded")
|
|
1739
|
+
end
|
|
1740
|
+
if #items == 0 then return '{"items":[],"more":false}' end
|
|
1741
|
+
return cjson.encode({ items = items, more = #keys > limit })
|
|
1742
|
+
`
|
|
1743
|
+
}
|
|
1744
|
+
).withReturnType<string>()
|
|
1745
|
+
|
|
1746
|
+
/**
|
|
1747
|
+
* flowSweepWork(prefix, pendingAgeMs, limit, now) -> {reconcile, cascade}
|
|
1748
|
+
* grouped by flowId. Reconcile scans the flowpending zset (score = the row's
|
|
1749
|
+
* sweep-eligibility timestamp) and yields rows whose parent is still parked
|
|
1750
|
+
* in waiting-children. Every scanned member is re-armed or purged so no
|
|
1751
|
+
* member can pin the head of the page:
|
|
1752
|
+
*
|
|
1753
|
+
* - parent missing, or terminal with NO manifest: a crashed fan-out's
|
|
1754
|
+
* staged orphan — purge the row, its index member, and the flowpending
|
|
1755
|
+
* member (left alone their old scores would head-pin every page forever);
|
|
1756
|
+
* - parent alive but not waiting-children (mid-staging): re-arm to now so
|
|
1757
|
+
* it rotates behind fresher work;
|
|
1758
|
+
* - returned rows: re-arm to now (defer-on-return, per the contract) so a
|
|
1759
|
+
* full page rotates across sweeps;
|
|
1760
|
+
* - a non-pending row's membership is stale (rows never return to pending):
|
|
1761
|
+
* self-heal by removing the member.
|
|
1762
|
+
*
|
|
1763
|
+
* Cascade lists flowcascade members (cancels still owed to child stores).
|
|
1764
|
+
* Spec JSON strings pass through untouched — the script never cjson-decodes
|
|
1765
|
+
* stored payloads (precision, lone surrogates).
|
|
1766
|
+
*/
|
|
1767
|
+
export const flowSweepWork = (HELPERS: string) => Redis.script(
|
|
1768
|
+
(prefix: string, pendingAgeMs: number, limit: number, now: number) => [prefix, pendingAgeMs, limit, now],
|
|
1769
|
+
{
|
|
1770
|
+
numberOfKeys: 0,
|
|
1771
|
+
lua: `${HELPERS}
|
|
1772
|
+
local pendingAgeMs = tonumber(ARGV[2])
|
|
1773
|
+
local limit = tonumber(ARGV[3])
|
|
1774
|
+
local now = tonumber(ARGV[4])
|
|
1775
|
+
local reconcile, rIndex = {}, {}
|
|
1776
|
+
local due = redis.call("ZRANGEBYSCORE", prefix .. ":flowpending", "-inf", now - pendingAgeMs,
|
|
1777
|
+
"LIMIT", 0, limit)
|
|
1778
|
+
for _, member in ipairs(due) do
|
|
1779
|
+
local sep = string.find(member, "\0", 1, true)
|
|
1780
|
+
local flowId = string.sub(member, 1, sep - 1)
|
|
1781
|
+
local childKey = string.sub(member, sep + 1)
|
|
1782
|
+
local jk = jobKey(flowId)
|
|
1783
|
+
local state = redis.call("HGET", jk, "state")
|
|
1784
|
+
local pendingField = redis.call("HGET", jk, "flowPending")
|
|
1785
|
+
local hasManifest = pendingField ~= false and pendingField ~= ""
|
|
1786
|
+
local terminal = state == "completed" or state == "failed" or state == "cancelled"
|
|
1787
|
+
if state == false or (terminal and not hasManifest) then
|
|
1788
|
+
-- Staged orphan (parent gone, or went terminal before a manifest ever
|
|
1789
|
+
-- landed): purge, or its old score head-pins every future page.
|
|
1790
|
+
redis.call("DEL", flowChildKey(flowId, childKey))
|
|
1791
|
+
redis.call("ZREM", flowIndexKey(flowId), childKey)
|
|
1792
|
+
redis.call("ZREM", prefix .. ":flowpending", member)
|
|
1793
|
+
elseif state ~= "waiting-children" then
|
|
1794
|
+
-- Alive but not parked (e.g. mid-staging): not this sweep's business —
|
|
1795
|
+
-- rotate it behind fresher work.
|
|
1796
|
+
redis.call("ZADD", prefix .. ":flowpending", now, member)
|
|
1797
|
+
elseif redis.call("HGET", flowChildKey(flowId, childKey), "status") ~= "pending" then
|
|
1798
|
+
-- Stale membership (rows never return to pending): self-heal.
|
|
1799
|
+
redis.call("ZREM", prefix .. ":flowpending", member)
|
|
1800
|
+
else
|
|
1801
|
+
local rk = flowChildKey(flowId, childKey)
|
|
1802
|
+
local group = rIndex[flowId]
|
|
1803
|
+
if group == nil then
|
|
1804
|
+
group = { flowId = flowId, children = {} }
|
|
1805
|
+
rIndex[flowId] = group
|
|
1806
|
+
reconcile[#reconcile + 1] = group
|
|
1807
|
+
end
|
|
1808
|
+
group.children[#group.children + 1] = {
|
|
1809
|
+
childKey = childKey,
|
|
1810
|
+
storeKey = redis.call("HGET", rk, "storeKey"),
|
|
1811
|
+
spec = redis.call("HGET", rk, "spec")
|
|
1812
|
+
}
|
|
1813
|
+
-- Returned work defers its own re-eligibility by one age: page rotation.
|
|
1814
|
+
redis.call("ZADD", prefix .. ":flowpending", now, member)
|
|
1815
|
+
end
|
|
1816
|
+
end
|
|
1817
|
+
local cascade, cIndex = {}, {}
|
|
1818
|
+
local owed = redis.call("ZRANGE", prefix .. ":flowcascade", 0, limit - 1)
|
|
1819
|
+
for _, member in ipairs(owed) do
|
|
1820
|
+
local sep = string.find(member, "\0", 1, true)
|
|
1821
|
+
local flowId = string.sub(member, 1, sep - 1)
|
|
1822
|
+
local childKey = string.sub(member, sep + 1)
|
|
1823
|
+
local rk = flowChildKey(flowId, childKey)
|
|
1824
|
+
if redis.call("EXISTS", rk) == 1 then
|
|
1825
|
+
local group = cIndex[flowId]
|
|
1826
|
+
if group == nil then
|
|
1827
|
+
group = { flowId = flowId, children = {} }
|
|
1828
|
+
cIndex[flowId] = group
|
|
1829
|
+
cascade[#cascade + 1] = group
|
|
1830
|
+
end
|
|
1831
|
+
group.children[#group.children + 1] = {
|
|
1832
|
+
childKey = childKey,
|
|
1833
|
+
storeKey = redis.call("HGET", rk, "storeKey"),
|
|
1834
|
+
childJobId = redis.call("HGET", rk, "childJobId")
|
|
1835
|
+
}
|
|
1836
|
+
end
|
|
1837
|
+
end
|
|
1838
|
+
return cjson.encode({ reconcile = reconcile, cascade = cascade })
|
|
1839
|
+
`
|
|
1840
|
+
}
|
|
1841
|
+
).withReturnType<string>()
|
|
1842
|
+
|
|
1843
|
+
/**
|
|
1844
|
+
* markChildrenCascaded(prefix, flowId, childKeysJson) — idempotent; unknown
|
|
1845
|
+
* keys are ignored (their index members are still cleared).
|
|
1846
|
+
*/
|
|
1847
|
+
export const markChildrenCascaded = (HELPERS: string) => Redis.script(
|
|
1848
|
+
(prefix: string, flowId: string, childKeysJson: string) => [prefix, flowId, childKeysJson],
|
|
1849
|
+
{
|
|
1850
|
+
numberOfKeys: 0,
|
|
1851
|
+
lua: `${HELPERS}
|
|
1852
|
+
local flowId = ARGV[2]
|
|
1853
|
+
for _, key in ipairs(cjson.decode(ARGV[3])) do
|
|
1854
|
+
local rk = flowChildKey(flowId, key)
|
|
1855
|
+
if redis.call("EXISTS", rk) == 1 then
|
|
1856
|
+
redis.call("HSET", rk, "cascaded", "1")
|
|
1857
|
+
end
|
|
1858
|
+
redis.call("ZREM", prefix .. ":flowcascade", flowMember(flowId, key))
|
|
1859
|
+
end
|
|
1860
|
+
return '{"ok":true}'
|
|
1861
|
+
`
|
|
1862
|
+
}
|
|
1863
|
+
).withReturnType<string>()
|