effect-mq 0.3.2 → 0.4.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.
- package/README.md +102 -14
- package/dist/Job.d.ts +131 -9
- package/dist/Job.d.ts.map +1 -1
- package/dist/Job.js +81 -5
- package/dist/Job.js.map +1 -1
- package/dist/JobStore.d.ts +63 -2
- package/dist/JobStore.d.ts.map +1 -1
- package/dist/JobStore.js.map +1 -1
- package/dist/MemoryJobStore.d.ts.map +1 -1
- package/dist/MemoryJobStore.js +159 -119
- package/dist/MemoryJobStore.js.map +1 -1
- package/dist/Worker.d.ts +18 -0
- package/dist/Worker.d.ts.map +1 -1
- package/dist/Worker.js +37 -9
- package/dist/Worker.js.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.js +239 -49
- package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
- package/dist/drizzle-postgres/schema.d.ts +2 -0
- package/dist/drizzle-postgres/schema.d.ts.map +1 -1
- package/dist/drizzle-postgres/schema.js +1 -0
- package/dist/drizzle-postgres/schema.js.map +1 -1
- package/dist/redis/RedisJobStore.d.ts.map +1 -1
- package/dist/redis/RedisJobStore.js +173 -36
- package/dist/redis/RedisJobStore.js.map +1 -1
- package/dist/redis/scripts.d.ts +24 -1
- package/dist/redis/scripts.d.ts.map +1 -1
- package/dist/redis/scripts.js +126 -21
- package/dist/redis/scripts.js.map +1 -1
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +266 -0
- package/dist/testing/conformance.js.map +1 -1
- package/package.json +1 -1
- package/src/Job.ts +268 -13
- package/src/JobStore.ts +77 -2
- package/src/MemoryJobStore.ts +102 -53
- package/src/Worker.ts +61 -9
- package/src/drizzle-postgres/DrizzleJobStore.ts +273 -66
- package/src/drizzle-postgres/schema.ts +2 -0
- package/src/redis/RedisJobStore.ts +236 -47
- package/src/redis/scripts.ts +153 -21
- 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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
288
|
-
for (let
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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: "
|
|
422
|
+
message: "enqueueMany failed: could not generate a unique job id"
|
|
311
423
|
})
|
|
312
424
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
|
537
|
-
|
|
538
|
-
|
|
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")),
|
package/src/redis/scripts.ts
CHANGED
|
@@ -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
|
-
|
|
282
|
-
|
|
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
|