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