lonnymq 0.0.19 → 0.0.21
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 +71 -63
- package/dist/index.cjs +96 -144
- package/dist/index.d.ts +27 -95
- package/dist/index.js +96 -144
- 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,31 @@ 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
|
+
|
|
163
183
|
### Graceful Shutdowns and Message Recovery
|
|
164
184
|
|
|
165
185
|
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 +226,7 @@ client.on("notification", (msg) => {
|
|
|
206
226
|
|
|
207
227
|
### Waiting for Job Completion
|
|
208
228
|
|
|
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
|
|
229
|
+
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
230
|
|
|
211
231
|
```typescript
|
|
212
232
|
const client = await databaseClient.connect()
|
|
@@ -217,9 +237,7 @@ const wait = (messageId: string) : Promise<void> => {
|
|
|
217
237
|
const handler = (msg) => {
|
|
218
238
|
if (msg.channel === "EVENTS") {
|
|
219
239
|
const event = queueEventDecode(msg.payload as string)
|
|
220
|
-
if (event.eventType === "MESSAGE_DELETED" &&
|
|
221
|
-
event.channelName === "background-jobs" &&
|
|
222
|
-
event.messageName === messageId) {
|
|
240
|
+
if (event.eventType === "MESSAGE_DELETED" && event.id === messageId) {
|
|
223
241
|
client.off("notification", handler)
|
|
224
242
|
resolve()
|
|
225
243
|
}
|
|
@@ -234,7 +252,7 @@ await wait(messageId)
|
|
|
234
252
|
|
|
235
253
|
## Deadlocks
|
|
236
254
|
|
|
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
|
|
255
|
+
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:
|
|
238
256
|
|
|
239
257
|
- Message create
|
|
240
258
|
- Channel policy set
|
|
@@ -246,37 +264,6 @@ Beyond the actions specified above, it is manifestly **unsafe** to bulk-perform
|
|
|
246
264
|
- Message defer
|
|
247
265
|
- Message delete
|
|
248
266
|
|
|
249
|
-
## Batching Operations
|
|
250
|
-
|
|
251
|
-
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
|
-
|
|
253
|
-
The batch interface mirrors the main queue interface - you call `queue.batch()` to create a batch, then use the same `.channel(name).message.create()` and `.channel(name).policy.set/clear()` methods you're already familiar with. The key difference is that batch operations are queued up and don't execute immediately.
|
|
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.
|
|
256
|
-
|
|
257
|
-
```typescript
|
|
258
|
-
const batch = queue.batch()
|
|
259
|
-
|
|
260
|
-
batch.channel("user-123").message.create({
|
|
261
|
-
content: Buffer.from("Welcome email")
|
|
262
|
-
})
|
|
263
|
-
|
|
264
|
-
batch.channel("user-123").policy.set({
|
|
265
|
-
maxConcurrency: 5,
|
|
266
|
-
maxSize: 1000,
|
|
267
|
-
releaseIntervalMs: 100
|
|
268
|
-
})
|
|
269
|
-
|
|
270
|
-
batch.channel("notifications").message.create({
|
|
271
|
-
content: Buffer.from("Daily digest"),
|
|
272
|
-
name: "daily-digest-2025-08-29"
|
|
273
|
-
})
|
|
274
|
-
|
|
275
|
-
batch.channel("analytics").policy.clear()
|
|
276
|
-
|
|
277
|
-
await batch.execute({ databaseClient })
|
|
278
|
-
```
|
|
279
|
-
|
|
280
267
|
## Database Clients
|
|
281
268
|
|
|
282
269
|
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 +274,25 @@ interface DatabaseClient {
|
|
|
287
274
|
rows: Array<Record<string, unknown>>
|
|
288
275
|
}>
|
|
289
276
|
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Database Client Adapters
|
|
280
|
+
|
|
281
|
+
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:
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
import { Queue } from "lonnymq"
|
|
285
|
+
import { Pool } from "pg"
|
|
286
|
+
|
|
287
|
+
const customClient = new SomeOtherPostgresClient()
|
|
288
|
+
const queue = new Queue({
|
|
289
|
+
schema: "lonny",
|
|
290
|
+
adaptor: (client) => ({
|
|
291
|
+
query: async (sql, params) => {
|
|
292
|
+
// Adapt the client's interface to match DatabaseClient
|
|
293
|
+
const result = await client.executeQuery(sql, params)
|
|
294
|
+
return { rows: result.data }
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
})
|
|
290
298
|
```
|