@prsm/queue 2.0.0 → 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 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.0",
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,138 @@ 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
159
  await this._redis.lPush("queue:tasks", JSON.stringify(task))
160
+ this._pushed++
88
161
  this.emit("new", { task })
89
162
  return task.uuid
90
163
  }
91
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
+
92
182
  /**
93
183
  * @param {string} key
94
- * @returns {{ push: (payload: any) => Promise<string> }}
184
+ * @returns {{ push: (payload: any) => Promise<string>, pushAndWait: (payload: any, timeout?: number|string) => Promise<any> }}
95
185
  */
96
186
  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))
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++
101
195
  this.emit("new", { task })
102
196
  if (!this._groupWorkers.has(key)) {
103
197
  this._groupWorkers.set(key, new Map())
198
+ this._groupInFlight.set(key, 0)
104
199
  await this._startGroupWorkers(key)
105
200
  }
201
+ })
202
+ }
203
+
204
+ return {
205
+ push: async (payload) => {
206
+ const task = makeTask(payload)
207
+ await commit(task)
106
208
  return task.uuid
107
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
+ },
108
216
  }
109
217
  }
110
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
+
111
256
  /** @returns {Promise<void>} */
112
257
  async close() {
258
+ this._closed = true
113
259
  await this._readyPromise.catch(() => {})
260
+
114
261
  if (this._cleanupTimer) clearInterval(this._cleanupTimer)
262
+
115
263
  this._workers.clear()
116
264
  for (const groupWorkers of this._groupWorkers.values()) groupWorkers.clear()
117
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
+
118
289
  for (const client of this._workerClients) {
119
290
  if (client.isOpen) await client.disconnect()
120
291
  }
@@ -161,110 +332,193 @@ export default class Queue extends EventEmitter {
161
332
  async _startWorker(workerId) {
162
333
  this._workers.set(workerId, true)
163
334
  const client = await this._createWorkerClient()
164
- this._runWorkerLoop(workerId, client, "queue:tasks", this._workers, (task) => this._processTask(task))
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)
165
341
  }
166
342
 
167
343
  async _startGroupWorker(workerId, groupKey) {
168
344
  const groupWorkers = this._groupWorkers.get(groupKey)
169
345
  const client = await this._createWorkerClient()
170
- this._runWorkerLoop(workerId, client, `queue:groups:${groupKey}`, groupWorkers, (task) => this._processGroupTask(task))
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)
171
353
  }
172
354
 
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
355
+ async _runWorkerLoop(workerId, client, key, activeMap, opts) {
356
+ const delay = opts.groupKey ? this._options.groups.delay : this._options.delay
176
357
 
177
358
  while (activeMap.get(workerId)) {
178
359
  try {
179
360
  if (!client.isOpen) break
180
361
  const taskData = await client.brPop(key, 1)
181
- if (taskData) {
182
- const task = JSON.parse(taskData.element)
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
+
183
382
  this._inFlight++
184
- await processFn(task)
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()
185
399
  }
400
+
186
401
  if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay))
187
- } catch (err) {
188
- if (err.message?.includes("closed") || err.message?.includes("ClientClosedError")) break
402
+ } catch {
403
+ if (this._closed || !client.isOpen) break
189
404
  }
190
405
  }
406
+ if (client.isOpen) await client.disconnect().catch(() => {})
191
407
  }
192
408
 
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()
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
216
423
  }
424
+ await new Promise((r) => setTimeout(r, 50))
217
425
  }
426
+ return null
218
427
  }
219
428
 
220
- async _processGroupTask(task) {
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) {
221
454
  task.attempts++
222
- try {
223
- if (!this._handler) {
224
- this.emit("complete", { task, result: undefined })
225
- this._settle()
226
- return
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)
227
472
  }
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 })
473
+ } else {
474
+ succeeded = true
475
+ }
476
+
477
+ if (succeeded) {
234
478
  this._settle()
235
- } catch (error) {
236
- if (task.attempts < this._options.groups.maxRetries) {
237
- this.emit("retry", { task, error, attempt: task.attempts })
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) {
238
487
  this._inFlight--
239
- await this._redis.lPush(`queue:groups:${task.groupKey}`, JSON.stringify(task))
488
+ this.emit("retry", { task, error: handlerError, attempt: task.attempts })
240
489
  } else {
241
- this.emit("failed", { task, error })
242
490
  this._settle()
491
+ try { this.emit("failed", { task, error: handlerError }) } finally { this._emitDrain() }
243
492
  }
493
+ } else {
494
+ this._settle()
495
+ try { this.emit("failed", { task, error: handlerError }) } finally { this._emitDrain() }
244
496
  }
245
497
  }
246
498
 
247
499
  _settle() {
248
500
  this._inFlight--
249
501
  this._totalSettled++
250
- if (this._inFlight === 0 && this._totalSettled > 0) this.emit("drain")
502
+ }
503
+
504
+ _emitDrain() {
505
+ if (this._inFlight === 0 && this._totalSettled >= this._pushed) this.emit("drain")
251
506
  }
252
507
 
253
508
  async _periodicCleanup() {
254
509
  try {
255
510
  if (!this._redis.isOpen) return
256
- const groupKeys = Array.from(this._groupWorkers.keys())
257
- for (const groupKey of groupKeys) {
511
+ for (const groupKey of Array.from(this._groupWorkers.keys())) {
512
+ if ((this._groupInFlight.get(groupKey) || 0) > 0) continue
258
513
  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
- }
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)
267
520
  }
521
+ this._groupInFlight.delete(groupKey)
268
522
  }
269
523
  } catch {}
270
524
  }
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,24 @@ 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;
4712
4707
  _periodicCleanup(): Promise<void>;
4713
4708
  }
4714
4709
  export type QueueOptions = {
4715
4710
  /**
4716
- * - worker count (default 1)
4711
+ * - max concurrent tasks per instance (default 1)
4717
4712
  */
4718
4713
  concurrency?: number;
4714
+ /**
4715
+ * - max concurrent tasks across all instances, Redis-backed (default 0, disabled)
4716
+ */
4717
+ globalConcurrency?: number;
4719
4718
  /**
4720
4719
  * - pause between tasks, ms or string like "100ms" (default 0)
4721
4720
  */
@@ -4757,3 +4756,13 @@ export type Task = {
4757
4756
  };
4758
4757
  export type TaskHandler = (payload: any, task: Task) => Promise<any> | any;
4759
4758
  import { EventEmitter } from "events";
4759
+ declare class LocalSemaphore {
4760
+ constructor(max: any);
4761
+ _max: any;
4762
+ _current: number;
4763
+ _waiting: any[];
4764
+ acquire(): Promise<any>;
4765
+ release(): void;
4766
+ releaseAll(): void;
4767
+ }
4768
+ export {};