lonnymq 0.0.27 → 0.0.29
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 +78 -51
- package/dist/index.cjs +148 -171
- package/dist/index.d.ts +133 -126
- package/dist/index.js +125 -148
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# LonnyMQ
|
|
2
2
|
|
|
3
|
-
A high-performance, multi-tenant PostgreSQL message queue implementation for Node.js/TypeScript.
|
|
3
|
+
A high-performance, multi-tenant PostgreSQL message queue implementation for Node.js/TypeScript. Docs can be found [here](https://tlonny.github.io/lonnymq)
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
@@ -35,14 +35,16 @@ for (const sql of queue.install()) {
|
|
|
35
35
|
for (let ix = 0; ix < 500; ix += 1) {
|
|
36
36
|
await queue.message.create({
|
|
37
37
|
databaseClient,
|
|
38
|
-
content: Buffer.from("Hello")
|
|
39
|
-
lockMs: 30_000
|
|
38
|
+
content: Buffer.from("Hello")
|
|
40
39
|
})
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
// Process messages
|
|
44
43
|
while (true) {
|
|
45
|
-
const dequeueResult = await queue.dequeue({
|
|
44
|
+
const dequeueResult = await queue.dequeue({
|
|
45
|
+
databaseClient,
|
|
46
|
+
lockMs: 30_000
|
|
47
|
+
})
|
|
46
48
|
if (dequeueResult.resultType === "MESSAGE_NOT_AVAILABLE") {
|
|
47
49
|
break
|
|
48
50
|
}
|
|
@@ -87,18 +89,16 @@ await queue
|
|
|
87
89
|
|
|
88
90
|
## Message Creation
|
|
89
91
|
|
|
90
|
-
You can add a message to the queue using the `create` function. By default, messages are assigned to a
|
|
92
|
+
You can add a message to the queue using the `create` function. By default, messages are assigned to a unique channel, resulting in basic FIFO behaviour.
|
|
91
93
|
|
|
92
94
|
```typescript
|
|
93
95
|
await queue.message.create({
|
|
94
96
|
databaseClient,
|
|
95
97
|
content: Buffer.from("Hello, world"),
|
|
96
|
-
lockMs: 30000,
|
|
97
|
-
delayMs: 5000
|
|
98
98
|
})
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
-
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:
|
|
101
|
+
If you need to assign messages to specific channels (for example, to take advantage of fairness, concurrency or rate limiting features), you can specify the channel explicitly:
|
|
102
102
|
|
|
103
103
|
```typescript
|
|
104
104
|
await queue
|
|
@@ -106,22 +106,21 @@ await queue
|
|
|
106
106
|
.message
|
|
107
107
|
.create({
|
|
108
108
|
databaseClient,
|
|
109
|
-
content: Buffer.from("Hello, world")
|
|
110
|
-
lockMs: 30000,
|
|
111
|
-
delayMs: 5000
|
|
109
|
+
content: Buffer.from("Hello, world")
|
|
112
110
|
})
|
|
113
111
|
```
|
|
114
112
|
|
|
115
|
-
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.
|
|
116
|
-
|
|
117
113
|
The `delayMs` parameter is optional and allows you to delay when the message becomes available for processing.
|
|
118
114
|
|
|
119
115
|
## Message Processing
|
|
120
116
|
|
|
121
|
-
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).
|
|
117
|
+
Messages can be fetched for processing by calling `dequeue` on the `Queue` - this locks the message for a specified duration. Once processing is complete, messages must be "finalized" via **deletion** or **deferral** (for further processing in the future).
|
|
122
118
|
|
|
123
119
|
```typescript
|
|
124
|
-
const dequeueResult = await queue.dequeue({
|
|
120
|
+
const dequeueResult = await queue.dequeue({
|
|
121
|
+
databaseClient,
|
|
122
|
+
lockMs: 60_000
|
|
123
|
+
})
|
|
125
124
|
|
|
126
125
|
if (dequeueResult.resultType === "MESSAGE_DEQUEUED") {
|
|
127
126
|
const { message } = dequeueResult
|
|
@@ -157,15 +156,50 @@ if (dequeueResult.resultType === "MESSAGE_DEQUEUED") {
|
|
|
157
156
|
}
|
|
158
157
|
```
|
|
159
158
|
|
|
159
|
+
The `lockMs` parameter on `dequeue()` 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.
|
|
160
|
+
|
|
160
161
|
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.
|
|
161
162
|
|
|
162
163
|
**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.
|
|
163
164
|
|
|
164
|
-
###
|
|
165
|
+
### Extending Message Locks with Heartbeats
|
|
165
166
|
|
|
166
|
-
|
|
167
|
+
For messages that take a long time to process, setting a large initial lock is far from ideal. A crash shortly after message dequeue will result in channel throughput being degraded for a significant time (if the channel is concurrency-constrained). To mitigate this, you can set a short initial lock time that can be periodically renewed during message processing via a heartbeat:
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
const dequeueResult = await queue.dequeue({
|
|
171
|
+
databaseClient,
|
|
172
|
+
lockMs: 30_000
|
|
173
|
+
})
|
|
167
174
|
|
|
168
|
-
|
|
175
|
+
if (dequeueResult.resultType === "MESSAGE_DEQUEUED") {
|
|
176
|
+
const { message } = dequeueResult
|
|
177
|
+
|
|
178
|
+
// Start long-running process
|
|
179
|
+
const longTask = processLongRunningTask(message.content)
|
|
180
|
+
|
|
181
|
+
// Set up heartbeat to extend lock every 20 seconds
|
|
182
|
+
const heartbeatInterval = setInterval(async () => {
|
|
183
|
+
await message.heartbeat({
|
|
184
|
+
databaseClient,
|
|
185
|
+
lockMs: 30_000
|
|
186
|
+
})
|
|
187
|
+
}, 20_000)
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
await longTask
|
|
191
|
+
await message.delete({ databaseClient })
|
|
192
|
+
} catch (error) {
|
|
193
|
+
await message.defer({ databaseClient, delayMs: 60_000 })
|
|
194
|
+
} finally {
|
|
195
|
+
clearInterval(heartbeatInterval)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Graceful Shutdowns and Message Recovery
|
|
201
|
+
|
|
202
|
+
If your program ends unexpectedly, messages that are currently being processed may become "orphaned" in a locked state - causing channel blockages and reducing throughput until the lock expires. 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.
|
|
169
203
|
|
|
170
204
|
## Events
|
|
171
205
|
|
|
@@ -185,21 +219,37 @@ const install = queue.install({ eventChannel: "EVENTS"})
|
|
|
185
219
|
|
|
186
220
|
The simplest approach for processing messages is to call `dequeue` in a loop, backing off with a sleep when no messages are available. The downside of this approach is that we lose reactivity as we increase the polling timeout interval.
|
|
187
221
|
|
|
188
|
-
|
|
222
|
+
```typescript
|
|
223
|
+
// Basic polling approach
|
|
224
|
+
while (true) {
|
|
225
|
+
const result = await queue.dequeue({ databaseClient, lockMs: 30_000 })
|
|
226
|
+
|
|
227
|
+
if (result.resultType === "MESSAGE_NOT_AVAILABLE") {
|
|
228
|
+
await sleep(5_000)
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Process message...
|
|
233
|
+
await processMessage(result.message)
|
|
234
|
+
await result.message.delete({ databaseClient })
|
|
235
|
+
}
|
|
236
|
+
```
|
|
189
237
|
|
|
190
|
-
|
|
238
|
+
To improve reactivity, you can use the events system to track when new messages become available. By listening for `MESSAGE_CREATED` and `MESSAGE_DEFERRED` events and tracking their `delayMs`, you can determine the optimal time to retry dequeuing:
|
|
191
239
|
|
|
192
240
|
```typescript
|
|
193
241
|
// LISTEN/NOTIFY only works with a single connection - not on a connection pool.
|
|
194
242
|
const client = await databaseClient.connect()
|
|
195
243
|
await client.query(`LISTEN "EVENTS"`)
|
|
244
|
+
|
|
245
|
+
let nextWakeTime = Date.now()
|
|
246
|
+
|
|
196
247
|
client.on("notification", (msg) => {
|
|
197
|
-
if (msg.channel === "EVENTS") {
|
|
248
|
+
if (msg.channel === "EVENTS") {
|
|
198
249
|
const event = queueEventDecode(msg.payload as string)
|
|
199
|
-
if(event.eventType === "MESSAGE_CREATED") {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
console.log(`Should wake in ${event.delayMs} ms`)
|
|
250
|
+
if (event.eventType === "MESSAGE_CREATED" || event.eventType === "MESSAGE_DEFERRED") {
|
|
251
|
+
const messageAvailableAt = Date.now() + event.delayMs
|
|
252
|
+
nextWakeTime = Math.min(nextWakeTime, messageAvailableAt)
|
|
203
253
|
}
|
|
204
254
|
}
|
|
205
255
|
})
|
|
@@ -209,28 +259,6 @@ client.on("notification", (msg) => {
|
|
|
209
259
|
|
|
210
260
|
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.
|
|
211
261
|
|
|
212
|
-
```typescript
|
|
213
|
-
const client = await databaseClient.connect()
|
|
214
|
-
await client.query(`LISTEN "EVENTS"`)
|
|
215
|
-
|
|
216
|
-
const wait = (messageId: string) : Promise<void> => {
|
|
217
|
-
return new Promise((resolve) => {
|
|
218
|
-
const handler = (msg) => {
|
|
219
|
-
if (msg.channel === "EVENTS") {
|
|
220
|
-
const event = queueEventDecode(msg.payload as string)
|
|
221
|
-
if (event.eventType === "MESSAGE_DELETED" && event.id === messageId) {
|
|
222
|
-
client.off("notification", handler)
|
|
223
|
-
resolve()
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
client.on("notification", handler)
|
|
228
|
-
})
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
await wait(messageId)
|
|
232
|
-
```
|
|
233
|
-
|
|
234
262
|
## Deadlocks
|
|
235
263
|
|
|
236
264
|
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 ordering with respect to the target channel name:
|
|
@@ -244,6 +272,7 @@ Beyond the actions specified above, it is manifestly **unsafe** to bulk-perform
|
|
|
244
272
|
- Message dequeue
|
|
245
273
|
- Message defer
|
|
246
274
|
- Message delete
|
|
275
|
+
- Message heartbeat
|
|
247
276
|
|
|
248
277
|
## Database Clients
|
|
249
278
|
|
|
@@ -263,12 +292,10 @@ For database clients that don't match the expected interface exactly, LonnyMQ pr
|
|
|
263
292
|
|
|
264
293
|
```typescript
|
|
265
294
|
import { Queue } from "lonnymq"
|
|
266
|
-
import { Pool } from "pg"
|
|
267
295
|
|
|
268
|
-
const
|
|
269
|
-
const queue = new Queue({
|
|
296
|
+
const queue = new Queue<NonCompliantDatabaseClient>({
|
|
270
297
|
schema: "lonny",
|
|
271
|
-
adaptor: (client) => ({
|
|
298
|
+
adaptor: (client : NonCompliantDatabaseClient) => ({
|
|
272
299
|
query: async (sql, params) => {
|
|
273
300
|
// Adapt the client's interface to match DatabaseClient
|
|
274
301
|
const result = await client.executeQuery(sql, params)
|