@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 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.2",
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,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) return Promise.reject(new Error("Queue is 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 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) => {
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
- if (!this._groupWorkers.has(group)) {
200
- this._groupWorkers.set(group, new Map())
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
- return new Promise((resolve, reject) => {
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 onComplete = ({ task, result }) => {
216
- if (task.uuid !== uuid) return
240
+ const settle = (fn, value) => {
241
+ if (settled) return
242
+ settled = true
217
243
  cleanup()
218
- resolve(result)
244
+ fn(value)
219
245
  }
220
246
 
221
- const onFailed = ({ task, error }) => {
247
+ const onLocal = (event) => ({ task, result, error }) => {
222
248
  if (task.uuid !== uuid) return
223
- cleanup()
224
- reject(error)
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++