@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/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
+ }
@@ -0,0 +1 @@
1
+ export { default } from "./queue.js";