@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 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
- ## WebSocket Integration with [mesh](https://github.com/nvms/mesh)
200
+ ## Fan-out with Groups
180
201
 
181
- 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.
202
+ Use groups to fan out a single event to multiple independent handlers. Each group processes and retries independently.
182
203
 
183
- Send results to a specific client:
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 `mesh:*`).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prsm/queue",
3
- "version": "3.0.1",
3
+ "version": "3.0.3",
4
4
  "description": "Redis-backed distributed task queue with grouped concurrency, retries, and rate limiting",
5
5
  "type": "module",
6
6
  "exports": {
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) return Promise.reject(new Error("Queue is 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 result = this._awaitTask(task.uuid, timeout)
185
- result.catch(() => {})
186
- return this._enqueue(task, group).then(() => {
187
- this.emit("new", { task })
188
- return result
189
- }, (err) => {
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
- if (!this._groupWorkers.has(group)) {
200
- this._groupWorkers.set(group, new Map())
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
- return new Promise((resolve, reject) => {
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 onComplete = ({ task, result }) => {
216
- if (task.uuid !== uuid) return
239
+ const settle = (fn, value) => {
240
+ if (settled) return
241
+ settled = true
217
242
  cleanup()
218
- resolve(result)
243
+ fn(value)
219
244
  }
220
245
 
221
- const onFailed = ({ task, error }) => {
246
+ const onLocal = (event) => ({ task, result, error }) => {
222
247
  if (task.uuid !== uuid) return
223
- cleanup()
224
- reject(error)
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
  }