@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 +54 -10
- package/package.json +1 -1
- package/src/queue.js +339 -69
- package/types/queue.d.ts +39 -29
package/README.md
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
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, //
|
|
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, //
|
|
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
|
|
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
|
-
##
|
|
155
|
+
## Throttling Example
|
|
115
156
|
|
|
116
|
-
|
|
157
|
+
Throttle LLM calls to external providers per tenant:
|
|
117
158
|
|
|
118
159
|
```js
|
|
119
160
|
const queue = new Queue({
|
|
120
|
-
|
|
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
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] -
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
174
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
188
|
-
if (
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (!this.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
this.emit("complete", { task, result })
|
|
486
|
+
} else {
|
|
487
|
+
succeeded = true
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (succeeded) {
|
|
234
491
|
this._settle()
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
257
|
-
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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,
|
|
4709
|
-
|
|
4710
|
-
|
|
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
|
-
* -
|
|
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 {};
|