effect-mq 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +305 -25
  2. package/dist/Job.d.ts +118 -5
  3. package/dist/Job.d.ts.map +1 -1
  4. package/dist/Job.js +119 -4
  5. package/dist/Job.js.map +1 -1
  6. package/dist/JobStore.d.ts +260 -9
  7. package/dist/JobStore.d.ts.map +1 -1
  8. package/dist/JobStore.js +115 -1
  9. package/dist/JobStore.js.map +1 -1
  10. package/dist/MemoryJobStore.d.ts +38 -6
  11. package/dist/MemoryJobStore.d.ts.map +1 -1
  12. package/dist/MemoryJobStore.js +351 -47
  13. package/dist/MemoryJobStore.js.map +1 -1
  14. package/dist/Worker.d.ts +5 -1
  15. package/dist/Worker.d.ts.map +1 -1
  16. package/dist/Worker.js +117 -10
  17. package/dist/Worker.js.map +1 -1
  18. package/dist/{drizzle → drizzle-postgres}/DrizzleJobStore.d.ts +35 -2
  19. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -0
  20. package/dist/drizzle-postgres/DrizzleJobStore.js +941 -0
  21. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -0
  22. package/dist/drizzle-postgres/index.d.ts.map +1 -0
  23. package/dist/drizzle-postgres/index.js.map +1 -0
  24. package/dist/drizzle-postgres/schema.d.ts +670 -0
  25. package/dist/drizzle-postgres/schema.d.ts.map +1 -0
  26. package/dist/drizzle-postgres/schema.js +150 -0
  27. package/dist/drizzle-postgres/schema.js.map +1 -0
  28. package/dist/redis/RedisJobStore.d.ts +58 -0
  29. package/dist/redis/RedisJobStore.d.ts.map +1 -0
  30. package/dist/redis/RedisJobStore.js +424 -0
  31. package/dist/redis/RedisJobStore.js.map +1 -0
  32. package/dist/redis/index.d.ts +9 -0
  33. package/dist/redis/index.d.ts.map +1 -0
  34. package/dist/redis/index.js +9 -0
  35. package/dist/redis/index.js.map +1 -0
  36. package/dist/redis/scripts.d.ts +181 -0
  37. package/dist/redis/scripts.d.ts.map +1 -0
  38. package/dist/redis/scripts.js +940 -0
  39. package/dist/redis/scripts.js.map +1 -0
  40. package/dist/testing/conformance.d.ts.map +1 -1
  41. package/dist/testing/conformance.js +502 -8
  42. package/dist/testing/conformance.js.map +1 -1
  43. package/package.json +8 -4
  44. package/src/Job.ts +301 -10
  45. package/src/JobStore.ts +373 -9
  46. package/src/MemoryJobStore.ts +440 -53
  47. package/src/Worker.ts +153 -10
  48. package/src/drizzle-postgres/DrizzleJobStore.ts +1311 -0
  49. package/src/drizzle-postgres/schema.ts +279 -0
  50. package/src/redis/RedisJobStore.ts +652 -0
  51. package/src/redis/index.ts +8 -0
  52. package/src/redis/scripts.ts +1055 -0
  53. package/src/testing/conformance.ts +665 -8
  54. package/dist/drizzle/DrizzleJobStore.d.ts.map +0 -1
  55. package/dist/drizzle/DrizzleJobStore.js +0 -426
  56. package/dist/drizzle/DrizzleJobStore.js.map +0 -1
  57. package/dist/drizzle/index.d.ts.map +0 -1
  58. package/dist/drizzle/index.js.map +0 -1
  59. package/dist/drizzle/schema.d.ts +0 -464
  60. package/dist/drizzle/schema.d.ts.map +0 -1
  61. package/dist/drizzle/schema.js +0 -68
  62. package/dist/drizzle/schema.js.map +0 -1
  63. package/src/drizzle/DrizzleJobStore.ts +0 -599
  64. package/src/drizzle/schema.ts +0 -116
  65. /package/dist/{drizzle → drizzle-postgres}/index.d.ts +0 -0
  66. /package/dist/{drizzle → drizzle-postgres}/index.js +0 -0
  67. /package/src/{drizzle → drizzle-postgres}/index.ts +0 -0
@@ -0,0 +1,940 @@
1
+ /**
2
+ * Lua scripts backing `RedisJobStore`.
3
+ *
4
+ * Every mutation is one script, so every `JobStore` method is atomic on the
5
+ * server. Two disciplines apply throughout:
6
+ *
7
+ * - **All time comes in via ARGV** (from the Effect `Clock`) — never Redis
8
+ * `TIME` — so the conformance suite drives this driver under `TestClock`
9
+ * against a real server.
10
+ * - **Numbers that become strings are formatted with `%.0f`** — Lua 5.1's
11
+ * `tostring` renders large integers as `1.7e+12`, which would corrupt ids
12
+ * and stored timestamps.
13
+ *
14
+ * Key layout (all under a configurable prefix, non-cluster):
15
+ *
16
+ * - `p:seq` counter for seq + default job ids
17
+ * - `p:job:<id>` HASH of the job record
18
+ * - `p:attempts:<id>` LIST of JSON attempt-ledger entries
19
+ * - `p:waiting:<queue>` ZSET, score `-priority`, member `<seq %016d>:<id>`
20
+ * (score = priority desc; member lex = FIFO)
21
+ * - `p:delayed:<queue>` ZSET, score `runAt`
22
+ * - `p:active` ZSET, score `lockExpiresAt`
23
+ * - `p:all` ZSET, score `enqueuedAt` (list pagination)
24
+ * - `p:finished:<state>` ZSET, score `finishedAt` (history TTL)
25
+ * - `p:terminal:<name>:<state>` ZSET, score `finishedAt` (keep pruning)
26
+ * - `p:counts` HASH `<queue>|<state>` -> integer
27
+ * - `p:paused` SET of paused queues
28
+ * - `p:schedules` / `p:schedule:<key>` ZSET by nextRunAt + HASH per record
29
+ * - `p:dedupe:<name>\0<key>` HASH {jobId, expiresAt} + `p:dedupes` index
30
+ * ZSET (score = window expiry, +inf = pending)
31
+ *
32
+ * @since 0.2.0
33
+ */
34
+ import { Redis } from "effect/unstable/persistence";
35
+ /**
36
+ * Shared helpers textually prepended to every script (the `Redis.script`
37
+ * runner has no include mechanism). `ARGV[1]` is always the key prefix.
38
+ */
39
+ const HELPERS = `
40
+ local prefix = ARGV[1]
41
+ local function fmt(x) return string.format("%.0f", x) end
42
+ local function jobKey(id) return prefix .. ":job:" .. id end
43
+ local function attemptsKey(id) return prefix .. ":attempts:" .. id end
44
+ local function waitingKey(queue) return prefix .. ":waiting:" .. queue end
45
+ local function delayedKey(queue) return prefix .. ":delayed:" .. queue end
46
+ local function terminalKey(name, state) return prefix .. ":terminal:" .. name .. ":" .. state end
47
+ -- Waiting order: score = -priority (higher priority first, full number range);
48
+ -- FIFO within a priority via lexicographic members "<seq %016d>:<id>". A
49
+ -- composite numeric score would clip either priority or seq past float53.
50
+ local function waitingMember(seq, id) return string.format("%016.0f", seq) .. ":" .. id end
51
+ local function waitingId(member) return string.sub(member, 18) end
52
+ local function addWaiting(queue, priority, seq, id)
53
+ redis.call("ZADD", waitingKey(queue), -priority, waitingMember(seq, id))
54
+ end
55
+ local function remWaiting(queue, id)
56
+ local seq = tonumber(redis.call("HGET", jobKey(id), "seq")) or 0
57
+ redis.call("ZREM", waitingKey(queue), waitingMember(seq, id))
58
+ end
59
+ local function countsAdd(queue, state, n)
60
+ redis.call("HINCRBY", prefix .. ":counts", queue .. "|" .. state, n)
61
+ end
62
+ -- One ledger entry. startedAt/finishedAt/exitJson are strings ("" = absent).
63
+ local function appendAttempt(id, outcome, startedAt, finishedAt, exitJson)
64
+ local n = redis.call("LLEN", attemptsKey(id)) + 1
65
+ local started = (startedAt == nil or startedAt == "") and "null" or startedAt
66
+ -- The exit key is OMITTED (not null) when absent, so a legitimate encoded
67
+ -- null exit stays distinguishable on the read side.
68
+ local ex = (exitJson == nil or exitJson == "") and "" or (',"exit":' .. exitJson)
69
+ redis.call("RPUSH", attemptsKey(id),
70
+ '{"attempt":' .. n .. ',"startedAt":' .. started .. ',"finishedAt":' .. finishedAt ..
71
+ ',"outcome":"' .. outcome .. '"' .. ex .. '}')
72
+ end
73
+ -- Remove a job and every index entry that references it.
74
+ local function deleteJob(id)
75
+ local jk = jobKey(id)
76
+ local queue = redis.call("HGET", jk, "queue")
77
+ if not queue then return end
78
+ local state = redis.call("HGET", jk, "state")
79
+ local name = redis.call("HGET", jk, "name")
80
+ countsAdd(queue, state, -1)
81
+ redis.call("ZREM", prefix .. ":all", id)
82
+ redis.call("ZREM", prefix .. ":finished:" .. state, id)
83
+ redis.call("ZREM", prefix .. ":active", id)
84
+ redis.call("ZREM", terminalKey(name, state), id)
85
+ remWaiting(queue, id)
86
+ redis.call("ZREM", delayedKey(queue), id)
87
+ redis.call("DEL", jk, attemptsKey(id))
88
+ end
89
+ -- Terminal retention for one name+state group. Correctness over speed: the
90
+ -- count rule sorts the group by (finishedAt DESC, seq DESC) in Lua because a
91
+ -- zset score alone cannot carry the seq tie-break exactly.
92
+ local function applyKeep(name, state, keepJson, now)
93
+ if keepJson == nil or keepJson == "" then return end
94
+ local ok, decoded = pcall(cjson.decode, keepJson)
95
+ if not ok or type(decoded) ~= "table" then return end
96
+ -- Policies are split per terminal state; rows persisted by 0.2.x carry the
97
+ -- flat {count, ageMs} shape and apply to every state.
98
+ local keep = decoded[state]
99
+ if type(keep) ~= "table" then
100
+ if decoded.completed == nil and decoded.failed == nil and decoded.cancelled == nil
101
+ and (decoded.count ~= nil or decoded.ageMs ~= nil)
102
+ then
103
+ keep = decoded
104
+ else
105
+ return
106
+ end
107
+ end
108
+ local tkey = terminalKey(name, state)
109
+ if keep.ageMs ~= nil then
110
+ local old = redis.call("ZRANGEBYSCORE", tkey, "-inf", now - keep.ageMs)
111
+ for i = 1, #old do deleteJob(old[i]) end
112
+ end
113
+ -- Floor + clamp: a fractional/negative count must degrade like the memory
114
+ -- driver's slice(), never error mid-script (writes before an error stick).
115
+ local count = keep.count ~= nil and math.floor(math.max(0, tonumber(keep.count) or 0)) or nil
116
+ if count ~= nil and redis.call("ZCARD", tkey) > count then
117
+ local members = redis.call("ZRANGE", tkey, 0, -1, "WITHSCORES")
118
+ local arr = {}
119
+ for i = 1, #members, 2 do
120
+ arr[#arr + 1] = {
121
+ id = members[i],
122
+ fa = tonumber(members[i + 1]),
123
+ seq = tonumber(redis.call("HGET", jobKey(members[i]), "seq")) or 0
124
+ }
125
+ end
126
+ table.sort(arr, function(a, b)
127
+ if a.fa ~= b.fa then return a.fa > b.fa end
128
+ return a.seq > b.seq
129
+ end)
130
+ for i = count + 1, #arr do deleteJob(arr[i].id) end
131
+ end
132
+ end
133
+ local function dedupeStoreKey(name, key) return prefix .. ":dedupe:" .. name .. "\0" .. key end
134
+ -- A job leaving the pending states frees its pending-mode dedup entry; live
135
+ -- throttle windows deliberately outlast the job.
136
+ local function releaseDedupe(name, dkey, jobId, now)
137
+ if dkey == nil or dkey == false or dkey == "" then return end
138
+ local sk = dedupeStoreKey(name, dkey)
139
+ if redis.call("HGET", sk, "jobId") == jobId then
140
+ local exp = redis.call("HGET", sk, "expiresAt")
141
+ if exp == "" or tonumber(exp) <= now then
142
+ redis.call("DEL", sk)
143
+ redis.call("ZREM", prefix .. ":dedupes", name .. "\0" .. dkey)
144
+ end
145
+ end
146
+ end
147
+ -- Move an active job (whose lock bookkeeping was already cleared by the
148
+ -- caller) to the terminal cancelled state.
149
+ local function finishCancelled(id, queue, name, startedAt, now, nowStr)
150
+ local jk = jobKey(id)
151
+ redis.call("HSET", jk, "state", "cancelled", "finishedAt", nowStr, "cancelRequested", "0",
152
+ "lockToken", "", "lockExpiresAt", "")
153
+ countsAdd(queue, "active", -1)
154
+ countsAdd(queue, "cancelled", 1)
155
+ redis.call("ZADD", prefix .. ":finished:cancelled", now, id)
156
+ redis.call("ZADD", terminalKey(name, "cancelled"), now, id)
157
+ appendAttempt(id, "cancelled", startedAt, nowStr, "")
158
+ releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
159
+ applyKeep(name, "cancelled", redis.call("HGET", jk, "keep"), now)
160
+ end
161
+ `;
162
+ /**
163
+ * enqueue(prefix, idMode, id, name, queue, payloadJson, metadataJson,
164
+ * priority, attemptsMax, backoffJson, keepJson, timeoutMs, delayMs, now)
165
+ * idMode: "user" (dedup no-op), "generated" (collision -> retry sentinel),
166
+ * "auto" (j-<seq>, in-script collision loop).
167
+ */
168
+ export const enqueue = Redis.script((prefix, idMode, id, name, queue, payloadJson, metadataJson, priority, attemptsMax, backoffJson, keepJson, timeoutMs, delayMs, now, dedupeKey, dedupeTtlMs, dedupeExtend, dedupeReplace) => [
169
+ prefix,
170
+ idMode,
171
+ id,
172
+ name,
173
+ queue,
174
+ payloadJson,
175
+ metadataJson,
176
+ priority,
177
+ attemptsMax,
178
+ backoffJson,
179
+ keepJson,
180
+ timeoutMs,
181
+ delayMs,
182
+ now,
183
+ dedupeKey,
184
+ dedupeTtlMs,
185
+ dedupeExtend,
186
+ dedupeReplace
187
+ ], {
188
+ numberOfKeys: 0,
189
+ lua: `${HELPERS}
190
+ local idMode, id = ARGV[2], ARGV[3]
191
+ local queue = ARGV[5]
192
+ local nowStr = ARGV[14]
193
+ local now = tonumber(nowStr)
194
+ local delayMs = tonumber(ARGV[13])
195
+ if idMode == "user" or idMode == "generated" then
196
+ if redis.call("EXISTS", jobKey(id)) == 1 then
197
+ if idMode == "user" then return '{"duplicate":true,"id":' .. cjson.encode(id) .. '}' end
198
+ return '{"collision":true}'
199
+ end
200
+ end
201
+ -- Dedup decision tree: replace-while-delayed, throttle window, pending dedup.
202
+ local dKey = ARGV[15]
203
+ local name = ARGV[4]
204
+ if dKey ~= "" then
205
+ local sk = dedupeStoreKey(name, dKey)
206
+ local entryJob = redis.call("HGET", sk, "jobId")
207
+ if entryJob then
208
+ local expStr = redis.call("HGET", sk, "expiresAt")
209
+ local windowLive = expStr ~= "" and tonumber(expStr) > now
210
+ local keyedState = redis.call("HGET", jobKey(entryJob), "state")
211
+ local function bumpWindow()
212
+ if ARGV[17] == "1" and ARGV[16] ~= "" then
213
+ local windowEnd = now + tonumber(ARGV[16])
214
+ redis.call("HSET", sk, "expiresAt", fmt(windowEnd))
215
+ redis.call("ZADD", prefix .. ":dedupes", windowEnd, name .. "\0" .. dKey)
216
+ end
217
+ end
218
+ -- Latest-wins while the keyed job is still delayed.
219
+ if ARGV[18] == "1" and keyedState == "delayed" then
220
+ local kjk = jobKey(entryJob)
221
+ local newRunAt = now + delayMs
222
+ redis.call("HSET", kjk, "payload", ARGV[6], "metadata", ARGV[7], "priority", ARGV[8],
223
+ "attemptsMax", ARGV[9], "backoff", ARGV[10], "keep", ARGV[11], "timeoutMs", ARGV[12],
224
+ "runAt", fmt(newRunAt))
225
+ local keyedQueue = redis.call("HGET", kjk, "queue")
226
+ redis.call("ZADD", delayedKey(keyedQueue), newRunAt, entryJob)
227
+ -- A landed replace re-arms the ttl window.
228
+ if ARGV[16] ~= "" then
229
+ local windowEnd = now + tonumber(ARGV[16])
230
+ redis.call("HSET", sk, "expiresAt", fmt(windowEnd))
231
+ redis.call("ZADD", prefix .. ":dedupes", windowEnd, name .. "\0" .. dKey)
232
+ end
233
+ return '{"id":' .. cjson.encode(entryJob) .. ',"duplicate":true,"wake":true,"queue":' .. cjson.encode(keyedQueue) .. '}'
234
+ end
235
+ if windowLive then
236
+ bumpWindow()
237
+ return '{"id":' .. cjson.encode(entryJob) .. ',"duplicate":true}'
238
+ end
239
+ local pending = keyedState ~= false and keyedState ~= "completed"
240
+ and keyedState ~= "failed" and keyedState ~= "cancelled"
241
+ if expStr == "" and pending then
242
+ return '{"id":' .. cjson.encode(entryJob) .. ',"duplicate":true}'
243
+ end
244
+ -- Dead entry: the new job takes over the key below.
245
+ end
246
+ end
247
+ if idMode == "auto" then
248
+ id = ""
249
+ for i = 1, 5 do
250
+ local candidate = "j-" .. fmt(redis.call("INCR", prefix .. ":seq"))
251
+ if redis.call("EXISTS", jobKey(candidate)) == 0 then
252
+ id = candidate
253
+ break
254
+ end
255
+ end
256
+ if id == "" then return '{"error":"id"}' end
257
+ end
258
+ local seq = redis.call("INCR", prefix .. ":seq")
259
+ local state = delayMs > 0 and "delayed" or "waiting"
260
+ local runAt = now + delayMs
261
+ redis.call("HSET", jobKey(id),
262
+ "id", id, "name", ARGV[4], "queue", queue,
263
+ "payload", ARGV[6], "metadata", ARGV[7], "state", state,
264
+ "priority", ARGV[8], "attemptsMax", ARGV[9], "attemptsMade", "0", "stalledCount", "0",
265
+ "backoff", ARGV[10], "keep", ARGV[11], "timeoutMs", ARGV[12],
266
+ "cancelRequested", "0", "dedupeKey", dKey, "runAt", fmt(runAt), "enqueuedAt", nowStr,
267
+ "processedAt", "", "finishedAt", "", "exit", "", "failedReason", "",
268
+ "lockToken", "", "lockExpiresAt", "", "seq", fmt(seq))
269
+ redis.call("ZADD", prefix .. ":all", now, id)
270
+ if state == "waiting" then
271
+ addWaiting(queue, tonumber(ARGV[8]), seq, id)
272
+ else
273
+ redis.call("ZADD", delayedKey(queue), runAt, id)
274
+ end
275
+ countsAdd(queue, state, 1)
276
+ if dKey ~= "" then
277
+ local sk = dedupeStoreKey(name, dKey)
278
+ redis.call("DEL", sk)
279
+ redis.call("HSET", sk, "jobId", id,
280
+ "expiresAt", ARGV[16] == "" and "" or fmt(now + tonumber(ARGV[16])))
281
+ redis.call("ZADD", prefix .. ":dedupes",
282
+ ARGV[16] == "" and "inf" or tostring(now + tonumber(ARGV[16])), name .. "\0" .. dKey)
283
+ end
284
+ -- Wake on EVERY insert (delayed too): idle workers must re-claim to learn
285
+ -- the new nextRunAt, exactly like the memory and Postgres drivers.
286
+ return '{"id":' .. cjson.encode(id) .. ',"duplicate":false,"wake":true}'
287
+ `
288
+ }).withReturnType();
289
+ /**
290
+ * claim(prefix, queue, namesJson, token, lockDurationMs, now)
291
+ * Promotes due delayed jobs first (also while paused), then claims the best
292
+ * waiting job whose name matches. Returns the claimed record (HGETALL pairs)
293
+ * or an Empty result with the earliest matching delayed runAt.
294
+ */
295
+ export const claim = Redis.script((prefix, queue, namesJson, token, lockDurationMs, now) => [
296
+ prefix,
297
+ queue,
298
+ namesJson,
299
+ token,
300
+ lockDurationMs,
301
+ now
302
+ ], {
303
+ numberOfKeys: 0,
304
+ lua: `${HELPERS}
305
+ local queue = ARGV[2]
306
+ local nameSet = {}
307
+ for _, n in ipairs(cjson.decode(ARGV[3])) do nameSet[n] = true end
308
+ local token = ARGV[4]
309
+ local lockDurationMs = tonumber(ARGV[5])
310
+ local nowStr = ARGV[6]
311
+ local now = tonumber(nowStr)
312
+
313
+ -- Promote due delayed jobs (state change is visible even while paused).
314
+ local due = redis.call("ZRANGEBYSCORE", delayedKey(queue), "-inf", now)
315
+ for i = 1, #due do
316
+ local id = due[i]
317
+ local jk = jobKey(id)
318
+ redis.call("ZREM", delayedKey(queue), id)
319
+ local priority = tonumber(redis.call("HGET", jk, "priority")) or 0
320
+ local seq = tonumber(redis.call("HGET", jk, "seq")) or 0
321
+ redis.call("HSET", jk, "state", "waiting")
322
+ addWaiting(queue, priority, seq, id)
323
+ countsAdd(queue, "delayed", -1)
324
+ countsAdd(queue, "waiting", 1)
325
+ end
326
+
327
+ -- Earliest matching delayed job: how long an idle worker may sleep.
328
+ local nextRunAt = nil
329
+ local delayed = redis.call("ZRANGE", delayedKey(queue), 0, -1, "WITHSCORES")
330
+ for i = 1, #delayed, 2 do
331
+ if nameSet[redis.call("HGET", jobKey(delayed[i]), "name")] then
332
+ nextRunAt = tonumber(delayed[i + 1])
333
+ break
334
+ end
335
+ end
336
+
337
+ if redis.call("SISMEMBER", prefix .. ":paused", queue) == 1 then
338
+ return cjson.encode({ empty = true, nextRunAt = nextRunAt })
339
+ end
340
+
341
+ local offset = 0
342
+ while true do
343
+ local batch = redis.call("ZRANGE", waitingKey(queue), offset, offset + 99)
344
+ if #batch == 0 then break end
345
+ for i = 1, #batch do
346
+ local id = waitingId(batch[i])
347
+ local jk = jobKey(id)
348
+ if nameSet[redis.call("HGET", jk, "name")] then
349
+ redis.call("ZREM", waitingKey(queue), batch[i])
350
+ redis.call("HSET", jk, "state", "active", "lockToken", token,
351
+ "lockExpiresAt", fmt(now + lockDurationMs), "processedAt", nowStr)
352
+ redis.call("ZADD", prefix .. ":active", now + lockDurationMs, id)
353
+ countsAdd(queue, "waiting", -1)
354
+ countsAdd(queue, "active", 1)
355
+ return cjson.encode({ job = redis.call("HGETALL", jk) })
356
+ end
357
+ end
358
+ offset = offset + 100
359
+ end
360
+ return cjson.encode({ empty = true, nextRunAt = nextRunAt })
361
+ `
362
+ }).withReturnType();
363
+ /**
364
+ * ack(prefix, id, token, outcomeTag, exitJson, delayMs, now)
365
+ * Token-guarded. Retry on a cancel-requested job finishes it as cancelled
366
+ * (cancellation wins over revival, mirroring release/recoverStalled).
367
+ */
368
+ export const ack = Redis.script((prefix, id, token, outcomeTag, exitJson, delayMs, now) => [
369
+ prefix,
370
+ id,
371
+ token,
372
+ outcomeTag,
373
+ exitJson,
374
+ delayMs,
375
+ now
376
+ ], {
377
+ numberOfKeys: 0,
378
+ lua: `${HELPERS}
379
+ local id = ARGV[2]
380
+ local jk = jobKey(id)
381
+ if redis.call("EXISTS", jk) == 0 then return '{"error":"notfound"}' end
382
+ if redis.call("HGET", jk, "state") ~= "active" or redis.call("HGET", jk, "lockToken") ~= ARGV[3] then
383
+ return '{"error":"locklost"}'
384
+ end
385
+ local tag = ARGV[4]
386
+ local exitJson = ARGV[5]
387
+ local delayMs = tonumber(ARGV[6])
388
+ local nowStr = ARGV[7]
389
+ local now = tonumber(nowStr)
390
+ local queue = redis.call("HGET", jk, "queue")
391
+ local name = redis.call("HGET", jk, "name")
392
+ local startedAt = redis.call("HGET", jk, "processedAt")
393
+ local cancelRequested = redis.call("HGET", jk, "cancelRequested") == "1"
394
+
395
+ redis.call("HINCRBY", jk, "attemptsMade", 1)
396
+ redis.call("ZREM", prefix .. ":active", id)
397
+
398
+ local function finish(newState, storeExit, outcome, ledgerExit)
399
+ redis.call("HSET", jk, "state", newState, "finishedAt", nowStr, "cancelRequested", "0",
400
+ "lockToken", "", "lockExpiresAt", "")
401
+ if storeExit ~= nil then redis.call("HSET", jk, "exit", storeExit) end
402
+ countsAdd(queue, "active", -1)
403
+ countsAdd(queue, newState, 1)
404
+ redis.call("ZADD", prefix .. ":finished:" .. newState, now, id)
405
+ redis.call("ZADD", terminalKey(name, newState), now, id)
406
+ appendAttempt(id, outcome, startedAt, nowStr, ledgerExit)
407
+ releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
408
+ applyKeep(name, newState, redis.call("HGET", jk, "keep"), now)
409
+ end
410
+
411
+ if tag == "Complete" then
412
+ finish("completed", exitJson, "completed", exitJson)
413
+ elseif tag == "Fail" then
414
+ finish("failed", exitJson, "failed", exitJson)
415
+ elseif tag == "Cancelled" then
416
+ finish("cancelled", nil, "cancelled", "")
417
+ elseif cancelRequested then
418
+ finish("cancelled", nil, "cancelled", "")
419
+ else
420
+ appendAttempt(id, "retried", startedAt, nowStr, exitJson)
421
+ local seq = redis.call("INCR", prefix .. ":seq")
422
+ local runAt = now + delayMs
423
+ local state = delayMs > 0 and "delayed" or "waiting"
424
+ redis.call("HSET", jk, "state", state, "seq", fmt(seq), "runAt", fmt(runAt),
425
+ "lockToken", "", "lockExpiresAt", "")
426
+ countsAdd(queue, "active", -1)
427
+ countsAdd(queue, state, 1)
428
+ if state == "waiting" then
429
+ local priority = tonumber(redis.call("HGET", jk, "priority")) or 0
430
+ addWaiting(queue, priority, seq, id)
431
+ else
432
+ redis.call("ZADD", delayedKey(queue), runAt, id)
433
+ end
434
+ return '{"ok":true,"wake":true,"queue":' .. cjson.encode(queue) .. '}'
435
+ end
436
+ return '{"ok":true}'
437
+ `
438
+ }).withReturnType();
439
+ /**
440
+ * release(prefix, id, token, now) — hand the job back without consuming an
441
+ * attempt; a pending cancel wins and finishes the job instead.
442
+ */
443
+ export const release = Redis.script((prefix, id, token, now) => [prefix, id, token, now], {
444
+ numberOfKeys: 0,
445
+ lua: `${HELPERS}
446
+ local id = ARGV[2]
447
+ local jk = jobKey(id)
448
+ if redis.call("EXISTS", jk) == 0 then return '{"error":"notfound"}' end
449
+ if redis.call("HGET", jk, "state") ~= "active" or redis.call("HGET", jk, "lockToken") ~= ARGV[3] then
450
+ return '{"error":"locklost"}'
451
+ end
452
+ local nowStr = ARGV[4]
453
+ local now = tonumber(nowStr)
454
+ local queue = redis.call("HGET", jk, "queue")
455
+ redis.call("ZREM", prefix .. ":active", id)
456
+ if redis.call("HGET", jk, "cancelRequested") == "1" then
457
+ finishCancelled(id, queue, redis.call("HGET", jk, "name"), redis.call("HGET", jk, "processedAt"), now, nowStr)
458
+ return '{"ok":true}'
459
+ end
460
+ local seq = tonumber(redis.call("HGET", jk, "seq")) or 0
461
+ local priority = tonumber(redis.call("HGET", jk, "priority")) or 0
462
+ redis.call("HSET", jk, "state", "waiting", "lockToken", "", "lockExpiresAt", "")
463
+ addWaiting(queue, priority, seq, id)
464
+ countsAdd(queue, "active", -1)
465
+ countsAdd(queue, "waiting", 1)
466
+ return '{"ok":true,"wake":true,"queue":' .. cjson.encode(queue) .. '}'
467
+ `
468
+ }).withReturnType();
469
+ /**
470
+ * extendLocks(prefix, locksJson, durationMs, now) -> { lost, cancel }
471
+ * Cancel-requested locks are reported, not extended.
472
+ */
473
+ export const extendLocks = Redis.script((prefix, locksJson, durationMs, now) => [prefix, locksJson, durationMs, now], {
474
+ numberOfKeys: 0,
475
+ lua: `${HELPERS}
476
+ local locks = cjson.decode(ARGV[2])
477
+ local durationMs = tonumber(ARGV[3])
478
+ local now = tonumber(ARGV[4])
479
+ local lost, cancel = {}, {}
480
+ for _, lock in ipairs(locks) do
481
+ local jk = jobKey(lock.id)
482
+ if redis.call("HGET", jk, "state") ~= "active" or redis.call("HGET", jk, "lockToken") ~= lock.token then
483
+ lost[#lost + 1] = lock.id
484
+ elseif redis.call("HGET", jk, "cancelRequested") == "1" then
485
+ cancel[#cancel + 1] = lock.id
486
+ else
487
+ redis.call("HSET", jk, "lockExpiresAt", fmt(now + durationMs))
488
+ redis.call("ZADD", prefix .. ":active", now + durationMs, lock.id)
489
+ end
490
+ end
491
+ return cjson.encode({ lost = lost, cancel = cancel })
492
+ `
493
+ }).withReturnType();
494
+ /**
495
+ * recoverStalled(prefix, maxStalledCount, now) -> recovered [{id, failed}]
496
+ * A pending cancel finishes the job as cancelled (not reported as recovered).
497
+ */
498
+ export const recoverStalled = Redis.script((prefix, maxStalledCount, now) => [prefix, maxStalledCount, now], {
499
+ numberOfKeys: 0,
500
+ lua: `${HELPERS}
501
+ local maxStalledCount = tonumber(ARGV[2])
502
+ local nowStr = ARGV[3]
503
+ local now = tonumber(nowStr)
504
+ local expired = redis.call("ZRANGEBYSCORE", prefix .. ":active", "-inf", now)
505
+ local recovered = {}
506
+ for _, id in ipairs(expired) do
507
+ local jk = jobKey(id)
508
+ if redis.call("HGET", jk, "state") == "active" then
509
+ local queue = redis.call("HGET", jk, "queue")
510
+ local name = redis.call("HGET", jk, "name")
511
+ local startedAt = redis.call("HGET", jk, "processedAt")
512
+ redis.call("ZREM", prefix .. ":active", id)
513
+ if redis.call("HGET", jk, "cancelRequested") == "1" then
514
+ finishCancelled(id, queue, name, startedAt, now, nowStr)
515
+ else
516
+ local stalled = (tonumber(redis.call("HGET", jk, "stalledCount")) or 0) + 1
517
+ redis.call("HSET", jk, "stalledCount", fmt(stalled), "lockToken", "", "lockExpiresAt", "")
518
+ appendAttempt(id, "stalled", startedAt, nowStr, "")
519
+ if stalled > maxStalledCount then
520
+ redis.call("HSET", jk, "state", "failed", "finishedAt", nowStr,
521
+ "failedReason", "job stalled more than allowable limit")
522
+ countsAdd(queue, "active", -1)
523
+ countsAdd(queue, "failed", 1)
524
+ redis.call("ZADD", prefix .. ":finished:failed", now, id)
525
+ redis.call("ZADD", terminalKey(name, "failed"), now, id)
526
+ releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
527
+ recovered[#recovered + 1] = { id = id, failed = true }
528
+ else
529
+ local seq = tonumber(redis.call("HGET", jk, "seq")) or 0
530
+ local priority = tonumber(redis.call("HGET", jk, "priority")) or 0
531
+ redis.call("HSET", jk, "state", "waiting")
532
+ addWaiting(queue, priority, seq, id)
533
+ countsAdd(queue, "active", -1)
534
+ countsAdd(queue, "waiting", 1)
535
+ recovered[#recovered + 1] = { id = id, failed = false }
536
+ end
537
+ end
538
+ end
539
+ end
540
+ if #recovered == 0 then return "[]" end
541
+ return cjson.encode(recovered)
542
+ `
543
+ }).withReturnType();
544
+ /** getJob(prefix, id) -> HGETALL pairs (empty array when missing). */
545
+ export const getJob = Redis.script((prefix, id) => [prefix, id], {
546
+ numberOfKeys: 0,
547
+ lua: `${HELPERS}
548
+ local record = redis.call("HGETALL", jobKey(ARGV[2]))
549
+ if #record == 0 then return "[]" end
550
+ return cjson.encode(record)
551
+ `
552
+ }).withReturnType();
553
+ /**
554
+ * list(prefix, filtersJson, cursor, limit)
555
+ * Keyset pagination over p:all, newest first (enqueuedAt DESC, id DESC).
556
+ */
557
+ export const list = Redis.script((prefix, filtersJson, cursor, limit) => [prefix, filtersJson, cursor, limit], {
558
+ numberOfKeys: 0,
559
+ lua: `${HELPERS}
560
+ -- A filter that Redis's cjson cannot decode (e.g. lone-surrogate escapes)
561
+ -- degrades to an empty page instead of a script error.
562
+ local okFilters, filters = pcall(cjson.decode, ARGV[2])
563
+ if not okFilters then return '{"items":[],"more":false}' end
564
+ local stateSet = nil
565
+ if filters.states ~= nil then
566
+ stateSet = {}
567
+ for _, s in ipairs(filters.states) do stateSet[s] = true end
568
+ end
569
+ local cursorAt, cursorId = nil, nil
570
+ if ARGV[3] ~= "" then
571
+ local split = string.find(ARGV[3], ":", 1, true)
572
+ if split ~= nil then
573
+ cursorAt = tonumber(string.sub(ARGV[3], 1, split - 1))
574
+ cursorId = string.sub(ARGV[3], split + 1)
575
+ end
576
+ if cursorAt == nil then cursorId = nil end
577
+ end
578
+ local limit = tonumber(ARGV[4])
579
+ local items = {}
580
+ local moreMatches = false
581
+ local max = cursorAt == nil and "+inf" or fmt(cursorAt)
582
+ local offset = 0
583
+ while true do
584
+ local batch = redis.call("ZREVRANGEBYSCORE", prefix .. ":all", max, "-inf", "WITHSCORES", "LIMIT", offset, 100)
585
+ if #batch == 0 then break end
586
+ for i = 1, #batch, 2 do
587
+ local id = batch[i]
588
+ local at = tonumber(batch[i + 1])
589
+ -- Skip up to and including the cursor position within its score.
590
+ if cursorAt == nil or at < cursorAt or (at == cursorAt and id < cursorId) then
591
+ local jk = jobKey(id)
592
+ local matches = true
593
+ if filters.queue ~= nil and redis.call("HGET", jk, "queue") ~= filters.queue then matches = false end
594
+ if matches and filters.name ~= nil and redis.call("HGET", jk, "name") ~= filters.name then matches = false end
595
+ if matches and stateSet ~= nil and not stateSet[redis.call("HGET", jk, "state")] then matches = false end
596
+ if matches and filters.metadata ~= nil then
597
+ local okMeta, meta = pcall(cjson.decode, redis.call("HGET", jk, "metadata"))
598
+ if not okMeta then
599
+ matches = false
600
+ else
601
+ for k, v in pairs(filters.metadata) do
602
+ if meta[k] ~= v then
603
+ matches = false
604
+ break
605
+ end
606
+ end
607
+ end
608
+ end
609
+ if matches then
610
+ if #items >= limit then
611
+ moreMatches = true
612
+ break
613
+ end
614
+ items[#items + 1] = redis.call("HGETALL", jk)
615
+ end
616
+ end
617
+ end
618
+ if moreMatches then break end
619
+ offset = offset + 100
620
+ end
621
+ if #items == 0 then return '{"items":[],"more":false}' end
622
+ return cjson.encode({ items = items, more = moreMatches })
623
+ `
624
+ }).withReturnType();
625
+ /** counts(prefix) -> HGETALL pairs of p:counts. */
626
+ export const counts = Redis.script((prefix) => [prefix], {
627
+ numberOfKeys: 0,
628
+ lua: `${HELPERS}
629
+ local pairs_ = redis.call("HGETALL", prefix .. ":counts")
630
+ if #pairs_ == 0 then return "[]" end
631
+ return cjson.encode(pairs_)
632
+ `
633
+ }).withReturnType();
634
+ /** remove(prefix, id) -> removed boolean (active jobs are refused). */
635
+ export const remove = Redis.script((prefix, id) => [prefix, id], {
636
+ numberOfKeys: 0,
637
+ lua: `${HELPERS}
638
+ local id = ARGV[2]
639
+ local jk = jobKey(id)
640
+ if redis.call("EXISTS", jk) == 0 or redis.call("HGET", jk, "state") == "active" then
641
+ return "0"
642
+ end
643
+ deleteJob(id)
644
+ return "1"
645
+ `
646
+ }).withReturnType();
647
+ /** retry(prefix, id, now) — failed -> waiting with a fresh budget. */
648
+ export const retry = Redis.script((prefix, id, now) => [prefix, id, now], {
649
+ numberOfKeys: 0,
650
+ lua: `${HELPERS}
651
+ local id = ARGV[2]
652
+ local jk = jobKey(id)
653
+ if redis.call("EXISTS", jk) == 0 then return '{"error":"notfound"}' end
654
+ local state = redis.call("HGET", jk, "state")
655
+ if state ~= "failed" then return '{"error":"state","state":' .. cjson.encode(state) .. '}' end
656
+ local nowStr = ARGV[3]
657
+ local now = tonumber(nowStr)
658
+ local queue = redis.call("HGET", jk, "queue")
659
+ local name = redis.call("HGET", jk, "name")
660
+ local seq = redis.call("INCR", prefix .. ":seq")
661
+ redis.call("HSET", jk, "state", "waiting", "attemptsMade", "0", "stalledCount", "0",
662
+ "cancelRequested", "0", "exit", "", "failedReason", "", "finishedAt", "",
663
+ "processedAt", "", "runAt", nowStr, "seq", fmt(seq))
664
+ redis.call("ZREM", prefix .. ":finished:failed", id)
665
+ redis.call("ZREM", terminalKey(name, "failed"), id)
666
+ local priority = tonumber(redis.call("HGET", jk, "priority")) or 0
667
+ addWaiting(queue, priority, seq, id)
668
+ countsAdd(queue, "failed", -1)
669
+ countsAdd(queue, "waiting", 1)
670
+ return '{"ok":true,"queue":' .. cjson.encode(queue) .. '}'
671
+ `
672
+ }).withReturnType();
673
+ /**
674
+ * cancel(prefix, id, now) — waiting/delayed become terminal; active gets the
675
+ * cancel-request flag; terminal states are refused.
676
+ */
677
+ export const cancel = Redis.script((prefix, id, now) => [prefix, id, now], {
678
+ numberOfKeys: 0,
679
+ lua: `${HELPERS}
680
+ local id = ARGV[2]
681
+ local jk = jobKey(id)
682
+ if redis.call("EXISTS", jk) == 0 then return '{"error":"notfound"}' end
683
+ local state = redis.call("HGET", jk, "state")
684
+ if state == "active" then
685
+ redis.call("HSET", jk, "cancelRequested", "1")
686
+ return '{"ok":true}'
687
+ end
688
+ if state ~= "waiting" and state ~= "delayed" then
689
+ return '{"error":"state","state":' .. cjson.encode(state) .. '}'
690
+ end
691
+ local nowStr = ARGV[3]
692
+ local now = tonumber(nowStr)
693
+ local queue = redis.call("HGET", jk, "queue")
694
+ local name = redis.call("HGET", jk, "name")
695
+ remWaiting(queue, id)
696
+ redis.call("ZREM", delayedKey(queue), id)
697
+ redis.call("HSET", jk, "state", "cancelled", "finishedAt", nowStr, "cancelRequested", "0")
698
+ countsAdd(queue, state, -1)
699
+ countsAdd(queue, "cancelled", 1)
700
+ redis.call("ZADD", prefix .. ":finished:cancelled", now, id)
701
+ redis.call("ZADD", terminalKey(name, "cancelled"), now, id)
702
+ appendAttempt(id, "cancelled", redis.call("HGET", jk, "processedAt"), nowStr, "")
703
+ releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
704
+ applyKeep(name, "cancelled", redis.call("HGET", jk, "keep"), now)
705
+ return '{"ok":true}'
706
+ `
707
+ }).withReturnType();
708
+ /** promote(prefix, id, now) — delayed -> waiting now. */
709
+ export const promote = Redis.script((prefix, id, now) => [prefix, id, now], {
710
+ numberOfKeys: 0,
711
+ lua: `${HELPERS}
712
+ local id = ARGV[2]
713
+ local jk = jobKey(id)
714
+ if redis.call("EXISTS", jk) == 0 then return '{"error":"notfound"}' end
715
+ local state = redis.call("HGET", jk, "state")
716
+ if state ~= "delayed" then return '{"error":"state","state":' .. cjson.encode(state) .. '}' end
717
+ local queue = redis.call("HGET", jk, "queue")
718
+ local seq = tonumber(redis.call("HGET", jk, "seq")) or 0
719
+ local priority = tonumber(redis.call("HGET", jk, "priority")) or 0
720
+ redis.call("ZREM", delayedKey(queue), id)
721
+ redis.call("HSET", jk, "state", "waiting", "runAt", ARGV[3])
722
+ addWaiting(queue, priority, seq, id)
723
+ countsAdd(queue, "delayed", -1)
724
+ countsAdd(queue, "waiting", 1)
725
+ return '{"ok":true,"queue":' .. cjson.encode(queue) .. '}'
726
+ `
727
+ }).withReturnType();
728
+ /**
729
+ * upsertSchedule(prefix, key, jobName, queue, cron, tz, everyMs, payloadJson,
730
+ * metadataJson, priority, attemptsMax, backoffJson, keepJson,
731
+ * timeoutMs, nextRunAt)
732
+ * Every field arrives pre-encoded from TS and is stored VERBATIM — routing a
733
+ * record through cjson would corrupt high-precision numbers (14 significant
734
+ * digits) and empty arrays ({}). An unchanged cadence (cron/tz/everyMs)
735
+ * preserves the stored nextRunAt.
736
+ */
737
+ export const upsertSchedule = Redis.script((prefix, key, jobName, queue, cron, tz, everyMs, payloadJson, metadataJson, priority, attemptsMax, backoffJson, keepJson, timeoutMs, nextRunAt) => [
738
+ prefix,
739
+ key,
740
+ jobName,
741
+ queue,
742
+ cron,
743
+ tz,
744
+ everyMs,
745
+ payloadJson,
746
+ metadataJson,
747
+ priority,
748
+ attemptsMax,
749
+ backoffJson,
750
+ keepJson,
751
+ timeoutMs,
752
+ nextRunAt
753
+ ], {
754
+ numberOfKeys: 0,
755
+ lua: `${HELPERS}
756
+ local key = ARGV[2]
757
+ local sk = prefix .. ":schedule:" .. key
758
+ local nextRunAt = tonumber(ARGV[15])
759
+ local prevCron = redis.call("HGET", sk, "cron")
760
+ if prevCron ~= false
761
+ and prevCron == ARGV[5]
762
+ and redis.call("HGET", sk, "tz") == ARGV[6]
763
+ and redis.call("HGET", sk, "everyMs") == ARGV[7]
764
+ then
765
+ nextRunAt = tonumber(redis.call("HGET", sk, "nextRunAt")) or nextRunAt
766
+ end
767
+ redis.call("DEL", sk)
768
+ redis.call("HSET", sk,
769
+ "key", key, "jobName", ARGV[3], "queue", ARGV[4],
770
+ "cron", ARGV[5], "tz", ARGV[6], "everyMs", ARGV[7],
771
+ "payload", ARGV[8], "metadata", ARGV[9],
772
+ "priority", ARGV[10], "attemptsMax", ARGV[11],
773
+ "backoff", ARGV[12], "keep", ARGV[13], "timeoutMs", ARGV[14],
774
+ "nextRunAt", fmt(nextRunAt))
775
+ redis.call("ZADD", prefix .. ":schedules", nextRunAt, key)
776
+ return '{"ok":true}'
777
+ `
778
+ }).withReturnType();
779
+ /** removeSchedule(prefix, key) -> existed boolean. */
780
+ export const removeSchedule = Redis.script((prefix, key) => [prefix, key], {
781
+ numberOfKeys: 0,
782
+ lua: `${HELPERS}
783
+ local removed = redis.call("ZREM", prefix .. ":schedules", ARGV[2])
784
+ redis.call("DEL", prefix .. ":schedule:" .. ARGV[2])
785
+ return tostring(removed)
786
+ `
787
+ }).withReturnType();
788
+ /** listSchedules(prefix, filtersJson) ordered by nextRunAt ascending. */
789
+ export const listSchedules = Redis.script((prefix, filtersJson) => [prefix, filtersJson], {
790
+ numberOfKeys: 0,
791
+ lua: `${HELPERS}
792
+ local filters = cjson.decode(ARGV[2])
793
+ local keys = redis.call("ZRANGE", prefix .. ":schedules", 0, -1)
794
+ local out = {}
795
+ for _, key in ipairs(keys) do
796
+ local record = redis.call("HGETALL", prefix .. ":schedule:" .. key)
797
+ local byName = {}
798
+ for i = 1, #record, 2 do byName[record[i]] = record[i + 1] end
799
+ local matches = true
800
+ if filters.jobName ~= nil and byName.jobName ~= filters.jobName then matches = false end
801
+ if matches and filters.queue ~= nil and byName.queue ~= filters.queue then matches = false end
802
+ if matches then out[#out + 1] = record end
803
+ end
804
+ if #out == 0 then return "[]" end
805
+ return cjson.encode(out)
806
+ `
807
+ }).withReturnType();
808
+ /** dueSchedules(prefix, now) ordered by nextRunAt ascending. */
809
+ export const dueSchedules = Redis.script((prefix, now) => [prefix, now], {
810
+ numberOfKeys: 0,
811
+ lua: `${HELPERS}
812
+ local keys = redis.call("ZRANGEBYSCORE", prefix .. ":schedules", "-inf", tonumber(ARGV[2]))
813
+ local out = {}
814
+ for _, key in ipairs(keys) do
815
+ out[#out + 1] = redis.call("HGETALL", prefix .. ":schedule:" .. key)
816
+ end
817
+ if #out == 0 then return "[]" end
818
+ return cjson.encode(out)
819
+ `
820
+ }).withReturnType();
821
+ /** advanceSchedule(prefix, key, expectedRunAt, nextRunAt) — conditional CAS. */
822
+ export const advanceSchedule = Redis.script((prefix, key, expectedRunAt, nextRunAt) => [prefix, key, expectedRunAt, nextRunAt], {
823
+ numberOfKeys: 0,
824
+ lua: `${HELPERS}
825
+ local key = ARGV[2]
826
+ local sk = prefix .. ":schedule:" .. key
827
+ local current = redis.call("HGET", sk, "nextRunAt")
828
+ if current == false or tonumber(current) ~= tonumber(ARGV[3]) then return "0" end
829
+ redis.call("HSET", sk, "nextRunAt", fmt(tonumber(ARGV[4])))
830
+ redis.call("ZADD", prefix .. ":schedules", tonumber(ARGV[4]), key)
831
+ return "1"
832
+ `
833
+ }).withReturnType();
834
+ /**
835
+ * sweepState(prefix, state, ttlMs, limit, offset, now) -> {scanned, deleted}
836
+ * One bounded page over a terminal state's finished zset, deleting rows past
837
+ * min(store ceiling, per-row keep.age). The caller advances the offset by
838
+ * (scanned - deleted) and stops when a page comes back short.
839
+ */
840
+ export const sweepState = Redis.script((prefix, state, ttlMs, limit, offset, now) => [
841
+ prefix,
842
+ state,
843
+ ttlMs,
844
+ limit,
845
+ offset,
846
+ now
847
+ ], {
848
+ numberOfKeys: 0,
849
+ lua: `${HELPERS}
850
+ local state = ARGV[2]
851
+ local ttl = ARGV[3] ~= "" and tonumber(ARGV[3]) or nil
852
+ local limit = tonumber(ARGV[4])
853
+ local offset = tonumber(ARGV[5])
854
+ local now = tonumber(ARGV[6])
855
+ local batch = redis.call("ZRANGEBYSCORE", prefix .. ":finished:" .. state, "-inf", now,
856
+ "WITHSCORES", "LIMIT", offset, limit)
857
+ local scanned = 0
858
+ local deleted = 0
859
+ for i = 1, #batch, 2 do
860
+ scanned = scanned + 1
861
+ local id = batch[i]
862
+ local finishedAt = tonumber(batch[i + 1])
863
+ if redis.call("EXISTS", jobKey(id)) == 0 then
864
+ -- Orphaned member (hash evicted/removed out of band): self-heal so the
865
+ -- cursor math stays honest and the member never loops the sweep.
866
+ redis.call("ZREM", prefix .. ":finished:" .. state, id)
867
+ deleted = deleted + 1
868
+ else
869
+ local cutoffAge = ttl
870
+ local keepJson = redis.call("HGET", jobKey(id), "keep")
871
+ if keepJson and keepJson ~= "" then
872
+ local ok, keep = pcall(cjson.decode, keepJson)
873
+ if ok and type(keep) == "table" then
874
+ local policy = keep[state]
875
+ if type(policy) ~= "table"
876
+ and keep.completed == nil and keep.failed == nil and keep.cancelled == nil
877
+ and (keep.count ~= nil or keep.ageMs ~= nil)
878
+ then
879
+ policy = keep
880
+ end
881
+ if type(policy) == "table" and policy.ageMs ~= nil then
882
+ local age = tonumber(policy.ageMs)
883
+ if age ~= nil and (cutoffAge == nil or age < cutoffAge) then cutoffAge = age end
884
+ end
885
+ end
886
+ end
887
+ if cutoffAge ~= nil and finishedAt <= now - cutoffAge then
888
+ deleteJob(id)
889
+ deleted = deleted + 1
890
+ end
891
+ end
892
+ end
893
+ return cjson.encode({ scanned = scanned, deleted = deleted })
894
+ `
895
+ }).withReturnType();
896
+ /**
897
+ * sweepDedupes(prefix, limit, now) -> number pruned (bounded batch; the
898
+ * caller loops until 0): expired windows, then pending pointers (+inf)
899
+ * whose job is gone or terminal.
900
+ */
901
+ export const sweepDedupes = Redis.script((prefix, limit, now) => [prefix, limit, now], {
902
+ numberOfKeys: 0,
903
+ lua: `${HELPERS}
904
+ local limit = tonumber(ARGV[2])
905
+ local now = tonumber(ARGV[3])
906
+ -- Lazy migration: drain the pre-0.3 unsplit finished zset into the per-state
907
+ -- keys (or drop orphans) so old history keeps getting swept.
908
+ local migrated = 0
909
+ local legacy = redis.call("ZRANGE", prefix .. ":finished", 0, limit - 1, "WITHSCORES")
910
+ for i = 1, #legacy, 2 do
911
+ local id = legacy[i]
912
+ local state = redis.call("HGET", jobKey(id), "state")
913
+ if state == "completed" or state == "failed" or state == "cancelled" then
914
+ redis.call("ZADD", prefix .. ":finished:" .. state, tonumber(legacy[i + 1]), id)
915
+ end
916
+ redis.call("ZREM", prefix .. ":finished", id)
917
+ migrated = migrated + 1
918
+ end
919
+ local index = prefix .. ":dedupes"
920
+ local expired = redis.call("ZRANGEBYSCORE", index, "-inf", now, "LIMIT", 0, limit)
921
+ for _, member in ipairs(expired) do
922
+ redis.call("DEL", prefix .. ":dedupe:" .. member)
923
+ redis.call("ZREM", index, member)
924
+ end
925
+ local removedPending = 0
926
+ local pendings = redis.call("ZRANGEBYSCORE", index, "inf", "inf", "LIMIT", 0, limit)
927
+ for _, member in ipairs(pendings) do
928
+ local sk = prefix .. ":dedupe:" .. member
929
+ local jobId = redis.call("HGET", sk, "jobId")
930
+ local state = jobId and redis.call("HGET", jobKey(jobId), "state")
931
+ if not state or state == "completed" or state == "failed" or state == "cancelled" then
932
+ redis.call("DEL", sk)
933
+ redis.call("ZREM", index, member)
934
+ removedPending = removedPending + 1
935
+ end
936
+ end
937
+ return tostring(migrated + #expired + removedPending)
938
+ `
939
+ }).withReturnType();
940
+ //# sourceMappingURL=scripts.js.map