@prsm/queue 1.0.2 → 2.1.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 +109 -24
- package/package.json +11 -21
- package/src/index.js +1 -0
- package/src/queue.js +525 -0
- package/types/index.d.ts +1 -0
- package/types/queue.d.ts +4768 -0
- package/dist/index.cjs +0 -290
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -77
- package/dist/index.d.ts +0 -77
- package/dist/index.js +0 -257
- package/dist/index.js.map +0 -1
package/src/queue.js
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import { createClient } from "redis"
|
|
2
|
+
import { EventEmitter } from "events"
|
|
3
|
+
import { randomUUID } from "crypto"
|
|
4
|
+
import ms from "@prsm/ms"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {Object} QueueOptions
|
|
8
|
+
* @property {number} [concurrency] - max concurrent tasks per instance (default 1)
|
|
9
|
+
* @property {number} [globalConcurrency] - max concurrent tasks across all instances, Redis-backed (default 0, disabled)
|
|
10
|
+
* @property {number|string} [delay] - pause between tasks, ms or string like "100ms" (default 0)
|
|
11
|
+
* @property {number|string} [timeout] - max task duration, ms or string like "30s" (default 0, no limit)
|
|
12
|
+
* @property {number} [maxRetries] - attempts before failing (default 3)
|
|
13
|
+
* @property {{concurrency?: number, delay?: number|string, timeout?: number|string, maxRetries?: number}} [groups] - overrides for grouped queues
|
|
14
|
+
* @property {{url?: string, host?: string, port?: number, password?: string}} [redisOptions]
|
|
15
|
+
* @property {number} [cleanupInterval] - ms between empty group cleanup (default 30000, 0 to disable)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} Task
|
|
20
|
+
* @property {string} uuid
|
|
21
|
+
* @property {any} payload
|
|
22
|
+
* @property {number} createdAt
|
|
23
|
+
* @property {string} [groupKey]
|
|
24
|
+
* @property {number} attempts
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @callback TaskHandler
|
|
29
|
+
* @param {any} payload
|
|
30
|
+
* @param {Task} task
|
|
31
|
+
* @returns {Promise<any>|any}
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const ACQUIRE_SCRIPT = `
|
|
35
|
+
local key = KEYS[1]
|
|
36
|
+
local max = tonumber(ARGV[1])
|
|
37
|
+
local id = ARGV[2]
|
|
38
|
+
local ttl = tonumber(ARGV[3])
|
|
39
|
+
local time = redis.call('TIME')
|
|
40
|
+
local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
|
|
41
|
+
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - ttl)
|
|
42
|
+
if redis.call('ZCARD', key) < max then
|
|
43
|
+
redis.call('ZADD', key, now, id)
|
|
44
|
+
return 1
|
|
45
|
+
end
|
|
46
|
+
return 0
|
|
47
|
+
`
|
|
48
|
+
|
|
49
|
+
const RELEASE_SCRIPT = `
|
|
50
|
+
redis.call('ZREM', KEYS[1], ARGV[1])
|
|
51
|
+
return 1
|
|
52
|
+
`
|
|
53
|
+
|
|
54
|
+
const RENEW_SCRIPT = `
|
|
55
|
+
local time = redis.call('TIME')
|
|
56
|
+
local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
|
|
57
|
+
if redis.call('ZSCORE', KEYS[1], ARGV[1]) then
|
|
58
|
+
redis.call('ZADD', KEYS[1], now, ARGV[1])
|
|
59
|
+
return 1
|
|
60
|
+
end
|
|
61
|
+
return 0
|
|
62
|
+
`
|
|
63
|
+
|
|
64
|
+
const LEASE_TTL = 60000
|
|
65
|
+
const HEARTBEAT_INTERVAL = 15000
|
|
66
|
+
const CLOSE_TIMEOUT = 5000
|
|
67
|
+
|
|
68
|
+
class LocalSemaphore {
|
|
69
|
+
constructor(max) {
|
|
70
|
+
this._max = max
|
|
71
|
+
this._current = 0
|
|
72
|
+
this._waiting = []
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
acquire() {
|
|
76
|
+
if (this._current < this._max) {
|
|
77
|
+
this._current++
|
|
78
|
+
return Promise.resolve(true)
|
|
79
|
+
}
|
|
80
|
+
return new Promise((resolve) => this._waiting.push(resolve))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
release() {
|
|
84
|
+
if (this._waiting.length > 0) {
|
|
85
|
+
this._waiting.shift()(true)
|
|
86
|
+
} else if (this._current > 0) {
|
|
87
|
+
this._current--
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
releaseAll() {
|
|
92
|
+
for (const resolve of this._waiting) resolve(false)
|
|
93
|
+
this._waiting = []
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default class Queue extends EventEmitter {
|
|
98
|
+
/** @param {QueueOptions} [options] */
|
|
99
|
+
constructor(options = {}) {
|
|
100
|
+
super()
|
|
101
|
+
|
|
102
|
+
this._options = {
|
|
103
|
+
concurrency: options.concurrency ?? 1,
|
|
104
|
+
globalConcurrency: options.globalConcurrency ?? 0,
|
|
105
|
+
delay: ms(options.delay ?? 0),
|
|
106
|
+
timeout: ms(options.timeout ?? 0),
|
|
107
|
+
maxRetries: options.maxRetries ?? 3,
|
|
108
|
+
groups: {
|
|
109
|
+
concurrency: options.groups?.concurrency ?? 1,
|
|
110
|
+
delay: ms(options.groups?.delay ?? options.delay ?? 0),
|
|
111
|
+
timeout: ms(options.groups?.timeout ?? options.timeout ?? 0),
|
|
112
|
+
maxRetries: options.groups?.maxRetries ?? options.maxRetries ?? 3,
|
|
113
|
+
},
|
|
114
|
+
redisOptions: options.redisOptions ?? {},
|
|
115
|
+
cleanupInterval: options.cleanupInterval ?? 30000,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this._handler = null
|
|
119
|
+
this._workers = new Map()
|
|
120
|
+
this._groupWorkers = new Map()
|
|
121
|
+
this._groupInFlight = new Map()
|
|
122
|
+
this._workerClients = []
|
|
123
|
+
this._cleanupTimer = null
|
|
124
|
+
this._inFlight = 0
|
|
125
|
+
this._pushed = 0
|
|
126
|
+
this._totalSettled = 0
|
|
127
|
+
this._closed = false
|
|
128
|
+
this._localSemaphore = new LocalSemaphore(this._options.concurrency)
|
|
129
|
+
this._activeLeases = new Set()
|
|
130
|
+
this._heartbeats = new Map()
|
|
131
|
+
|
|
132
|
+
this._redis = createClient(this._options.redisOptions)
|
|
133
|
+
this._redis.on("error", () => {})
|
|
134
|
+
this._readyPromise = this._initialize()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @returns {Promise<void>} */
|
|
138
|
+
ready() {
|
|
139
|
+
return this._readyPromise
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** @returns {number} */
|
|
143
|
+
get inFlight() {
|
|
144
|
+
return this._inFlight
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** @param {TaskHandler} handler */
|
|
148
|
+
process(handler) {
|
|
149
|
+
this._handler = handler
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {any} payload
|
|
154
|
+
* @returns {Promise<string>}
|
|
155
|
+
*/
|
|
156
|
+
async push(payload) {
|
|
157
|
+
if (this._closed) throw new Error("Queue is closed")
|
|
158
|
+
const task = { uuid: randomUUID(), payload, createdAt: Date.now(), attempts: 0 }
|
|
159
|
+
await this._redis.lPush("queue:tasks", JSON.stringify(task))
|
|
160
|
+
this._pushed++
|
|
161
|
+
this.emit("new", { task })
|
|
162
|
+
return task.uuid
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {any} payload
|
|
167
|
+
* @param {number|string} [timeout] - max time to wait for result, ms or string like "30s" (default 0, no limit)
|
|
168
|
+
* @returns {Promise<any>}
|
|
169
|
+
*/
|
|
170
|
+
pushAndWait(payload, timeout = 0) {
|
|
171
|
+
if (this._closed) return Promise.reject(new Error("Queue is closed"))
|
|
172
|
+
const task = { uuid: randomUUID(), payload, createdAt: Date.now(), attempts: 0 }
|
|
173
|
+
const result = this._awaitTask(task.uuid, timeout)
|
|
174
|
+
result.catch(() => {})
|
|
175
|
+
return this._redis.lPush("queue:tasks", JSON.stringify(task)).then(() => {
|
|
176
|
+
this._pushed++
|
|
177
|
+
this.emit("new", { task })
|
|
178
|
+
return result
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {string} key
|
|
184
|
+
* @returns {{ push: (payload: any) => Promise<string>, pushAndWait: (payload: any, timeout?: number|string) => Promise<any> }}
|
|
185
|
+
*/
|
|
186
|
+
group(key) {
|
|
187
|
+
const makeTask = (payload) => {
|
|
188
|
+
if (this._closed) throw new Error("Queue is closed")
|
|
189
|
+
return { uuid: randomUUID(), payload, createdAt: Date.now(), groupKey: key, attempts: 0 }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const commit = (task) => {
|
|
193
|
+
return this._redis.lPush(`queue:groups:${key}`, JSON.stringify(task)).then(async () => {
|
|
194
|
+
this._pushed++
|
|
195
|
+
this.emit("new", { task })
|
|
196
|
+
if (!this._groupWorkers.has(key)) {
|
|
197
|
+
this._groupWorkers.set(key, new Map())
|
|
198
|
+
this._groupInFlight.set(key, 0)
|
|
199
|
+
await this._startGroupWorkers(key)
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
push: async (payload) => {
|
|
206
|
+
const task = makeTask(payload)
|
|
207
|
+
await commit(task)
|
|
208
|
+
return task.uuid
|
|
209
|
+
},
|
|
210
|
+
pushAndWait: (payload, timeout = 0) => {
|
|
211
|
+
const task = makeTask(payload)
|
|
212
|
+
const result = this._awaitTask(task.uuid, timeout)
|
|
213
|
+
result.catch(() => {})
|
|
214
|
+
return commit(task).then(() => result)
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** @private */
|
|
220
|
+
_awaitTask(uuid, timeout = 0) {
|
|
221
|
+
const ms_ = ms(timeout)
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
let timer
|
|
224
|
+
|
|
225
|
+
const onComplete = ({ task, result }) => {
|
|
226
|
+
if (task.uuid !== uuid) return
|
|
227
|
+
cleanup()
|
|
228
|
+
resolve(result)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const onFailed = ({ task, error }) => {
|
|
232
|
+
if (task.uuid !== uuid) return
|
|
233
|
+
cleanup()
|
|
234
|
+
reject(error)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const cleanup = () => {
|
|
238
|
+
if (timer) clearTimeout(timer)
|
|
239
|
+
this.off("complete", onComplete)
|
|
240
|
+
this.off("failed", onFailed)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (ms_ > 0) {
|
|
244
|
+
timer = setTimeout(() => {
|
|
245
|
+
cleanup()
|
|
246
|
+
reject(new Error("pushAndWait timed out"))
|
|
247
|
+
}, ms_)
|
|
248
|
+
timer.unref?.()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
this.on("complete", onComplete)
|
|
252
|
+
this.on("failed", onFailed)
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** @returns {Promise<void>} */
|
|
257
|
+
async close() {
|
|
258
|
+
this._closed = true
|
|
259
|
+
await this._readyPromise.catch(() => {})
|
|
260
|
+
|
|
261
|
+
if (this._cleanupTimer) clearInterval(this._cleanupTimer)
|
|
262
|
+
|
|
263
|
+
this._workers.clear()
|
|
264
|
+
for (const groupWorkers of this._groupWorkers.values()) groupWorkers.clear()
|
|
265
|
+
this._groupWorkers.clear()
|
|
266
|
+
|
|
267
|
+
this._localSemaphore.releaseAll()
|
|
268
|
+
|
|
269
|
+
if (this._inFlight > 0) {
|
|
270
|
+
await Promise.race([
|
|
271
|
+
new Promise((resolve) => {
|
|
272
|
+
const check = () => { if (this._inFlight <= 0) resolve() }
|
|
273
|
+
this.on("complete", check)
|
|
274
|
+
this.on("failed", check)
|
|
275
|
+
}),
|
|
276
|
+
new Promise((resolve) => setTimeout(resolve, CLOSE_TIMEOUT)),
|
|
277
|
+
])
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (const [, interval] of this._heartbeats) clearInterval(interval)
|
|
281
|
+
if (this._redis.isOpen && this._activeLeases.size > 0) {
|
|
282
|
+
await Promise.all(
|
|
283
|
+
Array.from(this._activeLeases).map((id) => this._releaseGlobal(id).catch(() => {}))
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
this._heartbeats.clear()
|
|
287
|
+
this._activeLeases.clear()
|
|
288
|
+
|
|
289
|
+
for (const client of this._workerClients) {
|
|
290
|
+
if (client.isOpen) await client.disconnect()
|
|
291
|
+
}
|
|
292
|
+
this._workerClients = []
|
|
293
|
+
if (this._redis.isOpen) await this._redis.quit()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async _initialize() {
|
|
297
|
+
await this._redis.connect()
|
|
298
|
+
await this._startWorkers()
|
|
299
|
+
if (this._options.cleanupInterval > 0) {
|
|
300
|
+
this._cleanupTimer = setInterval(() => this._periodicCleanup(), this._options.cleanupInterval)
|
|
301
|
+
this._cleanupTimer.unref()
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async _createWorkerClient() {
|
|
306
|
+
const client = this._redis.duplicate()
|
|
307
|
+
client.on("error", () => {})
|
|
308
|
+
await client.connect()
|
|
309
|
+
this._workerClients.push(client)
|
|
310
|
+
return client
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async _startWorkers() {
|
|
314
|
+
const ready = []
|
|
315
|
+
for (let i = 0; i < this._options.concurrency; i++) {
|
|
316
|
+
ready.push(this._startWorker(`worker-${i}`))
|
|
317
|
+
}
|
|
318
|
+
await Promise.all(ready)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async _startGroupWorkers(groupKey) {
|
|
322
|
+
const groupWorkers = this._groupWorkers.get(groupKey)
|
|
323
|
+
const ready = []
|
|
324
|
+
for (let i = 0; i < this._options.groups.concurrency; i++) {
|
|
325
|
+
const workerId = `group-${groupKey}-worker-${i}`
|
|
326
|
+
groupWorkers.set(workerId, true)
|
|
327
|
+
ready.push(this._startGroupWorker(workerId, groupKey))
|
|
328
|
+
}
|
|
329
|
+
await Promise.all(ready)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async _startWorker(workerId) {
|
|
333
|
+
this._workers.set(workerId, true)
|
|
334
|
+
const client = await this._createWorkerClient()
|
|
335
|
+
const opts = {
|
|
336
|
+
timeout: this._options.timeout,
|
|
337
|
+
maxRetries: this._options.maxRetries,
|
|
338
|
+
retryKey: "queue:tasks",
|
|
339
|
+
}
|
|
340
|
+
this._runWorkerLoop(workerId, client, "queue:tasks", this._workers, opts)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async _startGroupWorker(workerId, groupKey) {
|
|
344
|
+
const groupWorkers = this._groupWorkers.get(groupKey)
|
|
345
|
+
const client = await this._createWorkerClient()
|
|
346
|
+
const opts = {
|
|
347
|
+
timeout: this._options.groups.timeout,
|
|
348
|
+
maxRetries: this._options.groups.maxRetries,
|
|
349
|
+
retryKey: `queue:groups:${groupKey}`,
|
|
350
|
+
groupKey,
|
|
351
|
+
}
|
|
352
|
+
this._runWorkerLoop(workerId, client, `queue:groups:${groupKey}`, groupWorkers, opts)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async _runWorkerLoop(workerId, client, key, activeMap, opts) {
|
|
356
|
+
const delay = opts.groupKey ? this._options.groups.delay : this._options.delay
|
|
357
|
+
|
|
358
|
+
while (activeMap.get(workerId)) {
|
|
359
|
+
try {
|
|
360
|
+
if (!client.isOpen) break
|
|
361
|
+
const taskData = await client.brPop(key, 1)
|
|
362
|
+
if (!taskData) continue
|
|
363
|
+
|
|
364
|
+
const task = JSON.parse(taskData.element)
|
|
365
|
+
|
|
366
|
+
const localAcquired = await this._localSemaphore.acquire()
|
|
367
|
+
if (!localAcquired) {
|
|
368
|
+
await this._redis.lPush(key, taskData.element).catch(() => {})
|
|
369
|
+
break
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
let leaseId = null
|
|
374
|
+
if (this._options.globalConcurrency > 0) {
|
|
375
|
+
leaseId = await this._acquireGlobal(workerId, activeMap)
|
|
376
|
+
if (!leaseId) {
|
|
377
|
+
await this._redis.lPush(key, taskData.element).catch(() => {})
|
|
378
|
+
break
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
this._inFlight++
|
|
383
|
+
if (opts.groupKey) {
|
|
384
|
+
this._groupInFlight.set(opts.groupKey, (this._groupInFlight.get(opts.groupKey) || 0) + 1)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
await this._processTask(task, opts)
|
|
389
|
+
} finally {
|
|
390
|
+
if (opts.groupKey) {
|
|
391
|
+
const count = (this._groupInFlight.get(opts.groupKey) || 1) - 1
|
|
392
|
+
if (count <= 0) this._groupInFlight.delete(opts.groupKey)
|
|
393
|
+
else this._groupInFlight.set(opts.groupKey, count)
|
|
394
|
+
}
|
|
395
|
+
if (leaseId) await this._releaseGlobal(leaseId).catch(() => {})
|
|
396
|
+
}
|
|
397
|
+
} finally {
|
|
398
|
+
this._localSemaphore.release()
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay))
|
|
402
|
+
} catch {
|
|
403
|
+
if (this._closed || !client.isOpen) break
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (client.isOpen) await client.disconnect().catch(() => {})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async _acquireGlobal(workerId, activeMap) {
|
|
410
|
+
const leaseId = randomUUID()
|
|
411
|
+
while (activeMap.get(workerId) && !this._closed) {
|
|
412
|
+
if (!this._redis.isOpen) return null
|
|
413
|
+
const acquired = await this._redis.eval(ACQUIRE_SCRIPT, {
|
|
414
|
+
keys: ["queue:active"],
|
|
415
|
+
arguments: [String(this._options.globalConcurrency), leaseId, String(LEASE_TTL)],
|
|
416
|
+
})
|
|
417
|
+
if (acquired) {
|
|
418
|
+
this._activeLeases.add(leaseId)
|
|
419
|
+
const heartbeat = setInterval(() => this._renewGlobal(leaseId).catch(() => {}), HEARTBEAT_INTERVAL)
|
|
420
|
+
heartbeat.unref()
|
|
421
|
+
this._heartbeats.set(leaseId, heartbeat)
|
|
422
|
+
return leaseId
|
|
423
|
+
}
|
|
424
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
425
|
+
}
|
|
426
|
+
return null
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async _releaseGlobal(leaseId) {
|
|
430
|
+
this._activeLeases.delete(leaseId)
|
|
431
|
+
const heartbeat = this._heartbeats.get(leaseId)
|
|
432
|
+
if (heartbeat) {
|
|
433
|
+
clearInterval(heartbeat)
|
|
434
|
+
this._heartbeats.delete(leaseId)
|
|
435
|
+
}
|
|
436
|
+
if (this._redis.isOpen) {
|
|
437
|
+
await this._redis.eval(RELEASE_SCRIPT, {
|
|
438
|
+
keys: ["queue:active"],
|
|
439
|
+
arguments: [leaseId],
|
|
440
|
+
})
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async _renewGlobal(leaseId) {
|
|
445
|
+
if (this._redis.isOpen) {
|
|
446
|
+
await this._redis.eval(RENEW_SCRIPT, {
|
|
447
|
+
keys: ["queue:active"],
|
|
448
|
+
arguments: [leaseId],
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async _processTask(task, opts) {
|
|
454
|
+
task.attempts++
|
|
455
|
+
let timer
|
|
456
|
+
let result
|
|
457
|
+
let handlerError
|
|
458
|
+
let succeeded = false
|
|
459
|
+
|
|
460
|
+
if (this._handler) {
|
|
461
|
+
try {
|
|
462
|
+
const timeoutPromise = opts.timeout > 0
|
|
463
|
+
? new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Task timeout")), opts.timeout) })
|
|
464
|
+
: null
|
|
465
|
+
const workPromise = Promise.resolve(this._handler(task.payload, task))
|
|
466
|
+
result = timeoutPromise ? await Promise.race([workPromise, timeoutPromise]) : await workPromise
|
|
467
|
+
succeeded = true
|
|
468
|
+
} catch (err) {
|
|
469
|
+
handlerError = err
|
|
470
|
+
} finally {
|
|
471
|
+
if (timer) clearTimeout(timer)
|
|
472
|
+
}
|
|
473
|
+
} else {
|
|
474
|
+
succeeded = true
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (succeeded) {
|
|
478
|
+
this._settle()
|
|
479
|
+
try { this.emit("complete", { task, result }) } finally { this._emitDrain() }
|
|
480
|
+
} else if (task.attempts < opts.maxRetries && !this._closed) {
|
|
481
|
+
let retried = false
|
|
482
|
+
try {
|
|
483
|
+
await this._redis.lPush(opts.retryKey, JSON.stringify(task))
|
|
484
|
+
retried = true
|
|
485
|
+
} catch {}
|
|
486
|
+
if (retried) {
|
|
487
|
+
this._inFlight--
|
|
488
|
+
this.emit("retry", { task, error: handlerError, attempt: task.attempts })
|
|
489
|
+
} else {
|
|
490
|
+
this._settle()
|
|
491
|
+
try { this.emit("failed", { task, error: handlerError }) } finally { this._emitDrain() }
|
|
492
|
+
}
|
|
493
|
+
} else {
|
|
494
|
+
this._settle()
|
|
495
|
+
try { this.emit("failed", { task, error: handlerError }) } finally { this._emitDrain() }
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
_settle() {
|
|
500
|
+
this._inFlight--
|
|
501
|
+
this._totalSettled++
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
_emitDrain() {
|
|
505
|
+
if (this._inFlight === 0 && this._totalSettled >= this._pushed) this.emit("drain")
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async _periodicCleanup() {
|
|
509
|
+
try {
|
|
510
|
+
if (!this._redis.isOpen) return
|
|
511
|
+
for (const groupKey of Array.from(this._groupWorkers.keys())) {
|
|
512
|
+
if ((this._groupInFlight.get(groupKey) || 0) > 0) continue
|
|
513
|
+
const length = await this._redis.lLen(`queue:groups:${groupKey}`)
|
|
514
|
+
if (length > 0) continue
|
|
515
|
+
if ((this._groupInFlight.get(groupKey) || 0) > 0) continue
|
|
516
|
+
const groupWorkers = this._groupWorkers.get(groupKey)
|
|
517
|
+
if (groupWorkers) {
|
|
518
|
+
groupWorkers.clear()
|
|
519
|
+
this._groupWorkers.delete(groupKey)
|
|
520
|
+
}
|
|
521
|
+
this._groupInFlight.delete(groupKey)
|
|
522
|
+
}
|
|
523
|
+
} catch {}
|
|
524
|
+
}
|
|
525
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./queue.js";
|