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 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 with automatic recovery
10
- - Flexible message processing retries and deferrals
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, type DatabaseClient } from "lonnymq"
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
- // Install the queue
30
- for (const sql of queue.install()) {
31
- await databaseClient.query(sql, [])
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
- // Create messages
35
- for (let ix = 0; ix < 500; ix += 1) {
36
- await queue.message.create({
37
- databaseClient,
38
- content: Buffer.from("Hello")
39
- })
40
- }
45
+ if (result.resultType !== "MESSAGE_CREATED") {
46
+ continue
47
+ }
41
48
 
42
- // Process messages
43
- while (true) {
44
- const dequeueResult = await queue.dequeue({
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
- console.log(dequeueResult.message.content.toString())
53
- await dequeueResult.message.delete({ databaseClient })
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: `queue.install()`.
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 rate limits by setting their "channel policy":
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
- The `delayMs` parameter is optional and allows you to delay when the message becomes available for processing.
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) * 1000
193
+ const backoffMs = Math.pow(2, message.numAttempts) * 1_000
144
194
  await message.defer({
145
195
  databaseClient,
146
- delayMs: backoffMs,
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 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.
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({ databaseClient, delayMs: 60_000 })
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 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.
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 `delayMs`, you can determine the optimal time to retry dequeuing:
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 = queueEventDecode(msg.payload as string)
300
+ const event = Queue.decode(msg.payload as string)
250
301
  if (event.eventType === "MESSAGE_CREATED" || event.eventType === "MESSAGE_DEFERRED") {
251
- const messageAvailableAt = Date.now() + event.delayMs
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 name:
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 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:
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
+ ```