@prsm/queue 2.0.0 → 2.1.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.
package/README.md CHANGED
@@ -1,4 +1,8 @@
1
- # @prsm/queue
1
+ <p align="center">
2
+ <img src=".github/logo.svg" width="80" height="80" alt="queue logo">
3
+ </p>
4
+
5
+ <h1 align="center">@prsm/queue</h1>
2
6
 
3
7
  Redis-backed distributed task queue with grouped concurrency, retries, and rate limiting.
4
8
 
@@ -38,13 +42,14 @@ await queue.push({ userId: 123, action: 'sync' })
38
42
 
39
43
  ```js
40
44
  const queue = new Queue({
41
- concurrency: 2, // worker count
45
+ concurrency: 2, // max concurrent tasks per instance
46
+ globalConcurrency: 10, // max concurrent tasks across all instances (Redis-backed)
42
47
  delay: '100ms', // pause between tasks (string or ms)
43
48
  timeout: '30s', // max task duration
44
49
  maxRetries: 3, // attempts before failing
45
50
 
46
51
  groups: {
47
- concurrency: 1, // workers per group
52
+ concurrency: 1, // max concurrent tasks per group
48
53
  delay: '50ms',
49
54
  timeout: '10s',
50
55
  maxRetries: 3
@@ -57,6 +62,41 @@ const queue = new Queue({
57
62
  })
58
63
  ```
59
64
 
65
+ ## Concurrency
66
+
67
+ Three independent limits compose together. A task must pass all applicable gates before processing.
68
+
69
+ **`concurrency`** - per-instance limit. Controls how many tasks this server can process simultaneously. This is the number of worker loops created for the main queue, and also caps total active tasks (including grouped) on this instance via an in-memory semaphore. Default: `1`.
70
+
71
+ **`globalConcurrency`** - cross-instance limit. Controls how many tasks can run across all servers sharing the same Redis. Uses a Redis-backed semaphore with automatic lease expiry for crash safety. If an instance crashes, its slots are reclaimed after 60 seconds. Default: `0` (disabled).
72
+
73
+ **`groups.concurrency`** - per-group limit. Controls how many tasks can run concurrently within a single group. Default: `1`.
74
+
75
+ ### Examples
76
+
77
+ Protect local resources (CPU/memory bound):
78
+
79
+ ```js
80
+ const queue = new Queue({
81
+ concurrency: 5,
82
+ groups: { concurrency: 1 }
83
+ })
84
+ ```
85
+
86
+ 100 groups each with 1 task - only 5 run at a time on this server.
87
+
88
+ Protect an external API (shared rate across servers):
89
+
90
+ ```js
91
+ const queue = new Queue({
92
+ concurrency: 10,
93
+ globalConcurrency: 20,
94
+ groups: { concurrency: 2 }
95
+ })
96
+ ```
97
+
98
+ 3 servers, each can handle 10 concurrent tasks, but only 20 total across all servers. Each group (tenant) gets up to 2 concurrent slots.
99
+
60
100
  ## Process Handler
61
101
 
62
102
  ```js
@@ -70,10 +110,11 @@ Throw an error to trigger retry. After `maxRetries`, the task fails permanently.
70
110
 
71
111
  ## Grouped Queues
72
112
 
73
- Isolated concurrency per key - perfect for per-tenant rate limiting.
113
+ Isolated concurrency per key - perfect for per-tenant throttling.
74
114
 
75
115
  ```js
76
116
  const queue = new Queue({
117
+ concurrency: 5,
77
118
  groups: { concurrency: 1, delay: '50ms' }
78
119
  })
79
120
 
@@ -87,7 +128,7 @@ await queue.group('tenant-123').push({ action: 'sync' })
87
128
  await queue.group('tenant-456').push({ action: 'sync' })
88
129
  ```
89
130
 
90
- Each tenant processes independently. One slow tenant won't block others.
131
+ Each tenant processes independently. One slow tenant won't block others. Total concurrent tasks across all tenants is capped by `concurrency`.
91
132
 
92
133
  ## Events
93
134
 
@@ -111,13 +152,14 @@ queue.on('drain', () => {})
111
152
  }
112
153
  ```
113
154
 
114
- ## Rate Limiting Example
155
+ ## Throttling Example
115
156
 
116
- 20 LLM calls/sec per tenant:
157
+ Throttle LLM calls to external providers per tenant:
117
158
 
118
159
  ```js
119
160
  const queue = new Queue({
120
- groups: { concurrency: 20, delay: '50ms' },
161
+ concurrency: 20,
162
+ groups: { concurrency: 2, delay: '50ms' },
121
163
  maxRetries: 3
122
164
  })
123
165
 
@@ -132,6 +174,8 @@ app.post('/api/generate', async (req, res) => {
132
174
  })
133
175
  ```
134
176
 
177
+ Each tenant gets up to 2 concurrent LLM calls with a 50ms pause between them. Total concurrent calls across all tenants is capped at 20, protecting your server and API budget from any single tenant overwhelming the system.
178
+
135
179
  ## WebSocket Integration with [mesh](https://github.com/nvms/mesh)
136
180
 
137
181
  Queue events are local-only - only the server that processes a task emits `complete`/`failed`. Use [mesh](https://github.com/nvms/mesh) to push results to connected clients in real time.
@@ -143,7 +187,7 @@ import Queue from '@prsm/queue'
143
187
  import { MeshServer } from '@mesh-kit/server'
144
188
 
145
189
  const mesh = new MeshServer({ redis: { host: 'localhost', port: 6379 } })
146
- const queue = new Queue({ groups: { concurrency: 1 } })
190
+ const queue = new Queue({ concurrency: 5, groups: { concurrency: 1 } })
147
191
 
148
192
  queue.process(async (payload) => {
149
193
  return await generateReport(payload)
@@ -173,7 +217,7 @@ Both queue and mesh use the same Redis instance. No key conflicts (`queue:*` vs
173
217
 
174
218
  ## Horizontal Scaling
175
219
 
176
- Multiple servers can push to the same queue. Redis coordinates via atomic operations - no duplicate processing.
220
+ Multiple servers can push to the same queue. Redis coordinates via atomic operations - no duplicate processing. Use `globalConcurrency` to enforce a hard limit across all instances.
177
221
 
178
222
  ## Cleanup
179
223
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prsm/queue",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "description": "Redis-backed distributed task queue with grouped concurrency, retries, and rate limiting",
5
5
  "type": "module",
6
6
  "exports": {
package/src/queue.js CHANGED
@@ -5,7 +5,8 @@ import ms from "@prsm/ms"
5
5
 
6
6
  /**
7
7
  * @typedef {Object} QueueOptions
8
- * @property {number} [concurrency] - worker count (default 1)
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)
9
10
  * @property {number|string} [delay] - pause between tasks, ms or string like "100ms" (default 0)
10
11
  * @property {number|string} [timeout] - max task duration, ms or string like "30s" (default 0, no limit)
11
12
  * @property {number} [maxRetries] - attempts before failing (default 3)
@@ -30,6 +31,69 @@ import ms from "@prsm/ms"
30
31
  * @returns {Promise<any>|any}
31
32
  */
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
+
33
97
  export default class Queue extends EventEmitter {
34
98
  /** @param {QueueOptions} [options] */
35
99
  constructor(options = {}) {
@@ -37,11 +101,12 @@ export default class Queue extends EventEmitter {
37
101
 
38
102
  this._options = {
39
103
  concurrency: options.concurrency ?? 1,
104
+ globalConcurrency: options.globalConcurrency ?? 0,
40
105
  delay: ms(options.delay ?? 0),
41
106
  timeout: ms(options.timeout ?? 0),
42
107
  maxRetries: options.maxRetries ?? 3,
43
108
  groups: {
44
- concurrency: options.groups?.concurrency ?? options.concurrency ?? 1,
109
+ concurrency: options.groups?.concurrency ?? 1,
45
110
  delay: ms(options.groups?.delay ?? options.delay ?? 0),
46
111
  timeout: ms(options.groups?.timeout ?? options.timeout ?? 0),
47
112
  maxRetries: options.groups?.maxRetries ?? options.maxRetries ?? 3,
@@ -53,10 +118,16 @@ export default class Queue extends EventEmitter {
53
118
  this._handler = null
54
119
  this._workers = new Map()
55
120
  this._groupWorkers = new Map()
121
+ this._groupInFlight = new Map()
56
122
  this._workerClients = []
57
123
  this._cleanupTimer = null
58
124
  this._inFlight = 0
125
+ this._pushed = 0
59
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()
60
131
 
61
132
  this._redis = createClient(this._options.redisOptions)
62
133
  this._redis.on("error", () => {})
@@ -83,38 +154,151 @@ export default class Queue extends EventEmitter {
83
154
  * @returns {Promise<string>}
84
155
  */
85
156
  async push(payload) {
157
+ if (this._closed) throw new Error("Queue is closed")
86
158
  const task = { uuid: randomUUID(), payload, createdAt: Date.now(), attempts: 0 }
87
- await this._redis.lPush("queue:tasks", JSON.stringify(task))
159
+ this._pushed++
160
+ try {
161
+ await this._redis.lPush("queue:tasks", JSON.stringify(task))
162
+ } catch (err) {
163
+ this._pushed--
164
+ throw err
165
+ }
88
166
  this.emit("new", { task })
89
167
  return task.uuid
90
168
  }
91
169
 
170
+ /**
171
+ * @param {any} payload
172
+ * @param {number|string} [timeout] - max time to wait for result, ms or string like "30s" (default 0, no limit)
173
+ * @returns {Promise<any>}
174
+ */
175
+ pushAndWait(payload, timeout = 0) {
176
+ if (this._closed) return Promise.reject(new Error("Queue is closed"))
177
+ const task = { uuid: randomUUID(), payload, createdAt: Date.now(), attempts: 0 }
178
+ this._pushed++
179
+ const result = this._awaitTask(task.uuid, timeout)
180
+ result.catch(() => {})
181
+ return this._redis.lPush("queue:tasks", JSON.stringify(task)).then(() => {
182
+ this.emit("new", { task })
183
+ return result
184
+ }, (err) => {
185
+ this._pushed--
186
+ throw err
187
+ })
188
+ }
189
+
92
190
  /**
93
191
  * @param {string} key
94
- * @returns {{ push: (payload: any) => Promise<string> }}
192
+ * @returns {{ push: (payload: any) => Promise<string>, pushAndWait: (payload: any, timeout?: number|string) => Promise<any> }}
95
193
  */
96
194
  group(key) {
97
- return {
98
- push: async (payload) => {
99
- const task = { uuid: randomUUID(), payload, createdAt: Date.now(), groupKey: key, attempts: 0 }
100
- await this._redis.lPush(`queue:groups:${key}`, JSON.stringify(task))
195
+ const makeTask = (payload) => {
196
+ if (this._closed) throw new Error("Queue is closed")
197
+ return { uuid: randomUUID(), payload, createdAt: Date.now(), groupKey: key, attempts: 0 }
198
+ }
199
+
200
+ const commit = (task) => {
201
+ return this._redis.lPush(`queue:groups:${key}`, JSON.stringify(task)).then(async () => {
101
202
  this.emit("new", { task })
102
203
  if (!this._groupWorkers.has(key)) {
103
204
  this._groupWorkers.set(key, new Map())
205
+ this._groupInFlight.set(key, 0)
104
206
  await this._startGroupWorkers(key)
105
207
  }
208
+ }, (err) => {
209
+ this._pushed--
210
+ throw err
211
+ })
212
+ }
213
+
214
+ return {
215
+ push: async (payload) => {
216
+ const task = makeTask(payload)
217
+ this._pushed++
218
+ await commit(task)
106
219
  return task.uuid
107
220
  },
221
+ pushAndWait: (payload, timeout = 0) => {
222
+ const task = makeTask(payload)
223
+ this._pushed++
224
+ const result = this._awaitTask(task.uuid, timeout)
225
+ result.catch(() => {})
226
+ return commit(task).then(() => result)
227
+ },
108
228
  }
109
229
  }
110
230
 
231
+ /** @private */
232
+ _awaitTask(uuid, timeout = 0) {
233
+ const ms_ = ms(timeout)
234
+ return new Promise((resolve, reject) => {
235
+ let timer
236
+
237
+ const onComplete = ({ task, result }) => {
238
+ if (task.uuid !== uuid) return
239
+ cleanup()
240
+ resolve(result)
241
+ }
242
+
243
+ const onFailed = ({ task, error }) => {
244
+ if (task.uuid !== uuid) return
245
+ cleanup()
246
+ reject(error)
247
+ }
248
+
249
+ const cleanup = () => {
250
+ if (timer) clearTimeout(timer)
251
+ this.off("complete", onComplete)
252
+ this.off("failed", onFailed)
253
+ }
254
+
255
+ if (ms_ > 0) {
256
+ timer = setTimeout(() => {
257
+ cleanup()
258
+ reject(new Error("pushAndWait timed out"))
259
+ }, ms_)
260
+ timer.unref?.()
261
+ }
262
+
263
+ this.on("complete", onComplete)
264
+ this.on("failed", onFailed)
265
+ })
266
+ }
267
+
111
268
  /** @returns {Promise<void>} */
112
269
  async close() {
270
+ this._closed = true
113
271
  await this._readyPromise.catch(() => {})
272
+
114
273
  if (this._cleanupTimer) clearInterval(this._cleanupTimer)
274
+ clearTimeout(this._drainTimer)
275
+
115
276
  this._workers.clear()
116
277
  for (const groupWorkers of this._groupWorkers.values()) groupWorkers.clear()
117
278
  this._groupWorkers.clear()
279
+
280
+ this._localSemaphore.releaseAll()
281
+
282
+ if (this._inFlight > 0) {
283
+ await Promise.race([
284
+ new Promise((resolve) => {
285
+ const check = () => { if (this._inFlight <= 0) resolve() }
286
+ this.on("complete", check)
287
+ this.on("failed", check)
288
+ }),
289
+ new Promise((resolve) => setTimeout(resolve, CLOSE_TIMEOUT)),
290
+ ])
291
+ }
292
+
293
+ for (const [, interval] of this._heartbeats) clearInterval(interval)
294
+ if (this._redis.isOpen && this._activeLeases.size > 0) {
295
+ await Promise.all(
296
+ Array.from(this._activeLeases).map((id) => this._releaseGlobal(id).catch(() => {}))
297
+ )
298
+ }
299
+ this._heartbeats.clear()
300
+ this._activeLeases.clear()
301
+
118
302
  for (const client of this._workerClients) {
119
303
  if (client.isOpen) await client.disconnect()
120
304
  }
@@ -161,110 +345,196 @@ export default class Queue extends EventEmitter {
161
345
  async _startWorker(workerId) {
162
346
  this._workers.set(workerId, true)
163
347
  const client = await this._createWorkerClient()
164
- this._runWorkerLoop(workerId, client, "queue:tasks", this._workers, (task) => this._processTask(task))
348
+ const opts = {
349
+ timeout: this._options.timeout,
350
+ maxRetries: this._options.maxRetries,
351
+ retryKey: "queue:tasks",
352
+ }
353
+ this._runWorkerLoop(workerId, client, "queue:tasks", this._workers, opts)
165
354
  }
166
355
 
167
356
  async _startGroupWorker(workerId, groupKey) {
168
357
  const groupWorkers = this._groupWorkers.get(groupKey)
169
358
  const client = await this._createWorkerClient()
170
- this._runWorkerLoop(workerId, client, `queue:groups:${groupKey}`, groupWorkers, (task) => this._processGroupTask(task))
359
+ const opts = {
360
+ timeout: this._options.groups.timeout,
361
+ maxRetries: this._options.groups.maxRetries,
362
+ retryKey: `queue:groups:${groupKey}`,
363
+ groupKey,
364
+ }
365
+ this._runWorkerLoop(workerId, client, `queue:groups:${groupKey}`, groupWorkers, opts)
171
366
  }
172
367
 
173
- async _runWorkerLoop(workerId, client, key, activeMap, processFn) {
174
- const isGrouped = key.startsWith("queue:groups:")
175
- const delay = isGrouped ? this._options.groups.delay : this._options.delay
368
+ async _runWorkerLoop(workerId, client, key, activeMap, opts) {
369
+ const delay = opts.groupKey ? this._options.groups.delay : this._options.delay
176
370
 
177
371
  while (activeMap.get(workerId)) {
178
372
  try {
179
373
  if (!client.isOpen) break
180
374
  const taskData = await client.brPop(key, 1)
181
- if (taskData) {
182
- const task = JSON.parse(taskData.element)
375
+ if (!taskData) continue
376
+
377
+ const task = JSON.parse(taskData.element)
378
+
379
+ const localAcquired = await this._localSemaphore.acquire()
380
+ if (!localAcquired) {
381
+ await this._redis.lPush(key, taskData.element).catch(() => {})
382
+ break
383
+ }
384
+
385
+ try {
386
+ let leaseId = null
387
+ if (this._options.globalConcurrency > 0) {
388
+ leaseId = await this._acquireGlobal(workerId, activeMap)
389
+ if (!leaseId) {
390
+ await this._redis.lPush(key, taskData.element).catch(() => {})
391
+ break
392
+ }
393
+ }
394
+
183
395
  this._inFlight++
184
- await processFn(task)
396
+ if (opts.groupKey) {
397
+ this._groupInFlight.set(opts.groupKey, (this._groupInFlight.get(opts.groupKey) || 0) + 1)
398
+ }
399
+
400
+ try {
401
+ await this._processTask(task, opts)
402
+ } finally {
403
+ if (opts.groupKey) {
404
+ const count = (this._groupInFlight.get(opts.groupKey) || 1) - 1
405
+ if (count <= 0) this._groupInFlight.delete(opts.groupKey)
406
+ else this._groupInFlight.set(opts.groupKey, count)
407
+ }
408
+ if (leaseId) await this._releaseGlobal(leaseId).catch(() => {})
409
+ }
410
+ } finally {
411
+ this._localSemaphore.release()
185
412
  }
413
+
186
414
  if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay))
187
- } catch (err) {
188
- if (err.message?.includes("closed") || err.message?.includes("ClientClosedError")) break
415
+ } catch {
416
+ if (this._closed || !client.isOpen) break
189
417
  }
190
418
  }
419
+ if (client.isOpen) await client.disconnect().catch(() => {})
191
420
  }
192
421
 
193
- async _processTask(task) {
194
- task.attempts++
195
- try {
196
- if (!this._handler) {
197
- this.emit("complete", { task, result: undefined })
198
- this._settle()
199
- return
200
- }
201
- const timeoutPromise = this._options.timeout > 0
202
- ? new Promise((_, reject) => setTimeout(() => reject(new Error("Task timeout")), this._options.timeout))
203
- : null
204
- const workPromise = Promise.resolve(this._handler(task.payload, task))
205
- const result = timeoutPromise ? await Promise.race([workPromise, timeoutPromise]) : await workPromise
206
- this.emit("complete", { task, result })
207
- this._settle()
208
- } catch (error) {
209
- if (task.attempts < this._options.maxRetries) {
210
- this.emit("retry", { task, error, attempt: task.attempts })
211
- this._inFlight--
212
- await this._redis.lPush("queue:tasks", JSON.stringify(task))
213
- } else {
214
- this.emit("failed", { task, error })
215
- this._settle()
422
+ async _acquireGlobal(workerId, activeMap) {
423
+ const leaseId = randomUUID()
424
+ while (activeMap.get(workerId) && !this._closed) {
425
+ if (!this._redis.isOpen) return null
426
+ const acquired = await this._redis.eval(ACQUIRE_SCRIPT, {
427
+ keys: ["queue:active"],
428
+ arguments: [String(this._options.globalConcurrency), leaseId, String(LEASE_TTL)],
429
+ })
430
+ if (acquired) {
431
+ this._activeLeases.add(leaseId)
432
+ const heartbeat = setInterval(() => this._renewGlobal(leaseId).catch(() => {}), HEARTBEAT_INTERVAL)
433
+ heartbeat.unref()
434
+ this._heartbeats.set(leaseId, heartbeat)
435
+ return leaseId
216
436
  }
437
+ await new Promise((r) => setTimeout(r, 50))
217
438
  }
439
+ return null
218
440
  }
219
441
 
220
- async _processGroupTask(task) {
442
+ async _releaseGlobal(leaseId) {
443
+ this._activeLeases.delete(leaseId)
444
+ const heartbeat = this._heartbeats.get(leaseId)
445
+ if (heartbeat) {
446
+ clearInterval(heartbeat)
447
+ this._heartbeats.delete(leaseId)
448
+ }
449
+ if (this._redis.isOpen) {
450
+ await this._redis.eval(RELEASE_SCRIPT, {
451
+ keys: ["queue:active"],
452
+ arguments: [leaseId],
453
+ })
454
+ }
455
+ }
456
+
457
+ async _renewGlobal(leaseId) {
458
+ if (this._redis.isOpen) {
459
+ await this._redis.eval(RENEW_SCRIPT, {
460
+ keys: ["queue:active"],
461
+ arguments: [leaseId],
462
+ })
463
+ }
464
+ }
465
+
466
+ async _processTask(task, opts) {
221
467
  task.attempts++
222
- try {
223
- if (!this._handler) {
224
- this.emit("complete", { task, result: undefined })
225
- this._settle()
226
- return
468
+ let timer
469
+ let result
470
+ let handlerError
471
+ let succeeded = false
472
+
473
+ if (this._handler) {
474
+ try {
475
+ const timeoutPromise = opts.timeout > 0
476
+ ? new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Task timeout")), opts.timeout) })
477
+ : null
478
+ const workPromise = Promise.resolve(this._handler(task.payload, task))
479
+ result = timeoutPromise ? await Promise.race([workPromise, timeoutPromise]) : await workPromise
480
+ succeeded = true
481
+ } catch (err) {
482
+ handlerError = err
483
+ } finally {
484
+ if (timer) clearTimeout(timer)
227
485
  }
228
- const timeoutPromise = this._options.groups.timeout > 0
229
- ? new Promise((_, reject) => setTimeout(() => reject(new Error("Task timeout")), this._options.groups.timeout))
230
- : null
231
- const workPromise = Promise.resolve(this._handler(task.payload, task))
232
- const result = timeoutPromise ? await Promise.race([workPromise, timeoutPromise]) : await workPromise
233
- this.emit("complete", { task, result })
486
+ } else {
487
+ succeeded = true
488
+ }
489
+
490
+ if (succeeded) {
234
491
  this._settle()
235
- } catch (error) {
236
- if (task.attempts < this._options.groups.maxRetries) {
237
- this.emit("retry", { task, error, attempt: task.attempts })
492
+ try { this.emit("complete", { task, result }) } finally { this._emitDrain() }
493
+ } else if (task.attempts < opts.maxRetries && !this._closed) {
494
+ let retried = false
495
+ try {
496
+ await this._redis.lPush(opts.retryKey, JSON.stringify(task))
497
+ retried = true
498
+ } catch {}
499
+ if (retried) {
238
500
  this._inFlight--
239
- await this._redis.lPush(`queue:groups:${task.groupKey}`, JSON.stringify(task))
501
+ this.emit("retry", { task, error: handlerError, attempt: task.attempts })
240
502
  } else {
241
- this.emit("failed", { task, error })
242
503
  this._settle()
504
+ try { this.emit("failed", { task, error: handlerError }) } finally { this._emitDrain() }
243
505
  }
506
+ } else {
507
+ this._settle()
508
+ try { this.emit("failed", { task, error: handlerError }) } finally { this._emitDrain() }
244
509
  }
245
510
  }
246
511
 
247
512
  _settle() {
248
513
  this._inFlight--
249
514
  this._totalSettled++
250
- if (this._inFlight === 0 && this._totalSettled > 0) this.emit("drain")
515
+ }
516
+
517
+ _emitDrain() {
518
+ clearTimeout(this._drainTimer)
519
+ this._drainTimer = setTimeout(() => {
520
+ if (this._inFlight === 0 && this._totalSettled >= this._pushed) this.emit("drain")
521
+ }, 0)
251
522
  }
252
523
 
253
524
  async _periodicCleanup() {
254
525
  try {
255
526
  if (!this._redis.isOpen) return
256
- const groupKeys = Array.from(this._groupWorkers.keys())
257
- for (const groupKey of groupKeys) {
527
+ for (const groupKey of Array.from(this._groupWorkers.keys())) {
528
+ if ((this._groupInFlight.get(groupKey) || 0) > 0) continue
258
529
  const length = await this._redis.lLen(`queue:groups:${groupKey}`)
259
- if (length === 0) {
260
- const keyExists = await this._redis.exists(`queue:groups:${groupKey}`)
261
- if (keyExists) await this._redis.del(`queue:groups:${groupKey}`)
262
- const groupWorkers = this._groupWorkers.get(groupKey)
263
- if (groupWorkers) {
264
- groupWorkers.clear()
265
- this._groupWorkers.delete(groupKey)
266
- }
530
+ if (length > 0) continue
531
+ if ((this._groupInFlight.get(groupKey) || 0) > 0) continue
532
+ const groupWorkers = this._groupWorkers.get(groupKey)
533
+ if (groupWorkers) {
534
+ groupWorkers.clear()
535
+ this._groupWorkers.delete(groupKey)
267
536
  }
537
+ this._groupInFlight.delete(groupKey)
268
538
  }
269
539
  } catch {}
270
540
  }
package/types/queue.d.ts CHANGED
@@ -1,32 +1,9 @@
1
- /**
2
- * @typedef {Object} QueueOptions
3
- * @property {number} [concurrency] - worker count (default 1)
4
- * @property {number|string} [delay] - pause between tasks, ms or string like "100ms" (default 0)
5
- * @property {number|string} [timeout] - max task duration, ms or string like "30s" (default 0, no limit)
6
- * @property {number} [maxRetries] - attempts before failing (default 3)
7
- * @property {{concurrency?: number, delay?: number|string, timeout?: number|string, maxRetries?: number}} [groups] - overrides for grouped queues
8
- * @property {{url?: string, host?: string, port?: number, password?: string}} [redisOptions]
9
- * @property {number} [cleanupInterval] - ms between empty group cleanup (default 30000, 0 to disable)
10
- */
11
- /**
12
- * @typedef {Object} Task
13
- * @property {string} uuid
14
- * @property {any} payload
15
- * @property {number} createdAt
16
- * @property {string} [groupKey]
17
- * @property {number} attempts
18
- */
19
- /**
20
- * @callback TaskHandler
21
- * @param {any} payload
22
- * @param {Task} task
23
- * @returns {Promise<any>|any}
24
- */
25
1
  export default class Queue extends EventEmitter<[never]> {
26
2
  /** @param {QueueOptions} [options] */
27
3
  constructor(options?: QueueOptions);
28
4
  _options: {
29
5
  concurrency: number;
6
+ globalConcurrency: number;
30
7
  delay: any;
31
8
  timeout: any;
32
9
  maxRetries: number;
@@ -47,10 +24,16 @@ export default class Queue extends EventEmitter<[never]> {
47
24
  _handler: TaskHandler;
48
25
  _workers: Map<any, any>;
49
26
  _groupWorkers: Map<any, any>;
27
+ _groupInFlight: Map<any, any>;
50
28
  _workerClients: any[];
51
29
  _cleanupTimer: NodeJS.Timeout;
52
30
  _inFlight: number;
31
+ _pushed: number;
53
32
  _totalSettled: number;
33
+ _closed: boolean;
34
+ _localSemaphore: LocalSemaphore;
35
+ _activeLeases: Set<any>;
36
+ _heartbeats: Map<any, any>;
54
37
  _redis: import("@redis/client").RedisClientType<{
55
38
  json: {
56
39
  ARRAPPEND: {
@@ -2377,13 +2360,22 @@ export default class Queue extends EventEmitter<[never]> {
2377
2360
  * @returns {Promise<string>}
2378
2361
  */
2379
2362
  push(payload: any): Promise<string>;
2363
+ /**
2364
+ * @param {any} payload
2365
+ * @param {number|string} [timeout] - max time to wait for result, ms or string like "30s" (default 0, no limit)
2366
+ * @returns {Promise<any>}
2367
+ */
2368
+ pushAndWait(payload: any, timeout?: number | string): Promise<any>;
2380
2369
  /**
2381
2370
  * @param {string} key
2382
- * @returns {{ push: (payload: any) => Promise<string> }}
2371
+ * @returns {{ push: (payload: any) => Promise<string>, pushAndWait: (payload: any, timeout?: number|string) => Promise<any> }}
2383
2372
  */
2384
2373
  group(key: string): {
2385
2374
  push: (payload: any) => Promise<string>;
2375
+ pushAndWait: (payload: any, timeout?: number | string) => Promise<any>;
2386
2376
  };
2377
+ /** @private */
2378
+ private _awaitTask;
2387
2379
  /** @returns {Promise<void>} */
2388
2380
  close(): Promise<void>;
2389
2381
  _initialize(): Promise<void>;
@@ -4705,17 +4697,25 @@ export default class Queue extends EventEmitter<[never]> {
4705
4697
  _startGroupWorkers(groupKey: any): Promise<void>;
4706
4698
  _startWorker(workerId: any): Promise<void>;
4707
4699
  _startGroupWorker(workerId: any, groupKey: any): Promise<void>;
4708
- _runWorkerLoop(workerId: any, client: any, key: any, activeMap: any, processFn: any): Promise<void>;
4709
- _processTask(task: any): Promise<void>;
4710
- _processGroupTask(task: any): Promise<void>;
4700
+ _runWorkerLoop(workerId: any, client: any, key: any, activeMap: any, opts: any): Promise<void>;
4701
+ _acquireGlobal(workerId: any, activeMap: any): Promise<`${string}-${string}-${string}-${string}-${string}`>;
4702
+ _releaseGlobal(leaseId: any): Promise<void>;
4703
+ _renewGlobal(leaseId: any): Promise<void>;
4704
+ _processTask(task: any, opts: any): Promise<void>;
4711
4705
  _settle(): void;
4706
+ _emitDrain(): void;
4707
+ _drainTimer: NodeJS.Timeout;
4712
4708
  _periodicCleanup(): Promise<void>;
4713
4709
  }
4714
4710
  export type QueueOptions = {
4715
4711
  /**
4716
- * - worker count (default 1)
4712
+ * - max concurrent tasks per instance (default 1)
4717
4713
  */
4718
4714
  concurrency?: number;
4715
+ /**
4716
+ * - max concurrent tasks across all instances, Redis-backed (default 0, disabled)
4717
+ */
4718
+ globalConcurrency?: number;
4719
4719
  /**
4720
4720
  * - pause between tasks, ms or string like "100ms" (default 0)
4721
4721
  */
@@ -4757,3 +4757,13 @@ export type Task = {
4757
4757
  };
4758
4758
  export type TaskHandler = (payload: any, task: Task) => Promise<any> | any;
4759
4759
  import { EventEmitter } from "events";
4760
+ declare class LocalSemaphore {
4761
+ constructor(max: any);
4762
+ _max: any;
4763
+ _current: number;
4764
+ _waiting: any[];
4765
+ acquire(): Promise<any>;
4766
+ release(): void;
4767
+ releaseAll(): void;
4768
+ }
4769
+ export {};