@prsm/queue 3.0.1 → 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 +62 -4
- package/package.json +1 -1
- package/src/queue.js +103 -25
- package/types/queue.d.ts +4636 -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,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
|
|
|
@@ -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
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", () => {})
|
|
@@ -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++
|