@prsm/queue 3.0.0 → 3.0.2
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 +69 -11
- package/package.json +1 -1
- package/src/queue.js +114 -36
- package/types/queue.d.ts +4637 -1
package/README.md
CHANGED
|
@@ -130,6 +130,27 @@ await queue.push({ action: 'sync' }, { group: 'tenant-456' })
|
|
|
130
130
|
|
|
131
131
|
Each tenant processes independently. One slow tenant won't block others. Total concurrent tasks across all tenants is capped by `concurrency`. When the group is conditional, just omit the option - no branching needed.
|
|
132
132
|
|
|
133
|
+
Groups are fully distributed - any instance can push to any group, and any instance with available concurrency will automatically discover and process tasks for that group. New groups are announced via Redis pub/sub, and existing groups are discovered at startup.
|
|
134
|
+
|
|
135
|
+
## Push and Wait
|
|
136
|
+
|
|
137
|
+
Push a task and wait for its result. Works across instances - instance A can push a task that instance B processes, and the result comes back to instance A via Redis pub/sub.
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
const result = await queue.pushAndWait({ prompt: 'summarize this' })
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
With timeout and groups:
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
const result = await queue.pushAndWait(
|
|
147
|
+
{ prompt: 'summarize this' },
|
|
148
|
+
{ group: 'tenant-123', timeout: '30s' }
|
|
149
|
+
)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Throws if the task fails (after retries are exhausted) or if the timeout is reached. Retries are transparent - if the handler fails twice then succeeds on the third attempt, `pushAndWait` resolves with the successful result.
|
|
153
|
+
|
|
133
154
|
## Events
|
|
134
155
|
|
|
135
156
|
```js
|
|
@@ -147,7 +168,7 @@ queue.on('drain', () => {})
|
|
|
147
168
|
uuid: string,
|
|
148
169
|
payload: any,
|
|
149
170
|
createdAt: number,
|
|
150
|
-
|
|
171
|
+
group?: string, // present when pushed with { group }
|
|
151
172
|
attempts: number
|
|
152
173
|
}
|
|
153
174
|
```
|
|
@@ -176,17 +197,54 @@ app.post('/api/generate', async (req, res) => {
|
|
|
176
197
|
|
|
177
198
|
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
199
|
|
|
179
|
-
##
|
|
200
|
+
## Fan-out with Groups
|
|
201
|
+
|
|
202
|
+
Use groups to fan out a single event to multiple independent handlers. Each group processes and retries independently.
|
|
203
|
+
|
|
204
|
+
```js
|
|
205
|
+
const queue = new Queue({
|
|
206
|
+
concurrency: 10,
|
|
207
|
+
groups: { concurrency: 1 },
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const handlers = {
|
|
211
|
+
"email": {
|
|
212
|
+
"user:created": (data) => sendWelcomeEmail(data.email),
|
|
213
|
+
"user:deleted": (data) => sendGoodbyeEmail(data.email),
|
|
214
|
+
},
|
|
215
|
+
"workspace": {
|
|
216
|
+
"user:created": (data) => createDefaultWorkspace(data.userId),
|
|
217
|
+
},
|
|
218
|
+
"slack": {
|
|
219
|
+
"user:created": (data) => notifySlack(`new user: ${data.email}`),
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
queue.process(async ({ event, data }, task) => {
|
|
224
|
+
await handlers[task.group]?.[event]?.(data)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
await queue.ready()
|
|
228
|
+
|
|
229
|
+
// emit to all groups
|
|
230
|
+
await Promise.all(
|
|
231
|
+
Object.keys(handlers).map((group) =>
|
|
232
|
+
queue.push({ event: "user:created", data: { userId: "u1", email: "a@b.com" } }, { group })
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The `handlers` object is both the registry and the routing logic. Adding a new subscriber is adding a key.
|
|
180
238
|
|
|
181
|
-
|
|
239
|
+
## WebSocket Integration
|
|
182
240
|
|
|
183
|
-
|
|
241
|
+
Queue events are local-only - only the server that processes a task emits `complete`/`failed`. Use [@prsm/realtime](https://github.com/nvms/realtime) to push results to connected clients in real time.
|
|
184
242
|
|
|
185
243
|
```js
|
|
186
244
|
import Queue from '@prsm/queue'
|
|
187
|
-
import {
|
|
245
|
+
import { RealtimeServer } from '@prsm/realtime'
|
|
188
246
|
|
|
189
|
-
const
|
|
247
|
+
const realtime = new RealtimeServer({ redis: { host: 'localhost', port: 6379 } })
|
|
190
248
|
const queue = new Queue({ concurrency: 5, groups: { concurrency: 1 } })
|
|
191
249
|
|
|
192
250
|
queue.process(async (payload) => {
|
|
@@ -194,14 +252,14 @@ queue.process(async (payload) => {
|
|
|
194
252
|
})
|
|
195
253
|
|
|
196
254
|
queue.on('complete', ({ task, result }) => {
|
|
197
|
-
|
|
255
|
+
realtime.sendTo(task.payload.connectionId, 'job:complete', result)
|
|
198
256
|
})
|
|
199
257
|
|
|
200
258
|
queue.on('failed', ({ task, error }) => {
|
|
201
|
-
|
|
259
|
+
realtime.sendTo(task.payload.connectionId, 'job:failed', { error: error.message })
|
|
202
260
|
})
|
|
203
261
|
|
|
204
|
-
|
|
262
|
+
realtime.exposeCommand('generate-report', async (ctx) => {
|
|
205
263
|
const taskId = await queue.push({
|
|
206
264
|
connectionId: ctx.connection.id,
|
|
207
265
|
...ctx.payload,
|
|
@@ -210,10 +268,10 @@ mesh.exposeCommand('generate-report', async (ctx) => {
|
|
|
210
268
|
})
|
|
211
269
|
|
|
212
270
|
await queue.ready()
|
|
213
|
-
await
|
|
271
|
+
await realtime.listen(8080)
|
|
214
272
|
```
|
|
215
273
|
|
|
216
|
-
Both queue and
|
|
274
|
+
Both queue and realtime use the same Redis instance. No key conflicts (`queue:*` vs `rt:*`).
|
|
217
275
|
|
|
218
276
|
## Horizontal Scaling
|
|
219
277
|
|
package/package.json
CHANGED
package/src/queue.js
CHANGED
|
@@ -20,7 +20,7 @@ import ms from "@prsm/ms"
|
|
|
20
20
|
* @property {string} uuid
|
|
21
21
|
* @property {any} payload
|
|
22
22
|
* @property {number} createdAt
|
|
23
|
-
* @property {string} [
|
|
23
|
+
* @property {string} [group]
|
|
24
24
|
* @property {number} attempts
|
|
25
25
|
*/
|
|
26
26
|
|
|
@@ -131,6 +131,9 @@ export default class Queue extends EventEmitter {
|
|
|
131
131
|
|
|
132
132
|
this._redis = createClient(this._options.redisOptions)
|
|
133
133
|
this._redis.on("error", () => {})
|
|
134
|
+
this._subClient = null
|
|
135
|
+
this._groupNotifyClient = null
|
|
136
|
+
this._pendingWaits = new Map()
|
|
134
137
|
this._readyPromise = this._initialize()
|
|
135
138
|
}
|
|
136
139
|
|
|
@@ -157,7 +160,7 @@ export default class Queue extends EventEmitter {
|
|
|
157
160
|
async push(payload, { group } = {}) {
|
|
158
161
|
if (this._closed) throw new Error("Queue is closed")
|
|
159
162
|
const task = group
|
|
160
|
-
? { uuid: randomUUID(), payload, createdAt: Date.now(),
|
|
163
|
+
? { uuid: randomUUID(), payload, createdAt: Date.now(), group, attempts: 0 }
|
|
161
164
|
: { uuid: randomUUID(), payload, createdAt: Date.now(), attempts: 0 }
|
|
162
165
|
this._pushed++
|
|
163
166
|
try {
|
|
@@ -175,72 +178,112 @@ export default class Queue extends EventEmitter {
|
|
|
175
178
|
* @param {{ group?: string, timeout?: number|string }} [options]
|
|
176
179
|
* @returns {Promise<any>}
|
|
177
180
|
*/
|
|
178
|
-
pushAndWait(payload, { group, timeout = 0 } = {}) {
|
|
179
|
-
if (this._closed)
|
|
181
|
+
async pushAndWait(payload, { group, timeout = 0 } = {}) {
|
|
182
|
+
if (this._closed) throw new Error("Queue is closed")
|
|
180
183
|
const task = group
|
|
181
|
-
? { uuid: randomUUID(), payload, createdAt: Date.now(),
|
|
184
|
+
? { uuid: randomUUID(), payload, createdAt: Date.now(), group, attempts: 0 }
|
|
182
185
|
: { uuid: randomUUID(), payload, createdAt: Date.now(), attempts: 0 }
|
|
183
186
|
this._pushed++
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
187
|
+
const { promise, ready } = this._awaitTask(task.uuid, timeout)
|
|
188
|
+
promise.catch(() => {})
|
|
189
|
+
await ready
|
|
190
|
+
try {
|
|
191
|
+
await this._enqueue(task, group)
|
|
192
|
+
} catch (err) {
|
|
190
193
|
this._pushed--
|
|
191
194
|
throw err
|
|
192
|
-
}
|
|
195
|
+
}
|
|
196
|
+
this.emit("new", { task })
|
|
197
|
+
return promise
|
|
193
198
|
}
|
|
194
199
|
|
|
195
200
|
/** @private */
|
|
196
201
|
async _enqueue(task, group) {
|
|
197
202
|
if (group) {
|
|
198
203
|
await this._redis.lPush(`queue:groups:${group}`, JSON.stringify(task))
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
this._groupInFlight.set(group, 0)
|
|
202
|
-
await this._startGroupWorkers(group)
|
|
203
|
-
}
|
|
204
|
+
await this._ensureGroupWorkers(group)
|
|
205
|
+
this._redis.publish("queue:group:notify", group).catch(() => {})
|
|
204
206
|
} else {
|
|
205
207
|
await this._redis.lPush("queue:tasks", JSON.stringify(task))
|
|
206
208
|
}
|
|
207
209
|
}
|
|
208
210
|
|
|
211
|
+
/** @private */
|
|
212
|
+
async _ensureGroupWorkers(group) {
|
|
213
|
+
if (this._groupWorkers.has(group) || this._closed || this._options.concurrency === 0) return
|
|
214
|
+
this._groupWorkers.set(group, new Map())
|
|
215
|
+
this._groupInFlight.set(group, 0)
|
|
216
|
+
await this._startGroupWorkers(group)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** @private */
|
|
220
|
+
async _ensureSubClient() {
|
|
221
|
+
if (this._subClient) return this._subClient
|
|
222
|
+
this._subClient = this._redis.duplicate()
|
|
223
|
+
this._subClient.on("error", () => {})
|
|
224
|
+
await this._subClient.connect()
|
|
225
|
+
return this._subClient
|
|
226
|
+
}
|
|
227
|
+
|
|
209
228
|
/** @private */
|
|
210
229
|
_awaitTask(uuid, timeout = 0) {
|
|
211
230
|
const ms_ = ms(timeout)
|
|
212
|
-
|
|
231
|
+
const channel = `queue:result:${uuid}`
|
|
232
|
+
let resolveReady
|
|
233
|
+
|
|
234
|
+
const ready = new Promise((r) => { resolveReady = r })
|
|
235
|
+
|
|
236
|
+
const promise = new Promise((resolve, reject) => {
|
|
213
237
|
let timer
|
|
238
|
+
let settled = false
|
|
214
239
|
|
|
215
|
-
const
|
|
216
|
-
if (
|
|
240
|
+
const settle = (fn, value) => {
|
|
241
|
+
if (settled) return
|
|
242
|
+
settled = true
|
|
217
243
|
cleanup()
|
|
218
|
-
|
|
244
|
+
fn(value)
|
|
219
245
|
}
|
|
220
246
|
|
|
221
|
-
const
|
|
247
|
+
const onLocal = (event) => ({ task, result, error }) => {
|
|
222
248
|
if (task.uuid !== uuid) return
|
|
223
|
-
|
|
224
|
-
reject
|
|
249
|
+
if (event === "complete") settle(resolve, result)
|
|
250
|
+
else settle(reject, error)
|
|
225
251
|
}
|
|
226
252
|
|
|
253
|
+
const onComplete = onLocal("complete")
|
|
254
|
+
const onFailed = onLocal("failed")
|
|
255
|
+
|
|
227
256
|
const cleanup = () => {
|
|
228
257
|
if (timer) clearTimeout(timer)
|
|
229
258
|
this.off("complete", onComplete)
|
|
230
259
|
this.off("failed", onFailed)
|
|
260
|
+
this._pendingWaits.delete(uuid)
|
|
261
|
+
this._subClient?.unsubscribe(channel).catch(() => {})
|
|
231
262
|
}
|
|
232
263
|
|
|
233
264
|
if (ms_ > 0) {
|
|
234
|
-
timer = setTimeout(() =>
|
|
235
|
-
cleanup()
|
|
236
|
-
reject(new Error("pushAndWait timed out"))
|
|
237
|
-
}, ms_)
|
|
265
|
+
timer = setTimeout(() => settle(reject, new Error("pushAndWait timed out")), ms_)
|
|
238
266
|
timer.unref?.()
|
|
239
267
|
}
|
|
240
268
|
|
|
241
269
|
this.on("complete", onComplete)
|
|
242
270
|
this.on("failed", onFailed)
|
|
271
|
+
|
|
272
|
+
this._pendingWaits.set(uuid, true)
|
|
273
|
+
|
|
274
|
+
this._ensureSubClient().then((sub) => {
|
|
275
|
+
if (settled) { resolveReady(); return }
|
|
276
|
+
sub.subscribe(channel, (message) => {
|
|
277
|
+
try {
|
|
278
|
+
const { status, result, error } = JSON.parse(message)
|
|
279
|
+
if (status === "complete") settle(resolve, result)
|
|
280
|
+
else settle(reject, error ? Object.assign(new Error(error.message), error) : new Error("Task failed"))
|
|
281
|
+
} catch {}
|
|
282
|
+
}).then(() => resolveReady()).catch(() => resolveReady())
|
|
283
|
+
}).catch(() => resolveReady())
|
|
243
284
|
})
|
|
285
|
+
|
|
286
|
+
return { promise, ready }
|
|
244
287
|
}
|
|
245
288
|
|
|
246
289
|
/** @returns {Promise<void>} */
|
|
@@ -281,18 +324,44 @@ export default class Queue extends EventEmitter {
|
|
|
281
324
|
if (client.isOpen) await client.disconnect()
|
|
282
325
|
}
|
|
283
326
|
this._workerClients = []
|
|
327
|
+
if (this._groupNotifyClient?.isOpen) await this._groupNotifyClient.unsubscribe().catch(() => {})
|
|
328
|
+
if (this._groupNotifyClient?.isOpen) await this._groupNotifyClient.disconnect().catch(() => {})
|
|
329
|
+
this._groupNotifyClient = null
|
|
330
|
+
if (this._subClient?.isOpen) await this._subClient.disconnect().catch(() => {})
|
|
331
|
+
this._subClient = null
|
|
284
332
|
if (this._redis.isOpen) await this._redis.quit()
|
|
285
333
|
}
|
|
286
334
|
|
|
287
335
|
async _initialize() {
|
|
288
336
|
await this._redis.connect()
|
|
289
337
|
await this._startWorkers()
|
|
338
|
+
if (this._options.concurrency > 0) {
|
|
339
|
+
await this._subscribeToGroupNotifications()
|
|
340
|
+
await this._discoverExistingGroups()
|
|
341
|
+
}
|
|
290
342
|
if (this._options.cleanupInterval > 0) {
|
|
291
343
|
this._cleanupTimer = setInterval(() => this._periodicCleanup(), this._options.cleanupInterval)
|
|
292
344
|
this._cleanupTimer.unref()
|
|
293
345
|
}
|
|
294
346
|
}
|
|
295
347
|
|
|
348
|
+
async _subscribeToGroupNotifications() {
|
|
349
|
+
this._groupNotifyClient = this._redis.duplicate()
|
|
350
|
+
this._groupNotifyClient.on("error", () => {})
|
|
351
|
+
await this._groupNotifyClient.connect()
|
|
352
|
+
await this._groupNotifyClient.subscribe("queue:group:notify", (group) => {
|
|
353
|
+
this._ensureGroupWorkers(group)
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async _discoverExistingGroups() {
|
|
358
|
+
const keys = await this._redis.keys("queue:groups:*")
|
|
359
|
+
for (const key of keys) {
|
|
360
|
+
const group = key.slice("queue:groups:".length)
|
|
361
|
+
await this._ensureGroupWorkers(group)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
296
365
|
async _createWorkerClient() {
|
|
297
366
|
const client = this._redis.duplicate()
|
|
298
367
|
client.on("error", () => {})
|
|
@@ -338,13 +407,13 @@ export default class Queue extends EventEmitter {
|
|
|
338
407
|
timeout: this._options.groups.timeout,
|
|
339
408
|
maxRetries: this._options.groups.maxRetries,
|
|
340
409
|
retryKey: `queue:groups:${groupKey}`,
|
|
341
|
-
groupKey,
|
|
410
|
+
group: groupKey,
|
|
342
411
|
}
|
|
343
412
|
this._runWorkerLoop(workerId, client, `queue:groups:${groupKey}`, groupWorkers, opts)
|
|
344
413
|
}
|
|
345
414
|
|
|
346
415
|
async _runWorkerLoop(workerId, client, key, activeMap, opts) {
|
|
347
|
-
const delay = opts.
|
|
416
|
+
const delay = opts.group ? this._options.groups.delay : this._options.delay
|
|
348
417
|
|
|
349
418
|
while (activeMap.get(workerId)) {
|
|
350
419
|
try {
|
|
@@ -371,17 +440,17 @@ export default class Queue extends EventEmitter {
|
|
|
371
440
|
}
|
|
372
441
|
|
|
373
442
|
this._inFlight++
|
|
374
|
-
if (opts.
|
|
375
|
-
this._groupInFlight.set(opts.
|
|
443
|
+
if (opts.group) {
|
|
444
|
+
this._groupInFlight.set(opts.group, (this._groupInFlight.get(opts.group) || 0) + 1)
|
|
376
445
|
}
|
|
377
446
|
|
|
378
447
|
try {
|
|
379
448
|
await this._processTask(task, opts)
|
|
380
449
|
} finally {
|
|
381
|
-
if (opts.
|
|
382
|
-
const count = (this._groupInFlight.get(opts.
|
|
383
|
-
if (count <= 0) this._groupInFlight.delete(opts.
|
|
384
|
-
else this._groupInFlight.set(opts.
|
|
450
|
+
if (opts.group) {
|
|
451
|
+
const count = (this._groupInFlight.get(opts.group) || 1) - 1
|
|
452
|
+
if (count <= 0) this._groupInFlight.delete(opts.group)
|
|
453
|
+
else this._groupInFlight.set(opts.group, count)
|
|
385
454
|
}
|
|
386
455
|
if (leaseId) await this._releaseGlobal(leaseId).catch(() => {})
|
|
387
456
|
}
|
|
@@ -467,6 +536,7 @@ export default class Queue extends EventEmitter {
|
|
|
467
536
|
|
|
468
537
|
if (succeeded) {
|
|
469
538
|
this._settle()
|
|
539
|
+
this._publishResult(task.uuid, { status: "complete", result })
|
|
470
540
|
try { this.emit("complete", { task, result }) } finally { this._emitDrain() }
|
|
471
541
|
} else if (task.attempts < opts.maxRetries && !this._closed) {
|
|
472
542
|
let retried = false
|
|
@@ -479,14 +549,22 @@ export default class Queue extends EventEmitter {
|
|
|
479
549
|
this.emit("retry", { task, error: handlerError, attempt: task.attempts })
|
|
480
550
|
} else {
|
|
481
551
|
this._settle()
|
|
552
|
+
this._publishResult(task.uuid, { status: "failed", error: { message: handlerError?.message, code: handlerError?.code, name: handlerError?.name } })
|
|
482
553
|
try { this.emit("failed", { task, error: handlerError }) } finally { this._emitDrain() }
|
|
483
554
|
}
|
|
484
555
|
} else {
|
|
485
556
|
this._settle()
|
|
557
|
+
this._publishResult(task.uuid, { status: "failed", error: { message: handlerError?.message, code: handlerError?.code, name: handlerError?.name } })
|
|
486
558
|
try { this.emit("failed", { task, error: handlerError }) } finally { this._emitDrain() }
|
|
487
559
|
}
|
|
488
560
|
}
|
|
489
561
|
|
|
562
|
+
_publishResult(uuid, payload) {
|
|
563
|
+
if (!this._redis.isOpen) return
|
|
564
|
+
const channel = `queue:result:${uuid}`
|
|
565
|
+
this._redis.publish(channel, JSON.stringify(payload)).catch(() => {})
|
|
566
|
+
}
|
|
567
|
+
|
|
490
568
|
_settle() {
|
|
491
569
|
this._inFlight--
|
|
492
570
|
this._totalSettled++
|