lonnymq 0.0.29 → 1.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/LICENSE +21 -0
- package/README.md +95 -45
- package/dist/index.cjs +225 -203
- package/dist/index.d.ts +133 -149
- package/dist/index.js +248 -226
- package/package.json +1 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Tim Lonsdale
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -6,52 +6,70 @@ A high-performance, multi-tenant PostgreSQL message queue implementation for Nod
|
|
|
6
6
|
|
|
7
7
|
- High throughput message processing
|
|
8
8
|
- Multi-tenant concurrency and rate limits
|
|
9
|
-
- Durable message processing
|
|
10
|
-
-
|
|
9
|
+
- Durable message processing
|
|
10
|
+
- Support for retries, recovery and custom back-off strategies
|
|
11
|
+
- Message prioritisation
|
|
11
12
|
- Queue operations as part of *existing* database transactions
|
|
12
13
|
- Database client agnostic with optional adapters
|
|
13
|
-
- Granular events via PostgreSQL `NOTIFY`
|
|
14
|
+
- Granular events via PostgreSQL `NOTIFY` (Avoid relying on polling to fetch new messages)
|
|
14
15
|
- Zero dependencies
|
|
15
16
|
|
|
16
17
|
**Note:** Unlike other queue implementations, LonnyMQ provides direct access to queue methods rather than providing batteries-included Worker/Processor daemons.
|
|
17
18
|
|
|
19
|
+
### Benchmarking
|
|
20
|
+
|
|
21
|
+
With the following parameters:
|
|
22
|
+
- Everything running in a single Bun instance
|
|
23
|
+
- A locally hosted postgres database
|
|
24
|
+
- The code as-is below in "Quick Look", but with 8 producers and 8 consumers
|
|
25
|
+
|
|
26
|
+
A message throughput of **~1,800** messages per second is observed.
|
|
27
|
+
|
|
18
28
|
## Quick Look
|
|
19
29
|
|
|
20
30
|
```typescript
|
|
21
|
-
import { Queue
|
|
31
|
+
import { Queue } from "lonnymq"
|
|
22
32
|
import { Pool } from "pg"
|
|
23
33
|
|
|
24
34
|
const databaseClient = new Pool({ connectionString: process.env.DATABASE_URL })
|
|
25
|
-
databaseClient satisfies DatabaseClient
|
|
26
|
-
|
|
27
35
|
const queue = new Queue({ schema: "lonny" })
|
|
36
|
+
const content = Buffer.from("hello world")
|
|
28
37
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
const produce = async () => {
|
|
39
|
+
while (true) {
|
|
40
|
+
const result = await queue.message.create({
|
|
41
|
+
databaseClient: pool,
|
|
42
|
+
content: content
|
|
43
|
+
})
|
|
33
44
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
databaseClient,
|
|
38
|
-
content: Buffer.from("Hello")
|
|
39
|
-
})
|
|
40
|
-
}
|
|
45
|
+
if (result.resultType !== "MESSAGE_CREATED") {
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
41
48
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
databaseClient,
|
|
46
|
-
lockMs: 30_000
|
|
47
|
-
})
|
|
48
|
-
if (dequeueResult.resultType === "MESSAGE_NOT_AVAILABLE") {
|
|
49
|
-
break
|
|
49
|
+
if (result.channelSize > 1_000) {
|
|
50
|
+
await sleep(result.channelSize)
|
|
51
|
+
}
|
|
50
52
|
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const consume = async () => {
|
|
56
|
+
while (true) {
|
|
57
|
+
const result = await queue.dequeue({
|
|
58
|
+
databaseClient: pool,
|
|
59
|
+
lockMs: 1_000
|
|
60
|
+
})
|
|
51
61
|
|
|
52
|
-
|
|
53
|
-
|
|
62
|
+
if (result.resultType === "MESSAGE_DEQUEUED") {
|
|
63
|
+
await result.message.delete({ databaseClient: pool })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
54
66
|
}
|
|
67
|
+
|
|
68
|
+
// Kick off producer loop
|
|
69
|
+
produce()
|
|
70
|
+
|
|
71
|
+
// Kick off consumer loop
|
|
72
|
+
consume()
|
|
55
73
|
```
|
|
56
74
|
|
|
57
75
|
## Setup & Installation
|
|
@@ -62,13 +80,26 @@ LonnyMQ can be installed from npm:
|
|
|
62
80
|
npm install lonnymq
|
|
63
81
|
```
|
|
64
82
|
|
|
65
|
-
Once the package is installed, the queue needs to be "installed" to a postgres schema. The requisite SQL for this can be generated via:
|
|
83
|
+
Once the package is installed, the queue needs to be "installed" to a postgres schema. The requisite SQL for this can be generated via:
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
const sqlCommands = queue.install({
|
|
87
|
+
eventChannel: "lonnymq-events",
|
|
88
|
+
|
|
89
|
+
for(const sql of sqlCommands) {
|
|
90
|
+
await pool
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
_Optional_ parameters can be passed in to alter default queue behaviour/semantics. If an `eventChannel` is provided, LonnyMQ will publish queue events to the channel provided via `NOTIFY`.
|
|
96
|
+
|
|
66
97
|
|
|
67
98
|
## Channels
|
|
68
99
|
|
|
69
100
|
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.
|
|
70
101
|
|
|
71
|
-
Channels can be configured with concurrency and
|
|
102
|
+
Channels can be configured with rate limiting, concurrency and capacity constraints by setting their _channel policy_:
|
|
72
103
|
|
|
73
104
|
```typescript
|
|
74
105
|
await queue
|
|
@@ -77,7 +108,8 @@ await queue
|
|
|
77
108
|
.set({
|
|
78
109
|
databaseClient,
|
|
79
110
|
maxConcurrency: 1,
|
|
80
|
-
releaseIntervalMs: 1000
|
|
111
|
+
releaseIntervalMs: 1000,
|
|
112
|
+
maxSize: 1_000
|
|
81
113
|
})
|
|
82
114
|
|
|
83
115
|
// Remove all constraints:
|
|
@@ -110,7 +142,25 @@ await queue
|
|
|
110
142
|
})
|
|
111
143
|
```
|
|
112
144
|
|
|
113
|
-
|
|
145
|
+
By default, created messages are immediately available for processing. To delay availability you can pass a `dequeueAt` unix timestamp (in milliseconds) that specifies the earliest time the message may be dequeued.
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
await queue.message.create({
|
|
149
|
+
databaseClient,
|
|
150
|
+
content: Buffer.from("Hello, world"),
|
|
151
|
+
dequeueAt: Date.now() + 5_000 // 5s in the future
|
|
152
|
+
})
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
N.B. `dequeueAt` is compared against the _database_ clock.
|
|
156
|
+
|
|
157
|
+
### Message Prioritization
|
|
158
|
+
|
|
159
|
+
LonnyMQ doesn't use an explicit message priority field for performance reasons. In short, there is no way to find the highest priority message that is also available for dequeue for a particular channel without some degree of linear scanning in the worst case vs. simply using a logarithmic index lookup.
|
|
160
|
+
|
|
161
|
+
However, messages that _are_ available for processing are dequeued from their channels in order of their `dequeueAt` values (oldest first). Thus, by overloading the semantics of the `dequeueAt` field and using _historic_ unix timestamps (i.e. `0`, `1`, `2`, etc.) - messages can trivially be prioritized within a channel.
|
|
162
|
+
|
|
163
|
+
N.B. there is no way to _globally_ prioritize a message - channels will _always_ be accessed in a round-robin fashion.
|
|
114
164
|
|
|
115
165
|
## Message Processing
|
|
116
166
|
|
|
@@ -140,10 +190,10 @@ if (dequeueResult.resultType === "MESSAGE_DEQUEUED") {
|
|
|
140
190
|
await message.delete({ databaseClient })
|
|
141
191
|
} else {
|
|
142
192
|
// Defer for retry with exponential backoff and updated state
|
|
143
|
-
const backoffMs = Math.pow(2, message.numAttempts) *
|
|
193
|
+
const backoffMs = Math.pow(2, message.numAttempts) * 1_000
|
|
144
194
|
await message.defer({
|
|
145
195
|
databaseClient,
|
|
146
|
-
|
|
196
|
+
dequeueAt: Date.now() + backoffMs,
|
|
147
197
|
state: Buffer.from(JSON.stringify({
|
|
148
198
|
error: error.message,
|
|
149
199
|
lastAttempt: new Date().toISOString()
|
|
@@ -158,7 +208,7 @@ if (dequeueResult.resultType === "MESSAGE_DEQUEUED") {
|
|
|
158
208
|
|
|
159
209
|
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
210
|
|
|
161
|
-
When
|
|
211
|
+
When a message is deferred it becomes immediately available for re-processing unless you supply a `dequeueAt` timestamp.
|
|
162
212
|
|
|
163
213
|
**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.
|
|
164
214
|
|
|
@@ -190,7 +240,10 @@ if (dequeueResult.resultType === "MESSAGE_DEQUEUED") {
|
|
|
190
240
|
await longTask
|
|
191
241
|
await message.delete({ databaseClient })
|
|
192
242
|
} catch (error) {
|
|
193
|
-
await message.defer({
|
|
243
|
+
await message.defer({
|
|
244
|
+
databaseClient,
|
|
245
|
+
dequeueAt: Date.now() + 60_000
|
|
246
|
+
})
|
|
194
247
|
} finally {
|
|
195
248
|
clearInterval(heartbeatInterval)
|
|
196
249
|
}
|
|
@@ -199,7 +252,7 @@ if (dequeueResult.resultType === "MESSAGE_DEQUEUED") {
|
|
|
199
252
|
|
|
200
253
|
### Graceful Shutdowns and Message Recovery
|
|
201
254
|
|
|
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
|
|
255
|
+
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 locks expire. 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.
|
|
203
256
|
|
|
204
257
|
## Events
|
|
205
258
|
|
|
@@ -209,8 +262,6 @@ Using PostgreSQL `NOTIFY`, we can receive a granular stream of queue events:
|
|
|
209
262
|
2. `MESSAGE_DEFERRED`
|
|
210
263
|
4. `MESSAGE_DELETED`
|
|
211
264
|
|
|
212
|
-
To enable this feature, ensure the optional `eventChannel` is defined when generating the installation SQL.
|
|
213
|
-
|
|
214
265
|
```typescript
|
|
215
266
|
const install = queue.install({ eventChannel: "EVENTS"})
|
|
216
267
|
```
|
|
@@ -235,7 +286,7 @@ while (true) {
|
|
|
235
286
|
}
|
|
236
287
|
```
|
|
237
288
|
|
|
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 `
|
|
289
|
+
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 `dequeueAt` values, you can determine the optimal time to retry dequeuing:
|
|
239
290
|
|
|
240
291
|
```typescript
|
|
241
292
|
// LISTEN/NOTIFY only works with a single connection - not on a connection pool.
|
|
@@ -246,10 +297,9 @@ let nextWakeTime = Date.now()
|
|
|
246
297
|
|
|
247
298
|
client.on("notification", (msg) => {
|
|
248
299
|
if (msg.channel === "EVENTS") {
|
|
249
|
-
const event =
|
|
300
|
+
const event = Queue.decode(msg.payload as string)
|
|
250
301
|
if (event.eventType === "MESSAGE_CREATED" || event.eventType === "MESSAGE_DEFERRED") {
|
|
251
|
-
|
|
252
|
-
nextWakeTime = Math.min(nextWakeTime, messageAvailableAt)
|
|
302
|
+
nextWakeTime = Math.min(nextWakeTime, event.dequeueAt)
|
|
253
303
|
}
|
|
254
304
|
}
|
|
255
305
|
})
|
|
@@ -261,13 +311,13 @@ The `MESSAGE_DELETED` event can be used to create coordination patterns where on
|
|
|
261
311
|
|
|
262
312
|
## Deadlocks
|
|
263
313
|
|
|
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
|
|
314
|
+
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:
|
|
265
315
|
|
|
266
316
|
- Message create
|
|
267
317
|
- Channel policy set
|
|
268
318
|
- Channel policy clear
|
|
269
319
|
|
|
270
|
-
Beyond the actions specified above, it is
|
|
320
|
+
Beyond the actions specified above, it is **unsafe** to bulk-perform any of the remaining actions within a single transaction. Each of these actions should be isolated within their **own** transaction:
|
|
271
321
|
|
|
272
322
|
- Message dequeue
|
|
273
323
|
- Message defer
|
|
@@ -303,4 +353,4 @@ const queue = new Queue<NonCompliantDatabaseClient>({
|
|
|
303
353
|
}
|
|
304
354
|
})
|
|
305
355
|
})
|
|
306
|
-
```
|
|
356
|
+
```
|