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