@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 +54 -10
- package/package.json +1 -1
- package/src/queue.js +322 -68
- package/types/queue.d.ts +38 -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,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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
174
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
188
|
-
if (
|
|
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
|
|
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()
|
|
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
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
this.emit("complete", { task, result })
|
|
473
|
+
} else {
|
|
474
|
+
succeeded = true
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (succeeded) {
|
|
234
478
|
this._settle()
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
257
|
-
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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,
|
|
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;
|
|
4712
4707
|
_periodicCleanup(): Promise<void>;
|
|
4713
4708
|
}
|
|
4714
4709
|
export type QueueOptions = {
|
|
4715
4710
|
/**
|
|
4716
|
-
* -
|
|
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 {};
|