lonnymq 0.0.18 → 0.0.20
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 +118 -44
- package/dist/index.cjs +83 -134
- package/dist/index.d.ts +76 -62
- package/dist/index.js +55 -106
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,12 +5,11 @@ A high-performance, multi-tenant PostgreSQL message queue implementation for Nod
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- High throughput message processing
|
|
8
|
-
- Multi-tenant concurrency and
|
|
8
|
+
- Multi-tenant concurrency and rate limits
|
|
9
9
|
- Durable message processing with automatic recovery
|
|
10
10
|
- Flexible message processing retries and deferrals
|
|
11
|
-
- Message deduplication
|
|
12
11
|
- Queue operations as part of *existing* database transactions
|
|
13
|
-
- Database client agnostic
|
|
12
|
+
- Database client agnostic with optional adapters
|
|
14
13
|
- Granular events via PostgreSQL `NOTIFY`
|
|
15
14
|
- Zero dependencies
|
|
16
15
|
|
|
@@ -34,22 +33,18 @@ for (const migration of queue.migrations()) {
|
|
|
34
33
|
|
|
35
34
|
// Create messages
|
|
36
35
|
for (let ix = 0; ix < 500; ix += 1) {
|
|
37
|
-
await queue
|
|
38
|
-
|
|
39
|
-
.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
databaseClient,
|
|
43
|
-
})
|
|
36
|
+
await queue.message.create({
|
|
37
|
+
databaseClient,
|
|
38
|
+
content: Buffer.from("Hello"),
|
|
39
|
+
lockMs: 30_000
|
|
40
|
+
})
|
|
44
41
|
}
|
|
45
42
|
|
|
46
43
|
// Process messages
|
|
47
44
|
while (true) {
|
|
48
45
|
const dequeueResult = await queue.dequeue({ databaseClient })
|
|
49
46
|
if (dequeueResult.resultType === "MESSAGE_NOT_AVAILABLE") {
|
|
50
|
-
|
|
51
|
-
await new Promise(resolve => setTimeout(resolve, sleepMs))
|
|
52
|
-
continue
|
|
47
|
+
break
|
|
53
48
|
}
|
|
54
49
|
|
|
55
50
|
console.log(dequeueResult.message.content.toString())
|
|
@@ -90,7 +85,7 @@ try {
|
|
|
90
85
|
|
|
91
86
|
Channels provide LonnyMQ's multi-tenancy support. They can be considered lightweight sub-queues that are read from in round-robin fashion. There is no performance penalty for using large numbers of channels, so they can be assigned on a highly granular basis (e.g., per-user) to ensure work is scheduled fairly.
|
|
92
87
|
|
|
93
|
-
Channels can be configured with concurrency
|
|
88
|
+
Channels can be configured with concurrency and rate limits by setting their "channel policy":
|
|
94
89
|
|
|
95
90
|
```typescript
|
|
96
91
|
await queue
|
|
@@ -99,7 +94,6 @@ await queue
|
|
|
99
94
|
.set({
|
|
100
95
|
databaseClient,
|
|
101
96
|
maxConcurrency: 1,
|
|
102
|
-
maxSize: 100,
|
|
103
97
|
releaseIntervalMs: 1000
|
|
104
98
|
})
|
|
105
99
|
|
|
@@ -112,7 +106,18 @@ await queue
|
|
|
112
106
|
|
|
113
107
|
## Message Creation
|
|
114
108
|
|
|
115
|
-
You can add a message to the queue
|
|
109
|
+
You can add a message to the queue using the `create` function. By default, messages are assigned to a random channel, ensuring fair distribution:
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
await queue.message.create({
|
|
113
|
+
databaseClient,
|
|
114
|
+
content: Buffer.from("Hello, world"),
|
|
115
|
+
lockMs: 30000,
|
|
116
|
+
delayMs: 5000
|
|
117
|
+
})
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
If you need to assign messages to specific channels (for example, to take advantage of concurrency or rate limiting features), you can specify the channel explicitly:
|
|
116
121
|
|
|
117
122
|
```typescript
|
|
118
123
|
await queue
|
|
@@ -121,25 +126,27 @@ await queue
|
|
|
121
126
|
.create({
|
|
122
127
|
databaseClient,
|
|
123
128
|
content: Buffer.from("Hello, world"),
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
delayMs: 5000 // 5 second delay
|
|
129
|
+
lockMs: 30000,
|
|
130
|
+
delayMs: 5000
|
|
127
131
|
})
|
|
128
132
|
```
|
|
129
133
|
|
|
130
|
-
The `
|
|
134
|
+
The `lockMs` parameter is required and specifies how long a message will remain exclusively locked after being dequeued. While locked, the message is **not available** for subsequent `dequeue()` calls, preventing duplicate processing. If your process crashes or takes longer than expected, the message will automatically become available for dequeue again after the lock expires.
|
|
135
|
+
|
|
136
|
+
The `delayMs` parameter is optional and allows you to delay when the message becomes available for processing.
|
|
131
137
|
|
|
132
138
|
## Message Processing
|
|
133
139
|
|
|
134
140
|
Messages can be fetched for processing by calling `dequeue` on the `Queue` - this locks the message. Once processing is complete, messages must be "finalized" via **deletion** or **deferral** (for further processing in the future).
|
|
135
141
|
|
|
136
142
|
```typescript
|
|
143
|
+
const dequeueResult = await queue.dequeue({ databaseClient })
|
|
137
144
|
|
|
138
145
|
if (dequeueResult.resultType === "MESSAGE_DEQUEUED") {
|
|
139
146
|
const { message } = dequeueResult
|
|
140
147
|
console.log(`Processing message: ${message.id}`)
|
|
141
148
|
console.log(`Content: ${message.content.toString()}`)
|
|
142
|
-
console.log(`
|
|
149
|
+
console.log(`State: ${message.state?.toString()}`)
|
|
143
150
|
|
|
144
151
|
try {
|
|
145
152
|
// Process the message...
|
|
@@ -148,18 +155,61 @@ if (dequeueResult.resultType === "MESSAGE_DEQUEUED") {
|
|
|
148
155
|
// Delete on success
|
|
149
156
|
await message.delete({ databaseClient })
|
|
150
157
|
} catch (error) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
databaseClient
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
158
|
+
if (message.numAttempts >= 5) {
|
|
159
|
+
// Too many retries, delete permanently
|
|
160
|
+
await message.delete({ databaseClient })
|
|
161
|
+
} else {
|
|
162
|
+
// Defer for retry with exponential backoff and updated state
|
|
163
|
+
const backoffMs = Math.pow(2, message.numAttempts) * 1000
|
|
164
|
+
await message.defer({
|
|
165
|
+
databaseClient,
|
|
166
|
+
delayMs: backoffMs,
|
|
167
|
+
state: Buffer.from(JSON.stringify({
|
|
168
|
+
error: error.message,
|
|
169
|
+
lastAttempt: new Date().toISOString()
|
|
170
|
+
}))
|
|
171
|
+
})
|
|
172
|
+
}
|
|
157
173
|
}
|
|
174
|
+
} else {
|
|
175
|
+
console.log("No messages available")
|
|
158
176
|
}
|
|
159
177
|
```
|
|
160
178
|
|
|
161
179
|
When deferring a message, you can optionally specify `delayMs` and `state` arguments. The `delayMs` parameter tells the queue how long to wait before making the message available for reprocessing, and `state` allows you to "save your work" and implement durable and/or repeating/scheduled tasks.
|
|
162
180
|
|
|
181
|
+
**Note:** The above shows just one processing pattern (defer on failure with retry limits). You have complete flexibility in how you handle message processing - you might delete messages immediately, defer them unconditionally, implement different retry strategies based on error types, or use the message metadata (attempts, state, channel) to make sophisticated routing decisions.
|
|
182
|
+
|
|
183
|
+
### Message Heartbeats
|
|
184
|
+
|
|
185
|
+
For long-running message processing, you can use the heartbeat functionality to extend the lock time beyond the initial `lockMs` value. This is useful when you need to process a message for longer than originally anticipated but want to keep a shorter default lock time for faster recovery.
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
const dequeueResult = await queue.dequeue({ databaseClient })
|
|
189
|
+
|
|
190
|
+
if (dequeueResult.resultType === "MESSAGE_DEQUEUED") {
|
|
191
|
+
const { message } = dequeueResult
|
|
192
|
+
|
|
193
|
+
// Start a long-running process
|
|
194
|
+
const intervalId = setInterval(async () => {
|
|
195
|
+
// Send heartbeat every 10 seconds to keep the message locked
|
|
196
|
+
await message.heartbeat({ databaseClient })
|
|
197
|
+
}, 10000)
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
// Process the message (this might take a long time)
|
|
201
|
+
await longRunningProcessMessage(message.content)
|
|
202
|
+
await message.delete({ databaseClient })
|
|
203
|
+
} catch (error) {
|
|
204
|
+
await message.defer({ databaseClient, delayMs: 30000 })
|
|
205
|
+
} finally {
|
|
206
|
+
clearInterval(intervalId)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Heartbeats reset the unlock time to the current time plus the original `lockMs` value.
|
|
212
|
+
|
|
163
213
|
### Graceful Shutdowns and Message Recovery
|
|
164
214
|
|
|
165
215
|
If your program ends unexpectedly, messages that are currently being processed may become "orphaned" in a locked state - causing channel blockages and reducing throughput. To mitigate this problem, it's essential that you shut down gracefully by catching unhandled exceptions and signals (i.e., `SIGINT`/`SIGTERM`) and finalize all outstanding messages before exiting.
|
|
@@ -206,7 +256,7 @@ client.on("notification", (msg) => {
|
|
|
206
256
|
|
|
207
257
|
### Waiting for Job Completion
|
|
208
258
|
|
|
209
|
-
The `MESSAGE_DELETED` event can be used to create coordination patterns where one part of your application waits for an unrelated job to complete. By listening for deletion events on specific
|
|
259
|
+
The `MESSAGE_DELETED` event can be used to create coordination patterns where one part of your application waits for an unrelated job to complete. By listening for deletion events on specific message IDs, you can implement blocking operations that wait for background work to finish.
|
|
210
260
|
|
|
211
261
|
```typescript
|
|
212
262
|
const client = await databaseClient.connect()
|
|
@@ -217,9 +267,7 @@ const wait = (messageId: string) : Promise<void> => {
|
|
|
217
267
|
const handler = (msg) => {
|
|
218
268
|
if (msg.channel === "EVENTS") {
|
|
219
269
|
const event = queueEventDecode(msg.payload as string)
|
|
220
|
-
if (event.eventType === "MESSAGE_DELETED" &&
|
|
221
|
-
event.channelName === "background-jobs" &&
|
|
222
|
-
event.messageName === messageId) {
|
|
270
|
+
if (event.eventType === "MESSAGE_DELETED" && event.id === messageId) {
|
|
223
271
|
client.off("notification", handler)
|
|
224
272
|
resolve()
|
|
225
273
|
}
|
|
@@ -234,7 +282,7 @@ await wait(messageId)
|
|
|
234
282
|
|
|
235
283
|
## Deadlocks
|
|
236
284
|
|
|
237
|
-
If all queue actions are isolated to their own transaction, there is zero risk of deadlocks occurring. That being said, it is *possible* to safely bulk-perform the following actions within a single transaction if we ensure they are performed in a consistent lexicographical ordering with respect to channel name
|
|
285
|
+
If all queue actions are isolated to their own transaction, there is zero risk of deadlocks occurring. That being said, it is *possible* to safely bulk-perform the following actions within a single transaction if we ensure they are performed in a consistent lexicographical ordering with respect to channel name:
|
|
238
286
|
|
|
239
287
|
- Message create
|
|
240
288
|
- Channel policy set
|
|
@@ -245,38 +293,43 @@ Beyond the actions specified above, it is manifestly **unsafe** to bulk-perform
|
|
|
245
293
|
- Message dequeue
|
|
246
294
|
- Message defer
|
|
247
295
|
- Message delete
|
|
296
|
+
- Message heartbeat
|
|
248
297
|
|
|
249
298
|
## Batching Operations
|
|
250
299
|
|
|
251
300
|
When you need to perform multiple safe operations (message creation and channel policy changes) within a single transaction, LonnyMQ provides a batching mechanism that automatically handles proper ordering to prevent deadlocks.
|
|
252
301
|
|
|
253
|
-
The batch interface mirrors
|
|
254
|
-
|
|
255
|
-
The batch system ensures that all operations are executed in a consistent lexicographical order based on channel name and message name, eliminating the possibility of deadlocks when multiple workers are performing bulk operations simultaneously.
|
|
302
|
+
The batch interface mirrors a subset of the queue interface, supporting only the operations that are safe to perform together: message creation and channel policy management.
|
|
256
303
|
|
|
257
304
|
```typescript
|
|
258
305
|
const batch = queue.batch()
|
|
259
306
|
|
|
260
|
-
|
|
261
|
-
|
|
307
|
+
// Create messages (assigned to random channels)
|
|
308
|
+
batch.message.create({
|
|
309
|
+
content: Buffer.from("Welcome email"),
|
|
310
|
+
lockMs: 30000
|
|
262
311
|
})
|
|
263
312
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
313
|
+
// Create messages on specific channels
|
|
314
|
+
batch.channel("user-notifications").message.create({
|
|
315
|
+
content: Buffer.from("User signup notification"),
|
|
316
|
+
lockMs: 60000
|
|
268
317
|
})
|
|
269
318
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
319
|
+
// Set channel policies
|
|
320
|
+
batch.channel("high-priority").policy.set({
|
|
321
|
+
maxConcurrency: 5,
|
|
322
|
+
releaseIntervalMs: 100
|
|
273
323
|
})
|
|
274
324
|
|
|
325
|
+
// Clear channel policies
|
|
275
326
|
batch.channel("analytics").policy.clear()
|
|
276
327
|
|
|
277
328
|
await batch.execute({ databaseClient })
|
|
278
329
|
```
|
|
279
330
|
|
|
331
|
+
The batch system ensures all operations are executed in a consistent lexicographical order, eliminating the possibility of deadlocks when multiple workers are performing bulk operations simultaneously.
|
|
332
|
+
|
|
280
333
|
## Database Clients
|
|
281
334
|
|
|
282
335
|
LonnyMQ is designed to be database client agnostic, requiring only a minimal interface that most PostgreSQL clients already implement. Your database client must provide a single `query` method with this signature:
|
|
@@ -287,4 +340,25 @@ interface DatabaseClient {
|
|
|
287
340
|
rows: Array<Record<string, unknown>>
|
|
288
341
|
}>
|
|
289
342
|
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Database Client Adapters
|
|
346
|
+
|
|
347
|
+
For database clients that don't match the expected interface exactly, LonnyMQ provides an adapter system to improve the developer experience. You can provide an adapter function when creating a Queue:
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
import { Queue } from "lonnymq"
|
|
351
|
+
import { Pool } from "pg"
|
|
352
|
+
|
|
353
|
+
const customClient = new SomeOtherPostgresClient()
|
|
354
|
+
const queue = new Queue({
|
|
355
|
+
schema: "lonny",
|
|
356
|
+
adaptor: (client) => ({
|
|
357
|
+
query: async (sql, params) => {
|
|
358
|
+
// Adapt the client's interface to match DatabaseClient
|
|
359
|
+
const result = await client.executeQuery(sql, params)
|
|
360
|
+
return { rows: result.data }
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
})
|
|
290
364
|
```
|