lonnymq 0.0.7 → 0.0.9
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 +178 -68
- package/dist/index.cjs +232 -187
- package/dist/index.d.ts +26 -8
- package/dist/index.js +232 -187
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
# LonnyMQ
|
|
2
2
|
|
|
3
|
-
A high
|
|
3
|
+
A high-performance, multi-tenant PostgreSQL message queue implementation for Node.js/TypeScript.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
- High throughput message processing
|
|
8
|
+
- Multi-tenant concurrency and capacity constraints
|
|
9
|
+
- Durable message processing with automatic recovery
|
|
10
|
+
- Flexible message processing retries and deferrals
|
|
11
|
+
- Message deduplication
|
|
12
|
+
- Queue operations as part of *existing* database transactions
|
|
13
|
+
- Database client agnostic
|
|
14
|
+
- Granular events via PostgreSQL `NOTIFY`
|
|
15
|
+
- Zero dependencies
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
**Note:** Unlike other queue implementations, LonnyMQ provides direct access to queue methods rather than providing batteries-included Worker/Processor daemons.
|
|
18
18
|
|
|
19
19
|
## Quick Look
|
|
20
20
|
|
|
@@ -27,59 +27,80 @@ databaseClient satisfies DatabaseClient
|
|
|
27
27
|
|
|
28
28
|
const queue = new Queue({ schema: "lonny" })
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
// Run migrations first
|
|
31
|
+
for (const migration of queue.migrations()) {
|
|
32
|
+
await databaseClient.query(migration, [])
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Create messages
|
|
36
|
+
for (let ix = 0; ix < 500; ix += 1) {
|
|
37
|
+
await queue
|
|
32
38
|
.channel("myChannel")
|
|
33
39
|
.message
|
|
34
40
|
.create({
|
|
35
|
-
content: "Hello",
|
|
41
|
+
content: Buffer.from("Hello"),
|
|
36
42
|
databaseClient,
|
|
37
43
|
})
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
|
|
46
|
+
// Process messages
|
|
47
|
+
while (true) {
|
|
41
48
|
const dequeueResult = await queue.dequeue({ databaseClient })
|
|
42
|
-
if(dequeueResult.resultType === "MESSAGE_NOT_AVAILABLE") {
|
|
43
|
-
|
|
49
|
+
if (dequeueResult.resultType === "MESSAGE_NOT_AVAILABLE") {
|
|
50
|
+
const sleepMs = Math.min(1000, dequeueResult.retryMs ?? 5000)
|
|
51
|
+
await new Promise(resolve => setTimeout(resolve, sleepMs))
|
|
44
52
|
continue
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
console.log(dequeueResult.message.content)
|
|
55
|
+
console.log(dequeueResult.message.content.toString())
|
|
48
56
|
await dequeueResult.message.delete({ databaseClient })
|
|
49
57
|
}
|
|
50
58
|
```
|
|
51
59
|
|
|
52
60
|
## Setup & Installation
|
|
53
61
|
|
|
54
|
-
LonnyMQ can be installed from npm
|
|
62
|
+
LonnyMQ can be installed from npm:
|
|
55
63
|
|
|
56
64
|
```bash
|
|
57
65
|
npm install lonnymq
|
|
58
66
|
```
|
|
59
67
|
|
|
60
|
-
Once the package is installed,
|
|
68
|
+
Once the package is installed, you need to install the required database schema. LonnyMQ is agnostic to database client and migration process, providing users with an ordered list of migrations - each containing a unique name and SQL fragments to be executed.
|
|
61
69
|
|
|
62
70
|
```typescript
|
|
63
71
|
const queue = new Queue({ schema: "lonny" })
|
|
64
72
|
const migrations = queue.migrations()
|
|
73
|
+
|
|
74
|
+
// Execute migrations (in a transaction for safety)
|
|
75
|
+
await databaseClient.query("BEGIN")
|
|
76
|
+
try {
|
|
77
|
+
for (const migration of migrations) {
|
|
78
|
+
await databaseClient.query(migration, [])
|
|
79
|
+
}
|
|
80
|
+
await databaseClient.query("COMMIT")
|
|
81
|
+
} catch (error) {
|
|
82
|
+
await databaseClient.query("ROLLBACK")
|
|
83
|
+
throw error
|
|
84
|
+
}
|
|
65
85
|
```
|
|
66
86
|
|
|
67
|
-
|
|
87
|
+
**Note:** Migration SQL is not idempotent and should be executed within a transaction that can be rolled back if an error occurs.
|
|
68
88
|
|
|
69
89
|
## Channels
|
|
70
90
|
|
|
71
|
-
Channels
|
|
91
|
+
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.
|
|
72
92
|
|
|
73
|
-
Channels can be configured with concurrency and
|
|
93
|
+
Channels can be configured with concurrency, capacity, and rate limits by setting their "channel policy":
|
|
74
94
|
|
|
75
95
|
```typescript
|
|
76
96
|
await queue
|
|
77
97
|
.channel("my-channel")
|
|
78
98
|
.policy
|
|
79
99
|
.set({
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
100
|
+
databaseClient,
|
|
101
|
+
maxConcurrency: 1,
|
|
102
|
+
maxSize: 100,
|
|
103
|
+
releaseIntervalMs: 1000
|
|
83
104
|
})
|
|
84
105
|
|
|
85
106
|
// Remove all constraints:
|
|
@@ -89,9 +110,9 @@ await queue
|
|
|
89
110
|
.clear({ databaseClient })
|
|
90
111
|
```
|
|
91
112
|
|
|
92
|
-
## Message
|
|
113
|
+
## Message Creation
|
|
93
114
|
|
|
94
|
-
|
|
115
|
+
You can add a message to the queue and assign it to a particular channel using the `create` function:
|
|
95
116
|
|
|
96
117
|
```typescript
|
|
97
118
|
await queue
|
|
@@ -99,82 +120,171 @@ await queue
|
|
|
99
120
|
.message
|
|
100
121
|
.create({
|
|
101
122
|
databaseClient,
|
|
102
|
-
content: "Hello, world"
|
|
123
|
+
content: Buffer.from("Hello, world"),
|
|
124
|
+
name: "optional-dedup-key",
|
|
125
|
+
lockMs: 30000, // 30 seconds
|
|
126
|
+
delayMs: 5000 // 5 second delay
|
|
103
127
|
})
|
|
104
128
|
```
|
|
105
129
|
|
|
106
|
-
|
|
130
|
+
The `name` argument can be provided for deduplication purposes: if a message that has *never* been dequeued exists with the same name within the same channel, no new message will be created.
|
|
131
|
+
|
|
132
|
+
## Message Processing
|
|
133
|
+
|
|
134
|
+
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
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
|
|
138
|
+
if (dequeueResult.resultType === "MESSAGE_DEQUEUED") {
|
|
139
|
+
const { message } = dequeueResult
|
|
140
|
+
console.log(`Processing message: ${message.id}`)
|
|
141
|
+
console.log(`Content: ${message.content.toString()}`)
|
|
142
|
+
console.log(`Attempts: ${message.numAttempts}`)
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
// Process the message...
|
|
146
|
+
await processMessage(message.content)
|
|
147
|
+
|
|
148
|
+
// Delete on success
|
|
149
|
+
await message.delete({ databaseClient })
|
|
150
|
+
} catch (error) {
|
|
151
|
+
// Defer for retry with updated state
|
|
152
|
+
await message.defer({
|
|
153
|
+
databaseClient,
|
|
154
|
+
delayMs: 30000, // Retry in 30 seconds
|
|
155
|
+
state: Buffer.from(JSON.stringify({ error: error.message }))
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
```
|
|
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.
|
|
107
162
|
|
|
108
|
-
|
|
163
|
+
### Graceful Shutdowns and Message Recovery
|
|
109
164
|
|
|
110
|
-
|
|
165
|
+
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.
|
|
111
166
|
|
|
112
|
-
|
|
167
|
+
That said, despite our best efforts, if we run out of memory, suffer a power loss, or receive a `SIGKILL`, we will be unable to finalize messages that are currently locked. To mitigate this, we set a `lockMs` during message creation (by default this is 1 hour) which limits the maximum amount of time a message can be locked before becoming available again for dequeue. This facility ensures that regardless of the nature of the shutdown, the queue will always recover automatically.
|
|
113
168
|
|
|
114
|
-
|
|
169
|
+
## Events
|
|
115
170
|
|
|
116
|
-
|
|
171
|
+
Using PostgreSQL `NOTIFY`, we can receive a granular stream of queue events:
|
|
117
172
|
|
|
118
|
-
|
|
173
|
+
1. `MESSAGE_CREATED`
|
|
174
|
+
2. `MESSAGE_DEFERRED`
|
|
175
|
+
4. `MESSAGE_DELETED`
|
|
119
176
|
|
|
120
|
-
|
|
177
|
+
To enable this feature, ensure the optional `eventChannel` is defined when constructing the SQL migrations.
|
|
121
178
|
|
|
122
|
-
|
|
179
|
+
### Improving on Polling
|
|
123
180
|
|
|
124
|
-
|
|
181
|
+
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.
|
|
125
182
|
|
|
126
|
-
|
|
183
|
+
To improve reactivity, you can use the `retryMs` returned when failing to dequeue a message. This will either be `null` or tell you how long until the next message becomes available for processing (a message might be deferred for a period of time). Thus, you can tune your sleep to use the minimum of your `retryMs` and a default poll timeout.
|
|
184
|
+
|
|
185
|
+
Unfortunately, this doesn't help in situations where a message is created or deferred while a worker is sleeping. However, by tracking the `delayMs` provided by the `MESSAGE_CREATED` and `MESSAGE_DEFERRED` events, we can determine the minimum amount of time to sleep until a message becomes available.
|
|
127
186
|
|
|
128
187
|
```typescript
|
|
129
188
|
const queue = new Queue({ schema: "lonny" })
|
|
130
|
-
const migrations = queue.migrations({
|
|
189
|
+
const migrations = queue.migrations({ eventChannel: "EVENTS" })
|
|
131
190
|
|
|
132
191
|
// LISTEN/NOTIFY only works with a single connection - not on a connection pool.
|
|
133
|
-
const client = await
|
|
134
|
-
await client.query(`LISTEN "
|
|
192
|
+
const client = await databaseClient.connect()
|
|
193
|
+
await client.query(`LISTEN "EVENTS"`)
|
|
135
194
|
client.on("notification", (msg) => {
|
|
136
|
-
if (msg.channel ===
|
|
137
|
-
const
|
|
138
|
-
|
|
195
|
+
if (msg.channel === "EVENTS") {}
|
|
196
|
+
const event = queueEventDecode(msg.payload as string)
|
|
197
|
+
if(event.eventType === "MESSAGE_CREATED") {
|
|
198
|
+
console.log(`Should wake in ${event.delayMs} ms`)
|
|
199
|
+
} else if(event.eventType === "MESSAGE_DEFERRED") {
|
|
200
|
+
console.log(`Should wake in ${event.delayMs} ms`)
|
|
201
|
+
}
|
|
139
202
|
}
|
|
140
203
|
})
|
|
141
204
|
```
|
|
142
205
|
|
|
206
|
+
### Waiting for Job Completion
|
|
207
|
+
|
|
208
|
+
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 channels or message names, you can implement blocking operations that wait for background work to finish.
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
// Worker process
|
|
212
|
+
const client = await databaseClient.connect()
|
|
213
|
+
await client.query(`LISTEN "EVENTS"`)
|
|
214
|
+
|
|
215
|
+
const wait = (messageId: string) : Promise<void> => {
|
|
216
|
+
return new Promise((resolve) => {
|
|
217
|
+
const handler = (msg) => {
|
|
218
|
+
if (msg.channel === "EVENTS") {
|
|
219
|
+
const event = queueEventDecode(msg.payload as string)
|
|
220
|
+
if (event.eventType === "MESSAGE_DELETED" &&
|
|
221
|
+
event.channelName === "background-jobs" &&
|
|
222
|
+
event.messageName === messageId) {
|
|
223
|
+
client.off("notification", handler)
|
|
224
|
+
resolve()
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
client.on("notification", handler)
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
await wait(messageId)
|
|
233
|
+
```
|
|
234
|
+
|
|
143
235
|
## Deadlocks
|
|
144
236
|
|
|
145
|
-
If all queue actions are isolated to their own transaction, there is
|
|
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 and message name (if provided):
|
|
146
238
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
239
|
+
- Message create
|
|
240
|
+
- Channel policy set
|
|
241
|
+
- Channel policy clear
|
|
150
242
|
|
|
151
243
|
Beyond the actions specified above, it is manifestly **unsafe** to bulk-perform any of the remaining actions within a single transaction. Each of these actions should be isolated within their **own** transaction:
|
|
152
244
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
245
|
+
- Message dequeue
|
|
246
|
+
- Message defer
|
|
247
|
+
- Message delete
|
|
248
|
+
|
|
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.
|
|
156
252
|
|
|
157
|
-
|
|
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.
|
|
158
256
|
|
|
159
257
|
```typescript
|
|
160
258
|
const batch = queue.batch()
|
|
161
|
-
```
|
|
162
259
|
|
|
163
|
-
|
|
260
|
+
batch.channel("user-123").message.create({
|
|
261
|
+
content: Buffer.from("Welcome email")
|
|
262
|
+
})
|
|
164
263
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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()
|
|
174
276
|
|
|
175
|
-
```typescript
|
|
176
277
|
await batch.execute({ databaseClient })
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Database Clients
|
|
281
|
+
|
|
282
|
+
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:
|
|
177
283
|
|
|
178
|
-
|
|
179
|
-
|
|
284
|
+
```typescript
|
|
285
|
+
interface DatabaseClient {
|
|
286
|
+
query(sql: string, params: Array<unknown>): Promise<{
|
|
287
|
+
rows: Array<Record<string, unknown>>
|
|
288
|
+
}>
|
|
289
|
+
}
|
|
180
290
|
```
|