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 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({ databaseClient })
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 random channel, ensuring fair distribution:
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({ databaseClient })
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
- ### Graceful Shutdowns and Message Recovery
165
+ ### Extending Message Locks with Heartbeats
165
166
 
166
- 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.
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
- 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.
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
- 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.
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
- 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.
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
- console.log(`Should wake in ${event.delayMs} ms`)
201
- } else if(event.eventType === "MESSAGE_DEFERRED") {
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 customClient = new SomeOtherPostgresClient()
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)