@prsm/queue 1.0.2 → 2.0.0

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
@@ -10,7 +10,7 @@ npm install @prsm/queue
10
10
 
11
11
  ## Quick Start
12
12
 
13
- ```ts
13
+ ```js
14
14
  import Queue from '@prsm/queue'
15
15
 
16
16
  const queue = new Queue({
@@ -30,12 +30,13 @@ queue.on('failed', ({ task, error }) => {
30
30
  console.log('Failed after retries:', task.uuid, error.message)
31
31
  })
32
32
 
33
+ await queue.ready()
33
34
  await queue.push({ userId: 123, action: 'sync' })
34
35
  ```
35
36
 
36
37
  ## Options
37
38
 
38
- ```ts
39
+ ```js
39
40
  const queue = new Queue({
40
41
  concurrency: 2, // worker count
41
42
  delay: '100ms', // pause between tasks (string or ms)
@@ -58,7 +59,7 @@ const queue = new Queue({
58
59
 
59
60
  ## Process Handler
60
61
 
61
- ```ts
62
+ ```js
62
63
  queue.process(async (payload, task) => {
63
64
  console.log('Task:', task.uuid, 'Attempt:', task.attempts)
64
65
  return await someWork(payload)
@@ -71,7 +72,7 @@ Throw an error to trigger retry. After `maxRetries`, the task fails permanently.
71
72
 
72
73
  Isolated concurrency per key - perfect for per-tenant rate limiting.
73
74
 
74
- ```ts
75
+ ```js
75
76
  const queue = new Queue({
76
77
  groups: { concurrency: 1, delay: '50ms' }
77
78
  })
@@ -80,6 +81,8 @@ queue.process(async (payload) => {
80
81
  return await callExternalAPI(payload)
81
82
  })
82
83
 
84
+ await queue.ready()
85
+
83
86
  await queue.group('tenant-123').push({ action: 'sync' })
84
87
  await queue.group('tenant-456').push({ action: 'sync' })
85
88
  ```
@@ -88,21 +91,22 @@ Each tenant processes independently. One slow tenant won't block others.
88
91
 
89
92
  ## Events
90
93
 
91
- ```ts
94
+ ```js
92
95
  queue.on('new', ({ task }) => {})
93
96
  queue.on('complete', ({ task, result }) => {})
94
97
  queue.on('retry', ({ task, error, attempt }) => {})
95
98
  queue.on('failed', ({ task, error }) => {})
99
+ queue.on('drain', () => {})
96
100
  ```
97
101
 
98
102
  ## Task Object
99
103
 
100
- ```ts
101
- interface Task<T> {
102
- uuid: string
103
- payload: T
104
- createdAt: number
105
- groupKey?: string
104
+ ```js
105
+ {
106
+ uuid: string,
107
+ payload: any,
108
+ createdAt: number,
109
+ groupKey?: string, // present when pushed via group()
106
110
  attempts: number
107
111
  }
108
112
  ```
@@ -111,7 +115,7 @@ interface Task<T> {
111
115
 
112
116
  20 LLM calls/sec per tenant:
113
117
 
114
- ```ts
118
+ ```js
115
119
  const queue = new Queue({
116
120
  groups: { concurrency: 20, delay: '50ms' },
117
121
  maxRetries: 3
@@ -128,15 +132,52 @@ app.post('/api/generate', async (req, res) => {
128
132
  })
129
133
  ```
130
134
 
135
+ ## WebSocket Integration with [mesh](https://github.com/nvms/mesh)
136
+
137
+ Queue events are local-only - only the server that processes a task emits `complete`/`failed`. Use [mesh](https://github.com/nvms/mesh) to push results to connected clients in real time.
138
+
139
+ Send results to a specific client:
140
+
141
+ ```js
142
+ import Queue from '@prsm/queue'
143
+ import { MeshServer } from '@mesh-kit/server'
144
+
145
+ const mesh = new MeshServer({ redis: { host: 'localhost', port: 6379 } })
146
+ const queue = new Queue({ groups: { concurrency: 1 } })
147
+
148
+ queue.process(async (payload) => {
149
+ return await generateReport(payload)
150
+ })
151
+
152
+ queue.on('complete', ({ task, result }) => {
153
+ mesh.sendTo(task.payload.connectionId, 'job:complete', result)
154
+ })
155
+
156
+ queue.on('failed', ({ task, error }) => {
157
+ mesh.sendTo(task.payload.connectionId, 'job:failed', { error: error.message })
158
+ })
159
+
160
+ mesh.exposeCommand('generate-report', async (ctx) => {
161
+ const taskId = await queue.group(ctx.connection.id).push({
162
+ connectionId: ctx.connection.id,
163
+ ...ctx.payload,
164
+ })
165
+ return { queued: true, taskId }
166
+ })
167
+
168
+ await queue.ready()
169
+ await mesh.listen(8080)
170
+ ```
171
+
172
+ Both queue and mesh use the same Redis instance. No key conflicts (`queue:*` vs `mesh:*`).
173
+
131
174
  ## Horizontal Scaling
132
175
 
133
176
  Multiple servers can push to the same queue. Redis coordinates via atomic operations - no duplicate processing.
134
177
 
135
- Note: events are local. Only the server that processes a task emits `complete`/`failed`. Use Redis pub/sub or WebSockets to broadcast results across servers.
136
-
137
178
  ## Cleanup
138
179
 
139
- ```ts
180
+ ```js
140
181
  await queue.close()
141
182
  ```
142
183
 
package/package.json CHANGED
@@ -1,31 +1,23 @@
1
1
  {
2
2
  "name": "@prsm/queue",
3
- "version": "1.0.2",
3
+ "version": "2.0.0",
4
4
  "description": "Redis-backed distributed task queue with grouped concurrency, retries, and rate limiting",
5
5
  "type": "module",
6
- "main": "./dist/index.cjs",
7
- "module": "./dist/index.js",
8
- "types": "./dist/index.d.ts",
9
6
  "exports": {
10
7
  ".": {
11
- "import": {
12
- "types": "./dist/index.d.ts",
13
- "default": "./dist/index.js"
14
- },
15
- "require": {
16
- "types": "./dist/index.d.cts",
17
- "default": "./dist/index.cjs"
18
- }
8
+ "types": "./types/index.d.ts",
9
+ "default": "./src/index.js"
19
10
  }
20
11
  },
12
+ "types": "./types/index.d.ts",
21
13
  "files": [
22
- "dist"
14
+ "src",
15
+ "types"
23
16
  ],
24
17
  "scripts": {
25
- "build": "tsup",
26
- "test": "vitest",
27
- "test:run": "vitest run",
28
- "prepublishOnly": "npm run build"
18
+ "test": "vitest --reporter=verbose --run",
19
+ "test:watch": "vitest",
20
+ "prepublishOnly": "npx tsc --declaration --allowJs --emitDeclarationOnly --skipLibCheck --target es2020 --module nodenext --moduleResolution nodenext --strict false --esModuleInterop true --outDir ./types src/index.js"
29
21
  },
30
22
  "keywords": [
31
23
  "queue",
@@ -38,7 +30,6 @@
38
30
  "concurrency",
39
31
  "retry"
40
32
  ],
41
- "author": "",
42
33
  "license": "MIT",
43
34
  "dependencies": {
44
35
  "@prsm/ms": "^1.0.1",
@@ -46,9 +37,8 @@
46
37
  },
47
38
  "devDependencies": {
48
39
  "@types/node": "^22.15.29",
49
- "tsup": "^8.5.0",
50
- "typescript": "^5.8.3",
51
- "vitest": "^3.1.4"
40
+ "typescript": "^5.9.3",
41
+ "vitest": "^3.2.4"
52
42
  },
53
43
  "engines": {
54
44
  "node": ">=18"
package/src/index.js ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./queue.js"
package/src/queue.js ADDED
@@ -0,0 +1,271 @@
1
+ import { createClient } from "redis"
2
+ import { EventEmitter } from "events"
3
+ import { randomUUID } from "crypto"
4
+ import ms from "@prsm/ms"
5
+
6
+ /**
7
+ * @typedef {Object} QueueOptions
8
+ * @property {number} [concurrency] - worker count (default 1)
9
+ * @property {number|string} [delay] - pause between tasks, ms or string like "100ms" (default 0)
10
+ * @property {number|string} [timeout] - max task duration, ms or string like "30s" (default 0, no limit)
11
+ * @property {number} [maxRetries] - attempts before failing (default 3)
12
+ * @property {{concurrency?: number, delay?: number|string, timeout?: number|string, maxRetries?: number}} [groups] - overrides for grouped queues
13
+ * @property {{url?: string, host?: string, port?: number, password?: string}} [redisOptions]
14
+ * @property {number} [cleanupInterval] - ms between empty group cleanup (default 30000, 0 to disable)
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} Task
19
+ * @property {string} uuid
20
+ * @property {any} payload
21
+ * @property {number} createdAt
22
+ * @property {string} [groupKey]
23
+ * @property {number} attempts
24
+ */
25
+
26
+ /**
27
+ * @callback TaskHandler
28
+ * @param {any} payload
29
+ * @param {Task} task
30
+ * @returns {Promise<any>|any}
31
+ */
32
+
33
+ export default class Queue extends EventEmitter {
34
+ /** @param {QueueOptions} [options] */
35
+ constructor(options = {}) {
36
+ super()
37
+
38
+ this._options = {
39
+ concurrency: options.concurrency ?? 1,
40
+ delay: ms(options.delay ?? 0),
41
+ timeout: ms(options.timeout ?? 0),
42
+ maxRetries: options.maxRetries ?? 3,
43
+ groups: {
44
+ concurrency: options.groups?.concurrency ?? options.concurrency ?? 1,
45
+ delay: ms(options.groups?.delay ?? options.delay ?? 0),
46
+ timeout: ms(options.groups?.timeout ?? options.timeout ?? 0),
47
+ maxRetries: options.groups?.maxRetries ?? options.maxRetries ?? 3,
48
+ },
49
+ redisOptions: options.redisOptions ?? {},
50
+ cleanupInterval: options.cleanupInterval ?? 30000,
51
+ }
52
+
53
+ this._handler = null
54
+ this._workers = new Map()
55
+ this._groupWorkers = new Map()
56
+ this._workerClients = []
57
+ this._cleanupTimer = null
58
+ this._inFlight = 0
59
+ this._totalSettled = 0
60
+
61
+ this._redis = createClient(this._options.redisOptions)
62
+ this._redis.on("error", () => {})
63
+ this._readyPromise = this._initialize()
64
+ }
65
+
66
+ /** @returns {Promise<void>} */
67
+ ready() {
68
+ return this._readyPromise
69
+ }
70
+
71
+ /** @returns {number} */
72
+ get inFlight() {
73
+ return this._inFlight
74
+ }
75
+
76
+ /** @param {TaskHandler} handler */
77
+ process(handler) {
78
+ this._handler = handler
79
+ }
80
+
81
+ /**
82
+ * @param {any} payload
83
+ * @returns {Promise<string>}
84
+ */
85
+ async push(payload) {
86
+ const task = { uuid: randomUUID(), payload, createdAt: Date.now(), attempts: 0 }
87
+ await this._redis.lPush("queue:tasks", JSON.stringify(task))
88
+ this.emit("new", { task })
89
+ return task.uuid
90
+ }
91
+
92
+ /**
93
+ * @param {string} key
94
+ * @returns {{ push: (payload: any) => Promise<string> }}
95
+ */
96
+ group(key) {
97
+ return {
98
+ push: async (payload) => {
99
+ const task = { uuid: randomUUID(), payload, createdAt: Date.now(), groupKey: key, attempts: 0 }
100
+ await this._redis.lPush(`queue:groups:${key}`, JSON.stringify(task))
101
+ this.emit("new", { task })
102
+ if (!this._groupWorkers.has(key)) {
103
+ this._groupWorkers.set(key, new Map())
104
+ await this._startGroupWorkers(key)
105
+ }
106
+ return task.uuid
107
+ },
108
+ }
109
+ }
110
+
111
+ /** @returns {Promise<void>} */
112
+ async close() {
113
+ await this._readyPromise.catch(() => {})
114
+ if (this._cleanupTimer) clearInterval(this._cleanupTimer)
115
+ this._workers.clear()
116
+ for (const groupWorkers of this._groupWorkers.values()) groupWorkers.clear()
117
+ this._groupWorkers.clear()
118
+ for (const client of this._workerClients) {
119
+ if (client.isOpen) await client.disconnect()
120
+ }
121
+ this._workerClients = []
122
+ if (this._redis.isOpen) await this._redis.quit()
123
+ }
124
+
125
+ async _initialize() {
126
+ await this._redis.connect()
127
+ await this._startWorkers()
128
+ if (this._options.cleanupInterval > 0) {
129
+ this._cleanupTimer = setInterval(() => this._periodicCleanup(), this._options.cleanupInterval)
130
+ this._cleanupTimer.unref()
131
+ }
132
+ }
133
+
134
+ async _createWorkerClient() {
135
+ const client = this._redis.duplicate()
136
+ client.on("error", () => {})
137
+ await client.connect()
138
+ this._workerClients.push(client)
139
+ return client
140
+ }
141
+
142
+ async _startWorkers() {
143
+ const ready = []
144
+ for (let i = 0; i < this._options.concurrency; i++) {
145
+ ready.push(this._startWorker(`worker-${i}`))
146
+ }
147
+ await Promise.all(ready)
148
+ }
149
+
150
+ async _startGroupWorkers(groupKey) {
151
+ const groupWorkers = this._groupWorkers.get(groupKey)
152
+ const ready = []
153
+ for (let i = 0; i < this._options.groups.concurrency; i++) {
154
+ const workerId = `group-${groupKey}-worker-${i}`
155
+ groupWorkers.set(workerId, true)
156
+ ready.push(this._startGroupWorker(workerId, groupKey))
157
+ }
158
+ await Promise.all(ready)
159
+ }
160
+
161
+ async _startWorker(workerId) {
162
+ this._workers.set(workerId, true)
163
+ const client = await this._createWorkerClient()
164
+ this._runWorkerLoop(workerId, client, "queue:tasks", this._workers, (task) => this._processTask(task))
165
+ }
166
+
167
+ async _startGroupWorker(workerId, groupKey) {
168
+ const groupWorkers = this._groupWorkers.get(groupKey)
169
+ const client = await this._createWorkerClient()
170
+ this._runWorkerLoop(workerId, client, `queue:groups:${groupKey}`, groupWorkers, (task) => this._processGroupTask(task))
171
+ }
172
+
173
+ async _runWorkerLoop(workerId, client, key, activeMap, processFn) {
174
+ const isGrouped = key.startsWith("queue:groups:")
175
+ const delay = isGrouped ? this._options.groups.delay : this._options.delay
176
+
177
+ while (activeMap.get(workerId)) {
178
+ try {
179
+ if (!client.isOpen) break
180
+ const taskData = await client.brPop(key, 1)
181
+ if (taskData) {
182
+ const task = JSON.parse(taskData.element)
183
+ this._inFlight++
184
+ await processFn(task)
185
+ }
186
+ if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay))
187
+ } catch (err) {
188
+ if (err.message?.includes("closed") || err.message?.includes("ClientClosedError")) break
189
+ }
190
+ }
191
+ }
192
+
193
+ async _processTask(task) {
194
+ task.attempts++
195
+ try {
196
+ if (!this._handler) {
197
+ this.emit("complete", { task, result: undefined })
198
+ this._settle()
199
+ return
200
+ }
201
+ const timeoutPromise = this._options.timeout > 0
202
+ ? new Promise((_, reject) => setTimeout(() => reject(new Error("Task timeout")), this._options.timeout))
203
+ : null
204
+ const workPromise = Promise.resolve(this._handler(task.payload, task))
205
+ const result = timeoutPromise ? await Promise.race([workPromise, timeoutPromise]) : await workPromise
206
+ this.emit("complete", { task, result })
207
+ this._settle()
208
+ } catch (error) {
209
+ if (task.attempts < this._options.maxRetries) {
210
+ this.emit("retry", { task, error, attempt: task.attempts })
211
+ this._inFlight--
212
+ await this._redis.lPush("queue:tasks", JSON.stringify(task))
213
+ } else {
214
+ this.emit("failed", { task, error })
215
+ this._settle()
216
+ }
217
+ }
218
+ }
219
+
220
+ async _processGroupTask(task) {
221
+ task.attempts++
222
+ try {
223
+ if (!this._handler) {
224
+ this.emit("complete", { task, result: undefined })
225
+ this._settle()
226
+ return
227
+ }
228
+ const timeoutPromise = this._options.groups.timeout > 0
229
+ ? new Promise((_, reject) => setTimeout(() => reject(new Error("Task timeout")), this._options.groups.timeout))
230
+ : null
231
+ const workPromise = Promise.resolve(this._handler(task.payload, task))
232
+ const result = timeoutPromise ? await Promise.race([workPromise, timeoutPromise]) : await workPromise
233
+ this.emit("complete", { task, result })
234
+ this._settle()
235
+ } catch (error) {
236
+ if (task.attempts < this._options.groups.maxRetries) {
237
+ this.emit("retry", { task, error, attempt: task.attempts })
238
+ this._inFlight--
239
+ await this._redis.lPush(`queue:groups:${task.groupKey}`, JSON.stringify(task))
240
+ } else {
241
+ this.emit("failed", { task, error })
242
+ this._settle()
243
+ }
244
+ }
245
+ }
246
+
247
+ _settle() {
248
+ this._inFlight--
249
+ this._totalSettled++
250
+ if (this._inFlight === 0 && this._totalSettled > 0) this.emit("drain")
251
+ }
252
+
253
+ async _periodicCleanup() {
254
+ try {
255
+ if (!this._redis.isOpen) return
256
+ const groupKeys = Array.from(this._groupWorkers.keys())
257
+ for (const groupKey of groupKeys) {
258
+ const length = await this._redis.lLen(`queue:groups:${groupKey}`)
259
+ if (length === 0) {
260
+ const keyExists = await this._redis.exists(`queue:groups:${groupKey}`)
261
+ if (keyExists) await this._redis.del(`queue:groups:${groupKey}`)
262
+ const groupWorkers = this._groupWorkers.get(groupKey)
263
+ if (groupWorkers) {
264
+ groupWorkers.clear()
265
+ this._groupWorkers.delete(groupKey)
266
+ }
267
+ }
268
+ }
269
+ } catch {}
270
+ }
271
+ }
@@ -0,0 +1 @@
1
+ export { default } from "./queue.js";