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