effect-mq 0.2.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 (45) hide show
  1. package/README.md +153 -18
  2. package/dist/Job.d.ts +51 -1
  3. package/dist/Job.d.ts.map +1 -1
  4. package/dist/Job.js +44 -2
  5. package/dist/Job.js.map +1 -1
  6. package/dist/JobStore.d.ts +86 -6
  7. package/dist/JobStore.d.ts.map +1 -1
  8. package/dist/JobStore.js +27 -1
  9. package/dist/JobStore.js.map +1 -1
  10. package/dist/MemoryJobStore.d.ts +6 -5
  11. package/dist/MemoryJobStore.d.ts.map +1 -1
  12. package/dist/MemoryJobStore.js +160 -32
  13. package/dist/MemoryJobStore.js.map +1 -1
  14. package/dist/Worker.d.ts.map +1 -1
  15. package/dist/Worker.js +1 -0
  16. package/dist/Worker.js.map +1 -1
  17. package/dist/drizzle-postgres/DrizzleJobStore.d.ts +20 -3
  18. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  19. package/dist/drizzle-postgres/DrizzleJobStore.js +352 -84
  20. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  21. package/dist/drizzle-postgres/schema.d.ts +255 -351
  22. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  23. package/dist/drizzle-postgres/schema.js +75 -27
  24. package/dist/drizzle-postgres/schema.js.map +1 -1
  25. package/dist/redis/RedisJobStore.d.ts +4 -2
  26. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  27. package/dist/redis/RedisJobStore.js +67 -28
  28. package/dist/redis/RedisJobStore.js.map +1 -1
  29. package/dist/redis/scripts.d.ts +19 -6
  30. package/dist/redis/scripts.d.ts.map +1 -1
  31. package/dist/redis/scripts.js +208 -23
  32. package/dist/redis/scripts.js.map +1 -1
  33. package/dist/testing/conformance.d.ts.map +1 -1
  34. package/dist/testing/conformance.js +172 -7
  35. package/dist/testing/conformance.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/Job.ts +113 -6
  38. package/src/JobStore.ts +122 -6
  39. package/src/MemoryJobStore.ts +184 -44
  40. package/src/Worker.ts +1 -0
  41. package/src/drizzle-postgres/DrizzleJobStore.ts +431 -91
  42. package/src/drizzle-postgres/schema.ts +177 -63
  43. package/src/redis/RedisJobStore.ts +90 -35
  44. package/src/redis/scripts.ts +217 -24
  45. package/src/testing/conformance.ts +246 -7
@@ -21,11 +21,13 @@
21
21
  * - `p:delayed:<queue>` ZSET, score `runAt`
22
22
  * - `p:active` ZSET, score `lockExpiresAt`
23
23
  * - `p:all` ZSET, score `enqueuedAt` (list pagination)
24
- * - `p:finished` ZSET, score `finishedAt` (history TTL)
24
+ * - `p:finished:<state>` ZSET, score `finishedAt` (history TTL)
25
25
  * - `p:terminal:<name>:<state>` ZSET, score `finishedAt` (keep pruning)
26
26
  * - `p:counts` HASH `<queue>|<state>` -> integer
27
27
  * - `p:paused` SET of paused queues
28
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)
29
31
  *
30
32
  * @since 0.2.0
31
33
  */
@@ -78,7 +80,7 @@ local function deleteJob(id)
78
80
  local name = redis.call("HGET", jk, "name")
79
81
  countsAdd(queue, state, -1)
80
82
  redis.call("ZREM", prefix .. ":all", id)
81
- redis.call("ZREM", prefix .. ":finished", id)
83
+ redis.call("ZREM", prefix .. ":finished:" .. state, id)
82
84
  redis.call("ZREM", prefix .. ":active", id)
83
85
  redis.call("ZREM", terminalKey(name, state), id)
84
86
  remWaiting(queue, id)
@@ -90,8 +92,20 @@ end
90
92
  -- zset score alone cannot carry the seq tie-break exactly.
91
93
  local function applyKeep(name, state, keepJson, now)
92
94
  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 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
95
109
  local tkey = terminalKey(name, state)
96
110
  if keep.ageMs ~= nil then
97
111
  local old = redis.call("ZRANGEBYSCORE", tkey, "-inf", now - keep.ageMs)
@@ -117,6 +131,20 @@ local function applyKeep(name, state, keepJson, now)
117
131
  for i = count + 1, #arr do deleteJob(arr[i].id) end
118
132
  end
119
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
120
148
  -- Move an active job (whose lock bookkeeping was already cleared by the
121
149
  -- caller) to the terminal cancelled state.
122
150
  local function finishCancelled(id, queue, name, startedAt, now, nowStr)
@@ -125,9 +153,10 @@ local function finishCancelled(id, queue, name, startedAt, now, nowStr)
125
153
  "lockToken", "", "lockExpiresAt", "")
126
154
  countsAdd(queue, "active", -1)
127
155
  countsAdd(queue, "cancelled", 1)
128
- redis.call("ZADD", prefix .. ":finished", now, id)
156
+ redis.call("ZADD", prefix .. ":finished:cancelled", now, id)
129
157
  redis.call("ZADD", terminalKey(name, "cancelled"), now, id)
130
158
  appendAttempt(id, "cancelled", startedAt, nowStr, "")
159
+ releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
131
160
  applyKeep(name, "cancelled", redis.call("HGET", jk, "keep"), now)
132
161
  end
133
162
  `
@@ -153,7 +182,11 @@ export const enqueue = Redis.script(
153
182
  keepJson: string,
154
183
  timeoutMs: string,
155
184
  delayMs: number,
156
- now: number
185
+ now: number,
186
+ dedupeKey: string,
187
+ dedupeTtlMs: string,
188
+ dedupeExtend: string,
189
+ dedupeReplace: string
157
190
  ) => [
158
191
  prefix,
159
192
  idMode,
@@ -168,7 +201,11 @@ export const enqueue = Redis.script(
168
201
  keepJson,
169
202
  timeoutMs,
170
203
  delayMs,
171
- now
204
+ now,
205
+ dedupeKey,
206
+ dedupeTtlMs,
207
+ dedupeExtend,
208
+ dedupeReplace
172
209
  ],
173
210
  {
174
211
  numberOfKeys: 0,
@@ -183,7 +220,54 @@ if idMode == "user" or idMode == "generated" then
183
220
  if idMode == "user" then return '{"duplicate":true,"id":' .. cjson.encode(id) .. '}' end
184
221
  return '{"collision":true}'
185
222
  end
186
- else
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
187
271
  id = ""
188
272
  for i = 1, 5 do
189
273
  local candidate = "j-" .. fmt(redis.call("INCR", prefix .. ":seq"))
@@ -202,7 +286,7 @@ redis.call("HSET", jobKey(id),
202
286
  "payload", ARGV[6], "metadata", ARGV[7], "state", state,
203
287
  "priority", ARGV[8], "attemptsMax", ARGV[9], "attemptsMade", "0", "stalledCount", "0",
204
288
  "backoff", ARGV[10], "keep", ARGV[11], "timeoutMs", ARGV[12],
205
- "cancelRequested", "0", "runAt", fmt(runAt), "enqueuedAt", nowStr,
289
+ "cancelRequested", "0", "dedupeKey", dKey, "runAt", fmt(runAt), "enqueuedAt", nowStr,
206
290
  "processedAt", "", "finishedAt", "", "exit", "", "failedReason", "",
207
291
  "lockToken", "", "lockExpiresAt", "", "seq", fmt(seq))
208
292
  redis.call("ZADD", prefix .. ":all", now, id)
@@ -212,6 +296,14 @@ else
212
296
  redis.call("ZADD", delayedKey(queue), runAt, id)
213
297
  end
214
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
215
307
  -- Wake on EVERY insert (delayed too): idle workers must re-claim to learn
216
308
  -- the new nextRunAt, exactly like the memory and Postgres drivers.
217
309
  return '{"id":' .. cjson.encode(id) .. ',"duplicate":false,"wake":true}'
@@ -340,9 +432,10 @@ local function finish(newState, storeExit, outcome, ledgerExit)
340
432
  if storeExit ~= nil then redis.call("HSET", jk, "exit", storeExit) end
341
433
  countsAdd(queue, "active", -1)
342
434
  countsAdd(queue, newState, 1)
343
- redis.call("ZADD", prefix .. ":finished", now, id)
435
+ redis.call("ZADD", prefix .. ":finished:" .. newState, now, id)
344
436
  redis.call("ZADD", terminalKey(name, newState), now, id)
345
437
  appendAttempt(id, outcome, startedAt, nowStr, ledgerExit)
438
+ releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
346
439
  applyKeep(name, newState, redis.call("HGET", jk, "keep"), now)
347
440
  end
348
441
 
@@ -369,7 +462,7 @@ else
369
462
  else
370
463
  redis.call("ZADD", delayedKey(queue), runAt, id)
371
464
  end
372
- return '{"ok":true,"wake":true}'
465
+ return '{"ok":true,"wake":true,"queue":' .. cjson.encode(queue) .. '}'
373
466
  end
374
467
  return '{"ok":true}'
375
468
  `
@@ -405,7 +498,7 @@ redis.call("HSET", jk, "state", "waiting", "lockToken", "", "lockExpiresAt", "")
405
498
  addWaiting(queue, priority, seq, id)
406
499
  countsAdd(queue, "active", -1)
407
500
  countsAdd(queue, "waiting", 1)
408
- return '{"ok":true,"wake":true}'
501
+ return '{"ok":true,"wake":true,"queue":' .. cjson.encode(queue) .. '}'
409
502
  `
410
503
  }
411
504
  ).withReturnType<string>()
@@ -471,8 +564,9 @@ for _, id in ipairs(expired) do
471
564
  "failedReason", "job stalled more than allowable limit")
472
565
  countsAdd(queue, "active", -1)
473
566
  countsAdd(queue, "failed", 1)
474
- redis.call("ZADD", prefix .. ":finished", now, id)
567
+ redis.call("ZADD", prefix .. ":finished:failed", now, id)
475
568
  redis.call("ZADD", terminalKey(name, "failed"), now, id)
569
+ releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
476
570
  recovered[#recovered + 1] = { id = id, failed = true }
477
571
  else
478
572
  local seq = tonumber(redis.call("HGET", jk, "seq")) or 0
@@ -630,13 +724,13 @@ local seq = redis.call("INCR", prefix .. ":seq")
630
724
  redis.call("HSET", jk, "state", "waiting", "attemptsMade", "0", "stalledCount", "0",
631
725
  "cancelRequested", "0", "exit", "", "failedReason", "", "finishedAt", "",
632
726
  "processedAt", "", "runAt", nowStr, "seq", fmt(seq))
633
- redis.call("ZREM", prefix .. ":finished", id)
727
+ redis.call("ZREM", prefix .. ":finished:failed", id)
634
728
  redis.call("ZREM", terminalKey(name, "failed"), id)
635
729
  local priority = tonumber(redis.call("HGET", jk, "priority")) or 0
636
730
  addWaiting(queue, priority, seq, id)
637
731
  countsAdd(queue, "failed", -1)
638
732
  countsAdd(queue, "waiting", 1)
639
- return '{"ok":true}'
733
+ return '{"ok":true,"queue":' .. cjson.encode(queue) .. '}'
640
734
  `
641
735
  }
642
736
  ).withReturnType<string>()
@@ -670,9 +764,10 @@ redis.call("ZREM", delayedKey(queue), id)
670
764
  redis.call("HSET", jk, "state", "cancelled", "finishedAt", nowStr, "cancelRequested", "0")
671
765
  countsAdd(queue, state, -1)
672
766
  countsAdd(queue, "cancelled", 1)
673
- redis.call("ZADD", prefix .. ":finished", now, id)
767
+ redis.call("ZADD", prefix .. ":finished:cancelled", now, id)
674
768
  redis.call("ZADD", terminalKey(name, "cancelled"), now, id)
675
769
  appendAttempt(id, "cancelled", redis.call("HGET", jk, "processedAt"), nowStr, "")
770
+ releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
676
771
  applyKeep(name, "cancelled", redis.call("HGET", jk, "keep"), now)
677
772
  return '{"ok":true}'
678
773
  `
@@ -698,7 +793,7 @@ redis.call("HSET", jk, "state", "waiting", "runAt", ARGV[3])
698
793
  addWaiting(queue, priority, seq, id)
699
794
  countsAdd(queue, "delayed", -1)
700
795
  countsAdd(queue, "waiting", 1)
701
- return '{"ok":true}'
796
+ return '{"ok":true,"queue":' .. cjson.encode(queue) .. '}'
702
797
  `
703
798
  }
704
799
  ).withReturnType<string>()
@@ -846,17 +941,115 @@ return "1"
846
941
  ).withReturnType<string>()
847
942
 
848
943
  /**
849
- * sweepHistory(prefix, cutoff, limit) -> number swept (bounded batch; the
850
- * caller loops until 0).
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.
851
1013
  */
852
- export const sweepHistory = Redis.script(
853
- (prefix: string, cutoff: number, limit: number) => [prefix, cutoff, limit],
1014
+ export const sweepDedupes = Redis.script(
1015
+ (prefix: string, limit: number, now: number) => [prefix, limit, now],
854
1016
  {
855
1017
  numberOfKeys: 0,
856
1018
  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)
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)
860
1053
  `
861
1054
  }
862
1055
  ).withReturnType<string>()