effect-mq 0.3.2 → 0.4.1

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 (42) hide show
  1. package/README.md +102 -14
  2. package/dist/Job.d.ts +131 -9
  3. package/dist/Job.d.ts.map +1 -1
  4. package/dist/Job.js +81 -5
  5. package/dist/Job.js.map +1 -1
  6. package/dist/JobStore.d.ts +63 -2
  7. package/dist/JobStore.d.ts.map +1 -1
  8. package/dist/JobStore.js.map +1 -1
  9. package/dist/MemoryJobStore.d.ts.map +1 -1
  10. package/dist/MemoryJobStore.js +159 -119
  11. package/dist/MemoryJobStore.js.map +1 -1
  12. package/dist/Worker.d.ts +18 -0
  13. package/dist/Worker.d.ts.map +1 -1
  14. package/dist/Worker.js +37 -9
  15. package/dist/Worker.js.map +1 -1
  16. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
  17. package/dist/drizzle-postgres/DrizzleJobStore.js +239 -49
  18. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
  19. package/dist/drizzle-postgres/schema.d.ts +35 -27
  20. package/dist/drizzle-postgres/schema.d.ts.map +1 -1
  21. package/dist/drizzle-postgres/schema.js +8 -2
  22. package/dist/drizzle-postgres/schema.js.map +1 -1
  23. package/dist/redis/RedisJobStore.d.ts.map +1 -1
  24. package/dist/redis/RedisJobStore.js +173 -36
  25. package/dist/redis/RedisJobStore.js.map +1 -1
  26. package/dist/redis/scripts.d.ts +24 -1
  27. package/dist/redis/scripts.d.ts.map +1 -1
  28. package/dist/redis/scripts.js +126 -21
  29. package/dist/redis/scripts.js.map +1 -1
  30. package/dist/testing/conformance.d.ts.map +1 -1
  31. package/dist/testing/conformance.js +266 -0
  32. package/dist/testing/conformance.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/Job.ts +268 -13
  35. package/src/JobStore.ts +77 -2
  36. package/src/MemoryJobStore.ts +102 -53
  37. package/src/Worker.ts +61 -9
  38. package/src/drizzle-postgres/DrizzleJobStore.ts +273 -66
  39. package/src/drizzle-postgres/schema.ts +33 -25
  40. package/src/redis/RedisJobStore.ts +236 -47
  41. package/src/redis/scripts.ts +153 -21
  42. package/src/testing/conformance.ts +350 -0
@@ -84,6 +84,7 @@ const toRecord = (hash: ReadonlyMap<string, string>): JobStore.JobRecord => ({
84
84
  timeoutMs: optionalNumber(hash.get("timeoutMs")),
85
85
  cancelRequested: hash.get("cancelRequested") === "1",
86
86
  dedupeKey: optionalString(hash.get("dedupeKey")),
87
+ trace: optionalJson<JobStore.TraceContext>(hash.get("trace")),
87
88
  runAt: Number(hash.get("runAt") ?? 0),
88
89
  enqueuedAt: Number(hash.get("enqueuedAt") ?? 0),
89
90
  processedAt: optionalNumber(hash.get("processedAt")),
@@ -154,6 +155,8 @@ export const make = (
154
155
  const evalListSchedules = redis.eval(scripts.listSchedules)
155
156
  const evalDueSchedules = redis.eval(scripts.dueSchedules)
156
157
  const evalAdvanceSchedule = redis.eval(scripts.advanceSchedule)
158
+ const evalTickSchedule = redis.eval(scripts.tickSchedule)
159
+ const evalEnqueueMany = redis.eval(scripts.enqueueMany)
157
160
  const evalSweepState = redis.eval(scripts.sweepState)
158
161
  const evalSweepDedupes = redis.eval(scripts.sweepDedupes)
159
162
 
@@ -277,53 +280,198 @@ export const make = (
277
280
  request.dedupe?.key ?? "",
278
281
  request.dedupe?.ttlMs === undefined ? "" : String(request.dedupe.ttlMs),
279
282
  request.dedupe?.extend === true ? "1" : "0",
280
- request.dedupe?.replace === true ? "1" : "0"
283
+ request.dedupe?.replace === true ? "1" : "0",
284
+ request.trace === undefined ? "" : JSON.stringify(request.trace)
281
285
  )
282
286
 
283
- const store: JobStore.Service = {
284
- enqueue: (request) =>
285
- Effect.gen(function*() {
287
+ // Shared by cancel and cancelByDedupe.
288
+ const cancelJob = (id: JobStore.JobId) =>
289
+ Effect.gen(function*() {
290
+ const now = yield* Clock.currentTimeMillis
291
+ const reply: { error?: string; state?: JobStore.JobState } = JSON.parse(
292
+ yield* evalCancel(prefix, id, now).pipe(Effect.mapError(storeError("cancel failed")))
293
+ )
294
+ if (reply.error === "notfound") return yield* new JobStore.JobNotFoundError({ jobId: id })
295
+ if (reply.error === "state") {
296
+ return yield* new JobStore.JobNotCancellableError({ jobId: id, state: reply.state ?? "completed" })
297
+ }
298
+ })
299
+
300
+ const enqueueJob = (request: JobStore.EnqueueRequest) =>
301
+ Effect.gen(function*() {
302
+ const now = yield* Clock.currentTimeMillis
303
+ const generate = options?.idGenerator
304
+ for (let i = 0; i < 5; i++) {
305
+ const mode = request.id !== undefined
306
+ ? "user" as const
307
+ : generate !== undefined
308
+ ? "generated" as const
309
+ : "auto" as const
310
+ const candidate = mode === "user"
311
+ ? request.id ?? ""
312
+ : mode === "generated" && generate !== undefined
313
+ ? yield* generateCandidate(request, generate)
314
+ : ""
315
+ const reply: {
316
+ id?: string
317
+ duplicate?: boolean
318
+ wake?: boolean
319
+ collision?: boolean
320
+ error?: string
321
+ queue?: string
322
+ } = JSON.parse(yield* enqueueOnce(request, mode, candidate, now))
323
+ if (reply.collision === true) continue
324
+ if (reply.error !== undefined || reply.id === undefined) {
325
+ return yield* new JobStore.JobStoreError({
326
+ message: "enqueue failed: could not generate a unique job id"
327
+ })
328
+ }
329
+ if (reply.wake === true) {
330
+ // A replace-while-delayed reply names the keyed job's queue.
331
+ yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : request.queue)
332
+ }
333
+ return { id: JobStore.JobId(reply.id), duplicate: reply.duplicate === true }
334
+ }
335
+ return yield* new JobStore.JobStoreError({
336
+ message: "enqueue failed: could not generate a unique job id"
337
+ })
338
+ }).pipe(
339
+ Effect.mapError((error) =>
340
+ error instanceof JobStore.JobStoreError ? error : storeError("enqueue failed")(error)
341
+ )
342
+ )
343
+
344
+ // One EVALSHA per chunk of plain (non-dedup) items. Ids resolve in-script
345
+ // ("auto") or client-side (user/generated); a generated id that collides
346
+ // re-draws and retries in the next round, like the single-enqueue path.
347
+ const insertBatch = (requests: ReadonlyArray<JobStore.EnqueueRequest>) =>
348
+ Effect.gen(function*() {
349
+ const generate = options?.idGenerator
350
+ const results: Array<JobStore.EnqueueResult | undefined> = requests.map(() => undefined)
351
+ let pending = requests.map((request, index) => ({ request, index }))
352
+ for (let round = 0; round < 5 && pending.length > 0; round++) {
286
353
  const now = yield* Clock.currentTimeMillis
287
- const generate = options?.idGenerator
288
- for (let i = 0; i < 5; i++) {
289
- const mode = request.id !== undefined
290
- ? "user" as const
291
- : generate !== undefined
292
- ? "generated" as const
293
- : "auto" as const
294
- const candidate = mode === "user"
295
- ? request.id ?? ""
296
- : mode === "generated" && generate !== undefined
297
- ? yield* generateCandidate(request, generate)
298
- : ""
299
- const reply: {
300
- id?: string
301
- duplicate?: boolean
302
- wake?: boolean
303
- collision?: boolean
304
- error?: string
305
- queue?: string
306
- } = JSON.parse(yield* enqueueOnce(request, mode, candidate, now))
307
- if (reply.collision === true) continue
308
- if (reply.error !== undefined || reply.id === undefined) {
354
+ const stillPending: typeof pending = []
355
+ for (let start = 0; start < pending.length; start += 500) {
356
+ const chunk = pending.slice(start, start + 500)
357
+ const itemArgs: Array<string> = []
358
+ for (const { request } of chunk) {
359
+ const mode = request.id !== undefined
360
+ ? "user"
361
+ : generate !== undefined
362
+ ? "generated"
363
+ : "auto"
364
+ const candidate = request.id !== undefined
365
+ ? request.id
366
+ : generate !== undefined
367
+ ? yield* generateCandidate(request, generate)
368
+ : ""
369
+ itemArgs.push(
370
+ mode,
371
+ candidate,
372
+ request.name,
373
+ request.queue,
374
+ JSON.stringify(request.payload ?? null),
375
+ JSON.stringify(request.metadata),
376
+ String(request.priority),
377
+ String(request.attemptsMax),
378
+ request.backoff === undefined ? "" : JSON.stringify(request.backoff),
379
+ request.keep === undefined ? "" : JSON.stringify(request.keep),
380
+ request.timeoutMs === undefined ? "" : String(request.timeoutMs),
381
+ request.trace === undefined ? "" : JSON.stringify(request.trace),
382
+ String(Math.max(0, request.delayMs))
383
+ )
384
+ }
385
+ const replies: Array<{ id?: string; duplicate?: boolean; collision?: boolean; error?: string }> = JSON
386
+ .parse(yield* evalEnqueueMany(prefix, now, chunk.length, itemArgs))
387
+ const freshQueues = new Set<JobStore.QueueName>()
388
+ let failed = false
389
+ for (let i = 0; i < chunk.length; i++) {
390
+ const reply = replies[i]
391
+ const item = chunk[i]
392
+ if (item === undefined) continue
393
+ if (reply === undefined || reply.error !== undefined) {
394
+ failed = true
395
+ continue
396
+ }
397
+ if (reply.collision === true) {
398
+ // Generated-id collision: re-draw and re-insert next round.
399
+ // NOTE this lands the item after its batch-mates in FIFO
400
+ // order — acceptable for a pathological collision, and
401
+ // documented on the contract.
402
+ stillPending.push(item)
403
+ continue
404
+ }
405
+ if (reply.id === undefined) {
406
+ failed = true
407
+ continue
408
+ }
409
+ results[item.index] = { id: JobStore.JobId(reply.id), duplicate: reply.duplicate === true }
410
+ if (reply.duplicate !== true) {
411
+ freshQueues.add(item.request.queue)
412
+ }
413
+ }
414
+ // The script is not transactional across items: inserts that
415
+ // landed before a failing item are durable, so wake their queues
416
+ // BEFORE surfacing the error.
417
+ for (const queue of freshQueues) {
418
+ yield* wakeUp(queue)
419
+ }
420
+ if (failed) {
309
421
  return yield* new JobStore.JobStoreError({
310
- message: "enqueue failed: could not generate a unique job id"
422
+ message: "enqueueMany failed: could not generate a unique job id"
311
423
  })
312
424
  }
313
- if (reply.wake === true) {
314
- // A replace-while-delayed reply names the keyed job's queue.
315
- yield* wakeUp(reply.queue !== undefined ? JobStore.QueueName(reply.queue) : request.queue)
425
+ }
426
+ pending = stillPending
427
+ }
428
+ const resolved: Array<JobStore.EnqueueResult> = []
429
+ for (const result of results) {
430
+ if (result === undefined) {
431
+ return yield* new JobStore.JobStoreError({
432
+ message: "enqueueMany failed: could not generate a unique job id"
433
+ })
434
+ }
435
+ resolved.push(result)
436
+ }
437
+ return resolved
438
+ }).pipe(
439
+ Effect.mapError((error) =>
440
+ error instanceof JobStore.JobStoreError ? error : storeError("enqueueMany failed")(error)
441
+ )
442
+ )
443
+
444
+ const store: JobStore.Service = {
445
+ enqueue: enqueueJob,
446
+
447
+ enqueueMany: (requests) =>
448
+ Effect.gen(function*() {
449
+ const results: Array<JobStore.EnqueueResult> = []
450
+ let batch: Array<JobStore.EnqueueRequest> = []
451
+ const flush = () =>
452
+ Effect.gen(function*() {
453
+ if (batch.length === 0) return
454
+ const items = batch
455
+ batch = []
456
+ // No spread: a six-figure batch would blow the engine's
457
+ // argument-count limit after the rows already committed.
458
+ for (const result of yield* insertBatch(items)) {
459
+ results.push(result)
460
+ }
461
+ })
462
+ for (const request of requests) {
463
+ // Dedup items run through the single-enqueue decision tree in
464
+ // order; runs of plain items between them batch into one script.
465
+ if (request.dedupe !== undefined) {
466
+ yield* flush()
467
+ results.push(yield* enqueueJob(request))
468
+ } else {
469
+ batch.push(request)
316
470
  }
317
- return { id: JobStore.JobId(reply.id), duplicate: reply.duplicate === true }
318
471
  }
319
- return yield* new JobStore.JobStoreError({
320
- message: "enqueue failed: could not generate a unique job id"
321
- })
322
- }).pipe(
323
- Effect.mapError((error) =>
324
- error instanceof JobStore.JobStoreError ? error : storeError("enqueue failed")(error)
325
- )
326
- ),
472
+ yield* flush()
473
+ return results
474
+ }),
327
475
 
328
476
  claim: (claimOptions) =>
329
477
  // Snapshot BEFORE the script runs: a wake that fires while the claim
@@ -531,16 +679,20 @@ export const make = (
531
679
  Effect.map((raw) => raw !== "0")
532
680
  ),
533
681
 
534
- cancel: (id) =>
682
+ cancel: (id) => cancelJob(id),
683
+
684
+ cancelByDedupe: (name, key) =>
535
685
  Effect.gen(function*() {
536
- const now = yield* Clock.currentTimeMillis
537
- const reply: { error?: string; state?: JobStore.JobState } = JSON.parse(
538
- yield* evalCancel(prefix, id, now).pipe(Effect.mapError(storeError("cancel failed")))
686
+ const jobId = yield* redis.send("HGET", `${prefix}:dedupe:${name}\u0000${key}`, "jobId").pipe(
687
+ Effect.mapError(storeError("cancelByDedupe failed"))
688
+ )
689
+ if (jobId === null || jobId === undefined || jobId === "") return false
690
+ return yield* cancelJob(JobStore.JobId(String(jobId))).pipe(
691
+ Effect.as(true),
692
+ // Idempotent: a vanished or already-terminal keyed job is
693
+ // "nothing pending", not an error.
694
+ Effect.catchTag(["JobNotFoundError", "JobNotCancellableError"], () => Effect.succeed(false))
539
695
  )
540
- if (reply.error === "notfound") return yield* new JobStore.JobNotFoundError({ jobId: id })
541
- if (reply.error === "state") {
542
- return yield* new JobStore.JobNotCancellableError({ jobId: id, state: reply.state ?? "completed" })
543
- }
544
696
  }),
545
697
 
546
698
  promote: (id) =>
@@ -622,6 +774,43 @@ export const make = (
622
774
  return flat.map((pairs) => toSchedule(foldPairs(pairs)))
623
775
  }).pipe(Effect.mapError(storeError("dueSchedules failed"))),
624
776
 
777
+ tickSchedule: (key, expectedRunAt, nextRunAt, request) =>
778
+ Effect.gen(function*() {
779
+ if (request.id === undefined) {
780
+ return yield* new JobStore.JobStoreError({
781
+ message: "tickSchedule requires an explicit request.id"
782
+ })
783
+ }
784
+ const now = yield* Clock.currentTimeMillis
785
+ const fired = (yield* evalTickSchedule(
786
+ prefix,
787
+ key,
788
+ expectedRunAt,
789
+ nextRunAt,
790
+ request.id,
791
+ request.name,
792
+ request.queue,
793
+ JSON.stringify(request.payload ?? null),
794
+ JSON.stringify(request.metadata),
795
+ request.priority,
796
+ request.attemptsMax,
797
+ request.backoff === undefined ? "" : JSON.stringify(request.backoff),
798
+ request.keep === undefined ? "" : JSON.stringify(request.keep),
799
+ request.timeoutMs === undefined ? "" : String(request.timeoutMs),
800
+ request.trace === undefined ? "" : JSON.stringify(request.trace),
801
+ Math.max(0, request.delayMs),
802
+ now
803
+ )) === "1"
804
+ if (fired) {
805
+ yield* wakeUp(request.queue)
806
+ }
807
+ return fired
808
+ }).pipe(
809
+ Effect.mapError((error) =>
810
+ error instanceof JobStore.JobStoreError ? error : storeError("tickSchedule failed")(error)
811
+ )
812
+ ),
813
+
625
814
  advanceSchedule: (key, expectedRunAt, nextRunAt) =>
626
815
  evalAdvanceSchedule(prefix, key, expectedRunAt, nextRunAt).pipe(
627
816
  Effect.mapError(storeError("advanceSchedule failed")),
@@ -159,6 +159,30 @@ local function finishCancelled(id, queue, name, startedAt, now, nowStr)
159
159
  releaseDedupe(name, redis.call("HGET", jk, "dedupeKey"), id, now)
160
160
  applyKeep(name, "cancelled", redis.call("HGET", jk, "keep"), now)
161
161
  end
162
+ -- Insert one fresh job row plus every index entry. String params are stored
163
+ -- verbatim (payload/metadata/backoff/keep/trace are pre-encoded JSON, "" =
164
+ -- absent); priority/delayMs/now numeric-coercible.
165
+ local function insertJobRow(id, name, queue, payloadJson, metadataJson, priority,
166
+ attemptsMax, backoffJson, keepJson, timeoutMs, dedupeKey, traceJson, delayMs, now, nowStr)
167
+ local seq = redis.call("INCR", prefix .. ":seq")
168
+ local state = delayMs > 0 and "delayed" or "waiting"
169
+ local runAt = now + delayMs
170
+ redis.call("HSET", jobKey(id),
171
+ "id", id, "name", name, "queue", queue,
172
+ "payload", payloadJson, "metadata", metadataJson, "state", state,
173
+ "priority", priority, "attemptsMax", attemptsMax, "attemptsMade", "0", "stalledCount", "0",
174
+ "backoff", backoffJson, "keep", keepJson, "timeoutMs", timeoutMs,
175
+ "cancelRequested", "0", "dedupeKey", dedupeKey, "trace", traceJson, "runAt", fmt(runAt), "enqueuedAt", nowStr,
176
+ "processedAt", "", "finishedAt", "", "exit", "", "failedReason", "",
177
+ "lockToken", "", "lockExpiresAt", "", "seq", fmt(seq))
178
+ redis.call("ZADD", prefix .. ":all", now, id)
179
+ if state == "waiting" then
180
+ addWaiting(queue, tonumber(priority), seq, id)
181
+ else
182
+ redis.call("ZADD", delayedKey(queue), runAt, id)
183
+ end
184
+ countsAdd(queue, state, 1)
185
+ end
162
186
  `
163
187
 
164
188
  /**
@@ -186,7 +210,8 @@ export const enqueue = Redis.script(
186
210
  dedupeKey: string,
187
211
  dedupeTtlMs: string,
188
212
  dedupeExtend: string,
189
- dedupeReplace: string
213
+ dedupeReplace: string,
214
+ traceJson: string
190
215
  ) => [
191
216
  prefix,
192
217
  idMode,
@@ -205,7 +230,8 @@ export const enqueue = Redis.script(
205
230
  dedupeKey,
206
231
  dedupeTtlMs,
207
232
  dedupeExtend,
208
- dedupeReplace
233
+ dedupeReplace,
234
+ traceJson
209
235
  ],
210
236
  {
211
237
  numberOfKeys: 0,
@@ -244,7 +270,7 @@ if dKey ~= "" then
244
270
  local newRunAt = now + delayMs
245
271
  redis.call("HSET", kjk, "payload", ARGV[6], "metadata", ARGV[7], "priority", ARGV[8],
246
272
  "attemptsMax", ARGV[9], "backoff", ARGV[10], "keep", ARGV[11], "timeoutMs", ARGV[12],
247
- "runAt", fmt(newRunAt))
273
+ "trace", ARGV[19], "runAt", fmt(newRunAt))
248
274
  local keyedQueue = redis.call("HGET", kjk, "queue")
249
275
  redis.call("ZADD", delayedKey(keyedQueue), newRunAt, entryJob)
250
276
  -- A landed replace re-arms the ttl window.
@@ -278,24 +304,8 @@ if idMode == "auto" then
278
304
  end
279
305
  if id == "" then return '{"error":"id"}' end
280
306
  end
281
- local seq = redis.call("INCR", prefix .. ":seq")
282
- local state = delayMs > 0 and "delayed" or "waiting"
283
- local runAt = now + delayMs
284
- redis.call("HSET", jobKey(id),
285
- "id", id, "name", ARGV[4], "queue", queue,
286
- "payload", ARGV[6], "metadata", ARGV[7], "state", state,
287
- "priority", ARGV[8], "attemptsMax", ARGV[9], "attemptsMade", "0", "stalledCount", "0",
288
- "backoff", ARGV[10], "keep", ARGV[11], "timeoutMs", ARGV[12],
289
- "cancelRequested", "0", "dedupeKey", dKey, "runAt", fmt(runAt), "enqueuedAt", nowStr,
290
- "processedAt", "", "finishedAt", "", "exit", "", "failedReason", "",
291
- "lockToken", "", "lockExpiresAt", "", "seq", fmt(seq))
292
- redis.call("ZADD", prefix .. ":all", now, id)
293
- if state == "waiting" then
294
- addWaiting(queue, tonumber(ARGV[8]), seq, id)
295
- else
296
- redis.call("ZADD", delayedKey(queue), runAt, id)
297
- end
298
- countsAdd(queue, state, 1)
307
+ insertJobRow(id, ARGV[4], queue, ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11],
308
+ ARGV[12], dKey, ARGV[19], delayMs, now, nowStr)
299
309
  if dKey ~= "" then
300
310
  local sk = dedupeStoreKey(name, dKey)
301
311
  redis.call("DEL", sk)
@@ -940,6 +950,128 @@ return "1"
940
950
  }
941
951
  ).withReturnType<string>()
942
952
 
953
+ /**
954
+ * tickSchedule(prefix, key, expectedRunAt, nextRunAt, id, name, queue,
955
+ * payloadJson, metadataJson, priority, attemptsMax, backoffJson, keepJson,
956
+ * timeoutMs, traceJson, delayMs, now) -> "1" fired | "0"
957
+ * Atomic occurrence claim: the nextRunAt CAS and the tick job's insert run
958
+ * in one script, so a stale sweeper can never re-fire a slot — even after
959
+ * retention pruned the previous slot's job row.
960
+ */
961
+ export const tickSchedule = Redis.script(
962
+ (
963
+ prefix: string,
964
+ key: string,
965
+ expectedRunAt: number,
966
+ nextRunAt: number,
967
+ id: string,
968
+ name: string,
969
+ queue: string,
970
+ payloadJson: string,
971
+ metadataJson: string,
972
+ priority: number,
973
+ attemptsMax: number,
974
+ backoffJson: string,
975
+ keepJson: string,
976
+ timeoutMs: string,
977
+ traceJson: string,
978
+ delayMs: number,
979
+ now: number
980
+ ) => [
981
+ prefix,
982
+ key,
983
+ expectedRunAt,
984
+ nextRunAt,
985
+ id,
986
+ name,
987
+ queue,
988
+ payloadJson,
989
+ metadataJson,
990
+ priority,
991
+ attemptsMax,
992
+ backoffJson,
993
+ keepJson,
994
+ timeoutMs,
995
+ traceJson,
996
+ delayMs,
997
+ now
998
+ ],
999
+ {
1000
+ numberOfKeys: 0,
1001
+ lua: `${HELPERS}
1002
+ local key = ARGV[2]
1003
+ local sk = prefix .. ":schedule:" .. key
1004
+ local current = redis.call("HGET", sk, "nextRunAt")
1005
+ if current == false or tonumber(current) ~= tonumber(ARGV[3]) then return "0" end
1006
+ redis.call("HSET", sk, "nextRunAt", fmt(tonumber(ARGV[4])))
1007
+ redis.call("ZADD", prefix .. ":schedules", tonumber(ARGV[4]), key)
1008
+ local id = ARGV[5]
1009
+ -- Pre-existing slot row (pre-0.4 crash between enqueue and advance): the
1010
+ -- schedule still advances, but nothing new fires.
1011
+ if redis.call("EXISTS", jobKey(id)) == 1 then return "0" end
1012
+ insertJobRow(id, ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12], ARGV[13],
1013
+ ARGV[14], "", ARGV[15], tonumber(ARGV[16]), tonumber(ARGV[17]), ARGV[17])
1014
+ return "1"
1015
+ `
1016
+ }
1017
+ ).withReturnType<string>()
1018
+
1019
+ /**
1020
+ * enqueueMany(prefix, now, count, ...items) -> JSON array of per-item results
1021
+ * ({id, duplicate} | {collision} | {error}). Items are 13-ARGV strides:
1022
+ * idMode, id, name, queue, payloadJson, metadataJson, priority, attemptsMax,
1023
+ * backoffJson, keepJson, timeoutMs, traceJson, delayMs. Plain (non-dedup)
1024
+ * items only — the caller routes dedup items through \`enqueue\`.
1025
+ */
1026
+ export const enqueueMany = Redis.script(
1027
+ (prefix: string, now: number, count: number, items: ReadonlyArray<string>) => [prefix, now, count, ...items],
1028
+ {
1029
+ numberOfKeys: 0,
1030
+ lua: `${HELPERS}
1031
+ local now = tonumber(ARGV[2])
1032
+ local nowStr = ARGV[2]
1033
+ local count = tonumber(ARGV[3])
1034
+ local out = {}
1035
+ for i = 0, count - 1 do
1036
+ local base = 3 + i * 13
1037
+ local idMode = ARGV[base + 1]
1038
+ local id = ARGV[base + 2]
1039
+ local result
1040
+ if idMode ~= "auto" and redis.call("EXISTS", jobKey(id)) == 1 then
1041
+ -- Sequential in-script processing makes intra-batch repeats of one user
1042
+ -- id resolve exactly like separate enqueues: first inserts, rest dedup.
1043
+ if idMode == "user" then
1044
+ result = '{"id":' .. cjson.encode(id) .. ',"duplicate":true}'
1045
+ else
1046
+ result = '{"collision":true}'
1047
+ end
1048
+ else
1049
+ if idMode == "auto" then
1050
+ id = ""
1051
+ for a = 1, 5 do
1052
+ local candidate = "j-" .. fmt(redis.call("INCR", prefix .. ":seq"))
1053
+ if redis.call("EXISTS", jobKey(candidate)) == 0 then
1054
+ id = candidate
1055
+ break
1056
+ end
1057
+ end
1058
+ end
1059
+ if id == "" then
1060
+ result = '{"error":"id"}'
1061
+ else
1062
+ insertJobRow(id, ARGV[base + 3], ARGV[base + 4], ARGV[base + 5], ARGV[base + 6],
1063
+ ARGV[base + 7], ARGV[base + 8], ARGV[base + 9], ARGV[base + 10], ARGV[base + 11],
1064
+ "", ARGV[base + 12], tonumber(ARGV[base + 13]), now, nowStr)
1065
+ result = '{"id":' .. cjson.encode(id) .. ',"duplicate":false}'
1066
+ end
1067
+ end
1068
+ out[#out + 1] = result
1069
+ end
1070
+ return "[" .. table.concat(out, ",") .. "]"
1071
+ `
1072
+ }
1073
+ ).withReturnType<string>()
1074
+
943
1075
  /**
944
1076
  * sweepState(prefix, state, ttlMs, limit, offset, now) -> {scanned, deleted}
945
1077
  * One bounded page over a terminal state's finished zset, deleting rows past