@prsm/queue 3.0.1 → 3.0.3
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 +62 -4
- package/package.json +1 -1
- package/src/queue.js +100 -25
- package/types/queue.d.ts +4635 -0
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
|
|
@@ -176,11 +197,48 @@ 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
|
|
180
201
|
|
|
181
|
-
|
|
202
|
+
Use groups to fan out a single event to multiple independent handlers. Each group processes and retries independently.
|
|
182
203
|
|
|
183
|
-
|
|
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.
|
|
238
|
+
|
|
239
|
+
## WebSocket Integration
|
|
240
|
+
|
|
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'
|
|
@@ -213,7 +271,7 @@ await queue.ready()
|
|
|
213
271
|
await realtime.listen(8080)
|
|
214
272
|
```
|
|
215
273
|
|
|
216
|
-
Both queue and realtime use the same Redis instance. No key conflicts (`queue:*` vs `
|
|
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
|
@@ -131,6 +131,8 @@ 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
|
|
134
136
|
this._readyPromise = this._initialize()
|
|
135
137
|
}
|
|
136
138
|
|
|
@@ -175,72 +177,109 @@ export default class Queue extends EventEmitter {
|
|
|
175
177
|
* @param {{ group?: string, timeout?: number|string }} [options]
|
|
176
178
|
* @returns {Promise<any>}
|
|
177
179
|
*/
|
|
178
|
-
pushAndWait(payload, { group, timeout = 0 } = {}) {
|
|
179
|
-
if (this._closed)
|
|
180
|
+
async pushAndWait(payload, { group, timeout = 0 } = {}) {
|
|
181
|
+
if (this._closed) throw new Error("Queue is closed")
|
|
180
182
|
const task = group
|
|
181
183
|
? { uuid: randomUUID(), payload, createdAt: Date.now(), group, attempts: 0 }
|
|
182
184
|
: { uuid: randomUUID(), payload, createdAt: Date.now(), attempts: 0 }
|
|
183
185
|
this._pushed++
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
186
|
+
const { promise, ready } = this._awaitTask(task.uuid, timeout)
|
|
187
|
+
promise.catch(() => {})
|
|
188
|
+
await ready
|
|
189
|
+
try {
|
|
190
|
+
await this._enqueue(task, group)
|
|
191
|
+
} catch (err) {
|
|
190
192
|
this._pushed--
|
|
191
193
|
throw err
|
|
192
|
-
}
|
|
194
|
+
}
|
|
195
|
+
this.emit("new", { task })
|
|
196
|
+
return promise
|
|
193
197
|
}
|
|
194
198
|
|
|
195
199
|
/** @private */
|
|
196
200
|
async _enqueue(task, group) {
|
|
197
201
|
if (group) {
|
|
198
202
|
await this._redis.lPush(`queue:groups:${group}`, JSON.stringify(task))
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
this._groupInFlight.set(group, 0)
|
|
202
|
-
await this._startGroupWorkers(group)
|
|
203
|
-
}
|
|
203
|
+
await this._ensureGroupWorkers(group)
|
|
204
|
+
this._redis.publish("queue:group:notify", group).catch(() => {})
|
|
204
205
|
} else {
|
|
205
206
|
await this._redis.lPush("queue:tasks", JSON.stringify(task))
|
|
206
207
|
}
|
|
207
208
|
}
|
|
208
209
|
|
|
210
|
+
/** @private */
|
|
211
|
+
async _ensureGroupWorkers(group) {
|
|
212
|
+
if (this._groupWorkers.has(group) || this._closed || this._options.concurrency === 0) return
|
|
213
|
+
this._groupWorkers.set(group, new Map())
|
|
214
|
+
this._groupInFlight.set(group, 0)
|
|
215
|
+
await this._startGroupWorkers(group)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** @private */
|
|
219
|
+
async _ensureSubClient() {
|
|
220
|
+
if (this._subClient) return this._subClient
|
|
221
|
+
this._subClient = this._redis.duplicate()
|
|
222
|
+
this._subClient.on("error", () => {})
|
|
223
|
+
await this._subClient.connect()
|
|
224
|
+
return this._subClient
|
|
225
|
+
}
|
|
226
|
+
|
|
209
227
|
/** @private */
|
|
210
228
|
_awaitTask(uuid, timeout = 0) {
|
|
211
229
|
const ms_ = ms(timeout)
|
|
212
|
-
|
|
230
|
+
const channel = `queue:result:${uuid}`
|
|
231
|
+
let resolveReady
|
|
232
|
+
|
|
233
|
+
const ready = new Promise((r) => { resolveReady = r })
|
|
234
|
+
|
|
235
|
+
const promise = new Promise((resolve, reject) => {
|
|
213
236
|
let timer
|
|
237
|
+
let settled = false
|
|
214
238
|
|
|
215
|
-
const
|
|
216
|
-
if (
|
|
239
|
+
const settle = (fn, value) => {
|
|
240
|
+
if (settled) return
|
|
241
|
+
settled = true
|
|
217
242
|
cleanup()
|
|
218
|
-
|
|
243
|
+
fn(value)
|
|
219
244
|
}
|
|
220
245
|
|
|
221
|
-
const
|
|
246
|
+
const onLocal = (event) => ({ task, result, error }) => {
|
|
222
247
|
if (task.uuid !== uuid) return
|
|
223
|
-
|
|
224
|
-
reject
|
|
248
|
+
if (event === "complete") settle(resolve, result)
|
|
249
|
+
else settle(reject, error)
|
|
225
250
|
}
|
|
226
251
|
|
|
252
|
+
const onComplete = onLocal("complete")
|
|
253
|
+
const onFailed = onLocal("failed")
|
|
254
|
+
|
|
227
255
|
const cleanup = () => {
|
|
228
256
|
if (timer) clearTimeout(timer)
|
|
229
257
|
this.off("complete", onComplete)
|
|
230
258
|
this.off("failed", onFailed)
|
|
259
|
+
this._subClient?.unsubscribe(channel).catch(() => {})
|
|
231
260
|
}
|
|
232
261
|
|
|
233
262
|
if (ms_ > 0) {
|
|
234
|
-
timer = setTimeout(() =>
|
|
235
|
-
cleanup()
|
|
236
|
-
reject(new Error("pushAndWait timed out"))
|
|
237
|
-
}, ms_)
|
|
263
|
+
timer = setTimeout(() => settle(reject, new Error("pushAndWait timed out")), ms_)
|
|
238
264
|
timer.unref?.()
|
|
239
265
|
}
|
|
240
266
|
|
|
241
267
|
this.on("complete", onComplete)
|
|
242
268
|
this.on("failed", onFailed)
|
|
269
|
+
|
|
270
|
+
this._ensureSubClient().then((sub) => {
|
|
271
|
+
if (settled) { resolveReady(); return }
|
|
272
|
+
sub.subscribe(channel, (message) => {
|
|
273
|
+
try {
|
|
274
|
+
const { status, result, error } = JSON.parse(message)
|
|
275
|
+
if (status === "complete") settle(resolve, result)
|
|
276
|
+
else settle(reject, error ? Object.assign(new Error(error.message), error) : new Error("Task failed"))
|
|
277
|
+
} catch {}
|
|
278
|
+
}).then(() => resolveReady()).catch(() => resolveReady())
|
|
279
|
+
}).catch(() => resolveReady())
|
|
243
280
|
})
|
|
281
|
+
|
|
282
|
+
return { promise, ready }
|
|
244
283
|
}
|
|
245
284
|
|
|
246
285
|
/** @returns {Promise<void>} */
|
|
@@ -281,18 +320,44 @@ export default class Queue extends EventEmitter {
|
|
|
281
320
|
if (client.isOpen) await client.disconnect()
|
|
282
321
|
}
|
|
283
322
|
this._workerClients = []
|
|
323
|
+
if (this._groupNotifyClient?.isOpen) await this._groupNotifyClient.unsubscribe().catch(() => {})
|
|
324
|
+
if (this._groupNotifyClient?.isOpen) await this._groupNotifyClient.disconnect().catch(() => {})
|
|
325
|
+
this._groupNotifyClient = null
|
|
326
|
+
if (this._subClient?.isOpen) await this._subClient.disconnect().catch(() => {})
|
|
327
|
+
this._subClient = null
|
|
284
328
|
if (this._redis.isOpen) await this._redis.quit()
|
|
285
329
|
}
|
|
286
330
|
|
|
287
331
|
async _initialize() {
|
|
288
332
|
await this._redis.connect()
|
|
289
333
|
await this._startWorkers()
|
|
334
|
+
if (this._options.concurrency > 0) {
|
|
335
|
+
await this._subscribeToGroupNotifications()
|
|
336
|
+
await this._discoverExistingGroups()
|
|
337
|
+
}
|
|
290
338
|
if (this._options.cleanupInterval > 0) {
|
|
291
339
|
this._cleanupTimer = setInterval(() => this._periodicCleanup(), this._options.cleanupInterval)
|
|
292
340
|
this._cleanupTimer.unref()
|
|
293
341
|
}
|
|
294
342
|
}
|
|
295
343
|
|
|
344
|
+
async _subscribeToGroupNotifications() {
|
|
345
|
+
this._groupNotifyClient = this._redis.duplicate()
|
|
346
|
+
this._groupNotifyClient.on("error", () => {})
|
|
347
|
+
await this._groupNotifyClient.connect()
|
|
348
|
+
await this._groupNotifyClient.subscribe("queue:group:notify", (group) => {
|
|
349
|
+
this._ensureGroupWorkers(group)
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async _discoverExistingGroups() {
|
|
354
|
+
const keys = await this._redis.keys("queue:groups:*")
|
|
355
|
+
for (const key of keys) {
|
|
356
|
+
const group = key.slice("queue:groups:".length)
|
|
357
|
+
await this._ensureGroupWorkers(group)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
296
361
|
async _createWorkerClient() {
|
|
297
362
|
const client = this._redis.duplicate()
|
|
298
363
|
client.on("error", () => {})
|
|
@@ -467,6 +532,7 @@ export default class Queue extends EventEmitter {
|
|
|
467
532
|
|
|
468
533
|
if (succeeded) {
|
|
469
534
|
this._settle()
|
|
535
|
+
this._publishResult(task.uuid, { status: "complete", result })
|
|
470
536
|
try { this.emit("complete", { task, result }) } finally { this._emitDrain() }
|
|
471
537
|
} else if (task.attempts < opts.maxRetries && !this._closed) {
|
|
472
538
|
let retried = false
|
|
@@ -479,14 +545,22 @@ export default class Queue extends EventEmitter {
|
|
|
479
545
|
this.emit("retry", { task, error: handlerError, attempt: task.attempts })
|
|
480
546
|
} else {
|
|
481
547
|
this._settle()
|
|
548
|
+
this._publishResult(task.uuid, { status: "failed", error: { message: handlerError?.message, code: handlerError?.code, name: handlerError?.name } })
|
|
482
549
|
try { this.emit("failed", { task, error: handlerError }) } finally { this._emitDrain() }
|
|
483
550
|
}
|
|
484
551
|
} else {
|
|
485
552
|
this._settle()
|
|
553
|
+
this._publishResult(task.uuid, { status: "failed", error: { message: handlerError?.message, code: handlerError?.code, name: handlerError?.name } })
|
|
486
554
|
try { this.emit("failed", { task, error: handlerError }) } finally { this._emitDrain() }
|
|
487
555
|
}
|
|
488
556
|
}
|
|
489
557
|
|
|
558
|
+
_publishResult(uuid, payload) {
|
|
559
|
+
if (!this._redis.isOpen) return
|
|
560
|
+
const channel = `queue:result:${uuid}`
|
|
561
|
+
this._redis.publish(channel, JSON.stringify(payload)).catch(() => {})
|
|
562
|
+
}
|
|
563
|
+
|
|
490
564
|
_settle() {
|
|
491
565
|
this._inFlight--
|
|
492
566
|
this._totalSettled++
|
|
@@ -514,6 +588,7 @@ export default class Queue extends EventEmitter {
|
|
|
514
588
|
}
|
|
515
589
|
this._groupInFlight.delete(groupKey)
|
|
516
590
|
}
|
|
591
|
+
this._workerClients = this._workerClients.filter((c) => c.isOpen)
|
|
517
592
|
} catch {}
|
|
518
593
|
}
|
|
519
594
|
}
|