@prsm/queue 2.1.1 → 3.0.1
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 +16 -16
- package/package.json +1 -1
- package/src/queue.js +32 -54
- package/types/queue.d.ts +12 -12
package/README.md
CHANGED
|
@@ -110,7 +110,7 @@ Throw an error to trigger retry. After `maxRetries`, the task fails permanently.
|
|
|
110
110
|
|
|
111
111
|
## Grouped Queues
|
|
112
112
|
|
|
113
|
-
Isolated concurrency per key - perfect for per-tenant throttling.
|
|
113
|
+
Isolated concurrency per key - perfect for per-tenant throttling. Pass `{ group }` as the second argument to `push` or `pushAndWait`.
|
|
114
114
|
|
|
115
115
|
```js
|
|
116
116
|
const queue = new Queue({
|
|
@@ -124,11 +124,11 @@ queue.process(async (payload) => {
|
|
|
124
124
|
|
|
125
125
|
await queue.ready()
|
|
126
126
|
|
|
127
|
-
await queue.
|
|
128
|
-
await queue.
|
|
127
|
+
await queue.push({ action: 'sync' }, { group: 'tenant-123' })
|
|
128
|
+
await queue.push({ action: 'sync' }, { group: 'tenant-456' })
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
Each tenant processes independently. One slow tenant won't block others. Total concurrent tasks across all tenants is capped by `concurrency`.
|
|
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
133
|
## Events
|
|
134
134
|
|
|
@@ -147,7 +147,7 @@ queue.on('drain', () => {})
|
|
|
147
147
|
uuid: string,
|
|
148
148
|
payload: any,
|
|
149
149
|
createdAt: number,
|
|
150
|
-
|
|
150
|
+
group?: string, // present when pushed with { group }
|
|
151
151
|
attempts: number
|
|
152
152
|
}
|
|
153
153
|
```
|
|
@@ -169,7 +169,7 @@ queue.process(async ({ prompt }) => {
|
|
|
169
169
|
|
|
170
170
|
app.post('/api/generate', async (req, res) => {
|
|
171
171
|
const { tenantId, prompt } = req.body
|
|
172
|
-
const taskId = await queue.
|
|
172
|
+
const taskId = await queue.push({ prompt }, { group: tenantId })
|
|
173
173
|
res.json({ queued: true, taskId })
|
|
174
174
|
})
|
|
175
175
|
```
|
|
@@ -178,15 +178,15 @@ Each tenant gets up to 2 concurrent LLM calls with a 50ms pause between them. To
|
|
|
178
178
|
|
|
179
179
|
## WebSocket Integration with [mesh](https://github.com/nvms/mesh)
|
|
180
180
|
|
|
181
|
-
Queue events are local-only - only the server that processes a task emits `complete`/`failed`. Use [
|
|
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.
|
|
182
182
|
|
|
183
183
|
Send results to a specific client:
|
|
184
184
|
|
|
185
185
|
```js
|
|
186
186
|
import Queue from '@prsm/queue'
|
|
187
|
-
import {
|
|
187
|
+
import { RealtimeServer } from '@prsm/realtime'
|
|
188
188
|
|
|
189
|
-
const
|
|
189
|
+
const realtime = new RealtimeServer({ redis: { host: 'localhost', port: 6379 } })
|
|
190
190
|
const queue = new Queue({ concurrency: 5, groups: { concurrency: 1 } })
|
|
191
191
|
|
|
192
192
|
queue.process(async (payload) => {
|
|
@@ -194,26 +194,26 @@ queue.process(async (payload) => {
|
|
|
194
194
|
})
|
|
195
195
|
|
|
196
196
|
queue.on('complete', ({ task, result }) => {
|
|
197
|
-
|
|
197
|
+
realtime.sendTo(task.payload.connectionId, 'job:complete', result)
|
|
198
198
|
})
|
|
199
199
|
|
|
200
200
|
queue.on('failed', ({ task, error }) => {
|
|
201
|
-
|
|
201
|
+
realtime.sendTo(task.payload.connectionId, 'job:failed', { error: error.message })
|
|
202
202
|
})
|
|
203
203
|
|
|
204
|
-
|
|
205
|
-
const taskId = await queue.
|
|
204
|
+
realtime.exposeCommand('generate-report', async (ctx) => {
|
|
205
|
+
const taskId = await queue.push({
|
|
206
206
|
connectionId: ctx.connection.id,
|
|
207
207
|
...ctx.payload,
|
|
208
|
-
})
|
|
208
|
+
}, { group: ctx.connection.id })
|
|
209
209
|
return { queued: true, taskId }
|
|
210
210
|
})
|
|
211
211
|
|
|
212
212
|
await queue.ready()
|
|
213
|
-
await
|
|
213
|
+
await realtime.listen(8080)
|
|
214
214
|
```
|
|
215
215
|
|
|
216
|
-
Both queue and
|
|
216
|
+
Both queue and realtime use the same Redis instance. No key conflicts (`queue:*` vs `mesh:*`).
|
|
217
217
|
|
|
218
218
|
## Horizontal Scaling
|
|
219
219
|
|
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
|
|
|
@@ -151,14 +151,17 @@ export default class Queue extends EventEmitter {
|
|
|
151
151
|
|
|
152
152
|
/**
|
|
153
153
|
* @param {any} payload
|
|
154
|
+
* @param {{ group?: string }} [options]
|
|
154
155
|
* @returns {Promise<string>}
|
|
155
156
|
*/
|
|
156
|
-
async push(payload) {
|
|
157
|
+
async push(payload, { group } = {}) {
|
|
157
158
|
if (this._closed) throw new Error("Queue is closed")
|
|
158
|
-
const task =
|
|
159
|
+
const task = group
|
|
160
|
+
? { uuid: randomUUID(), payload, createdAt: Date.now(), group, attempts: 0 }
|
|
161
|
+
: { uuid: randomUUID(), payload, createdAt: Date.now(), attempts: 0 }
|
|
159
162
|
this._pushed++
|
|
160
163
|
try {
|
|
161
|
-
await this.
|
|
164
|
+
await this._enqueue(task, group)
|
|
162
165
|
} catch (err) {
|
|
163
166
|
this._pushed--
|
|
164
167
|
throw err
|
|
@@ -169,16 +172,18 @@ export default class Queue extends EventEmitter {
|
|
|
169
172
|
|
|
170
173
|
/**
|
|
171
174
|
* @param {any} payload
|
|
172
|
-
* @param {
|
|
175
|
+
* @param {{ group?: string, timeout?: number|string }} [options]
|
|
173
176
|
* @returns {Promise<any>}
|
|
174
177
|
*/
|
|
175
|
-
pushAndWait(payload, timeout = 0) {
|
|
178
|
+
pushAndWait(payload, { group, timeout = 0 } = {}) {
|
|
176
179
|
if (this._closed) return Promise.reject(new Error("Queue is closed"))
|
|
177
|
-
const task =
|
|
180
|
+
const task = group
|
|
181
|
+
? { uuid: randomUUID(), payload, createdAt: Date.now(), group, attempts: 0 }
|
|
182
|
+
: { uuid: randomUUID(), payload, createdAt: Date.now(), attempts: 0 }
|
|
178
183
|
this._pushed++
|
|
179
184
|
const result = this._awaitTask(task.uuid, timeout)
|
|
180
185
|
result.catch(() => {})
|
|
181
|
-
return this.
|
|
186
|
+
return this._enqueue(task, group).then(() => {
|
|
182
187
|
this.emit("new", { task })
|
|
183
188
|
return result
|
|
184
189
|
}, (err) => {
|
|
@@ -187,44 +192,17 @@ export default class Queue extends EventEmitter {
|
|
|
187
192
|
})
|
|
188
193
|
}
|
|
189
194
|
|
|
190
|
-
/**
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
return this._redis.lPush(`queue:groups:${key}`, JSON.stringify(task)).then(async () => {
|
|
202
|
-
this.emit("new", { task })
|
|
203
|
-
if (!this._groupWorkers.has(key)) {
|
|
204
|
-
this._groupWorkers.set(key, new Map())
|
|
205
|
-
this._groupInFlight.set(key, 0)
|
|
206
|
-
await this._startGroupWorkers(key)
|
|
207
|
-
}
|
|
208
|
-
}, (err) => {
|
|
209
|
-
this._pushed--
|
|
210
|
-
throw err
|
|
211
|
-
})
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return {
|
|
215
|
-
push: async (payload) => {
|
|
216
|
-
const task = makeTask(payload)
|
|
217
|
-
this._pushed++
|
|
218
|
-
await commit(task)
|
|
219
|
-
return task.uuid
|
|
220
|
-
},
|
|
221
|
-
pushAndWait: (payload, timeout = 0) => {
|
|
222
|
-
const task = makeTask(payload)
|
|
223
|
-
this._pushed++
|
|
224
|
-
const result = this._awaitTask(task.uuid, timeout)
|
|
225
|
-
result.catch(() => {})
|
|
226
|
-
return commit(task).then(() => result)
|
|
227
|
-
},
|
|
195
|
+
/** @private */
|
|
196
|
+
async _enqueue(task, group) {
|
|
197
|
+
if (group) {
|
|
198
|
+
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
|
+
} else {
|
|
205
|
+
await this._redis.lPush("queue:tasks", JSON.stringify(task))
|
|
228
206
|
}
|
|
229
207
|
}
|
|
230
208
|
|
|
@@ -360,13 +338,13 @@ export default class Queue extends EventEmitter {
|
|
|
360
338
|
timeout: this._options.groups.timeout,
|
|
361
339
|
maxRetries: this._options.groups.maxRetries,
|
|
362
340
|
retryKey: `queue:groups:${groupKey}`,
|
|
363
|
-
groupKey,
|
|
341
|
+
group: groupKey,
|
|
364
342
|
}
|
|
365
343
|
this._runWorkerLoop(workerId, client, `queue:groups:${groupKey}`, groupWorkers, opts)
|
|
366
344
|
}
|
|
367
345
|
|
|
368
346
|
async _runWorkerLoop(workerId, client, key, activeMap, opts) {
|
|
369
|
-
const delay = opts.
|
|
347
|
+
const delay = opts.group ? this._options.groups.delay : this._options.delay
|
|
370
348
|
|
|
371
349
|
while (activeMap.get(workerId)) {
|
|
372
350
|
try {
|
|
@@ -393,17 +371,17 @@ export default class Queue extends EventEmitter {
|
|
|
393
371
|
}
|
|
394
372
|
|
|
395
373
|
this._inFlight++
|
|
396
|
-
if (opts.
|
|
397
|
-
this._groupInFlight.set(opts.
|
|
374
|
+
if (opts.group) {
|
|
375
|
+
this._groupInFlight.set(opts.group, (this._groupInFlight.get(opts.group) || 0) + 1)
|
|
398
376
|
}
|
|
399
377
|
|
|
400
378
|
try {
|
|
401
379
|
await this._processTask(task, opts)
|
|
402
380
|
} finally {
|
|
403
|
-
if (opts.
|
|
404
|
-
const count = (this._groupInFlight.get(opts.
|
|
405
|
-
if (count <= 0) this._groupInFlight.delete(opts.
|
|
406
|
-
else this._groupInFlight.set(opts.
|
|
381
|
+
if (opts.group) {
|
|
382
|
+
const count = (this._groupInFlight.get(opts.group) || 1) - 1
|
|
383
|
+
if (count <= 0) this._groupInFlight.delete(opts.group)
|
|
384
|
+
else this._groupInFlight.set(opts.group, count)
|
|
407
385
|
}
|
|
408
386
|
if (leaseId) await this._releaseGlobal(leaseId).catch(() => {})
|
|
409
387
|
}
|
package/types/queue.d.ts
CHANGED
|
@@ -2357,23 +2357,23 @@ export default class Queue extends EventEmitter<[never]> {
|
|
|
2357
2357
|
process(handler: TaskHandler): void;
|
|
2358
2358
|
/**
|
|
2359
2359
|
* @param {any} payload
|
|
2360
|
+
* @param {{ group?: string }} [options]
|
|
2360
2361
|
* @returns {Promise<string>}
|
|
2361
2362
|
*/
|
|
2362
|
-
push(payload: any
|
|
2363
|
+
push(payload: any, { group }?: {
|
|
2364
|
+
group?: string;
|
|
2365
|
+
}): Promise<string>;
|
|
2363
2366
|
/**
|
|
2364
2367
|
* @param {any} payload
|
|
2365
|
-
* @param {
|
|
2368
|
+
* @param {{ group?: string, timeout?: number|string }} [options]
|
|
2366
2369
|
* @returns {Promise<any>}
|
|
2367
2370
|
*/
|
|
2368
|
-
pushAndWait(payload: any,
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
push: (payload: any) => Promise<string>;
|
|
2375
|
-
pushAndWait: (payload: any, timeout?: number | string) => Promise<any>;
|
|
2376
|
-
};
|
|
2371
|
+
pushAndWait(payload: any, { group, timeout }?: {
|
|
2372
|
+
group?: string;
|
|
2373
|
+
timeout?: number | string;
|
|
2374
|
+
}): Promise<any>;
|
|
2375
|
+
/** @private */
|
|
2376
|
+
private _enqueue;
|
|
2377
2377
|
/** @private */
|
|
2378
2378
|
private _awaitTask;
|
|
2379
2379
|
/** @returns {Promise<void>} */
|
|
@@ -4752,7 +4752,7 @@ export type Task = {
|
|
|
4752
4752
|
uuid: string;
|
|
4753
4753
|
payload: any;
|
|
4754
4754
|
createdAt: number;
|
|
4755
|
-
|
|
4755
|
+
group?: string;
|
|
4756
4756
|
attempts: number;
|
|
4757
4757
|
};
|
|
4758
4758
|
export type TaskHandler = (payload: any, task: Task) => Promise<any> | any;
|