lonnymq 0.0.18 → 0.0.20

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
@@ -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 capacity constraints
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
- .channel("myChannel")
39
- .message
40
- .create({
41
- content: Buffer.from("Hello"),
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
- const sleepMs = Math.min(1000, dequeueResult.retryMs ?? 5000)
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, capacity, and rate limits by setting their "channel policy":
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 and assign it to a particular channel using the `create` function:
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
- name: "optional-dedup-key",
125
- lockMs: 30000, // 30 seconds
126
- delayMs: 5000 // 5 second delay
129
+ lockMs: 30000,
130
+ delayMs: 5000
127
131
  })
128
132
  ```
129
133
 
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.
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(`Attempts: ${message.numAttempts}`)
149
+ console.log(`State: ${message.state?.toString()}`)
143
150
 
144
151
  try {
145
152
  // Process the message...
@@ -148,18 +155,61 @@ if (dequeueResult.resultType === "MESSAGE_DEQUEUED") {
148
155
  // Delete on success
149
156
  await message.delete({ databaseClient })
150
157
  } 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
- })
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
+
183
+ ### Message Heartbeats
184
+
185
+ For long-running message processing, you can use the heartbeat functionality to extend the lock time beyond the initial `lockMs` value. This is useful when you need to process a message for longer than originally anticipated but want to keep a shorter default lock time for faster recovery.
186
+
187
+ ```typescript
188
+ const dequeueResult = await queue.dequeue({ databaseClient })
189
+
190
+ if (dequeueResult.resultType === "MESSAGE_DEQUEUED") {
191
+ const { message } = dequeueResult
192
+
193
+ // Start a long-running process
194
+ const intervalId = setInterval(async () => {
195
+ // Send heartbeat every 10 seconds to keep the message locked
196
+ await message.heartbeat({ databaseClient })
197
+ }, 10000)
198
+
199
+ try {
200
+ // Process the message (this might take a long time)
201
+ await longRunningProcessMessage(message.content)
202
+ await message.delete({ databaseClient })
203
+ } catch (error) {
204
+ await message.defer({ databaseClient, delayMs: 30000 })
205
+ } finally {
206
+ clearInterval(intervalId)
207
+ }
208
+ }
209
+ ```
210
+
211
+ Heartbeats reset the unlock time to the current time plus the original `lockMs` value.
212
+
163
213
  ### Graceful Shutdowns and Message Recovery
164
214
 
165
215
  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 +256,7 @@ client.on("notification", (msg) => {
206
256
 
207
257
  ### Waiting for Job Completion
208
258
 
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 channels or message names, you can implement blocking operations that wait for background work to finish.
259
+ 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
260
 
211
261
  ```typescript
212
262
  const client = await databaseClient.connect()
@@ -217,9 +267,7 @@ const wait = (messageId: string) : Promise<void> => {
217
267
  const handler = (msg) => {
218
268
  if (msg.channel === "EVENTS") {
219
269
  const event = queueEventDecode(msg.payload as string)
220
- if (event.eventType === "MESSAGE_DELETED" &&
221
- event.channelName === "background-jobs" &&
222
- event.messageName === messageId) {
270
+ if (event.eventType === "MESSAGE_DELETED" && event.id === messageId) {
223
271
  client.off("notification", handler)
224
272
  resolve()
225
273
  }
@@ -234,7 +282,7 @@ await wait(messageId)
234
282
 
235
283
  ## Deadlocks
236
284
 
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):
285
+ 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:
238
286
 
239
287
  - Message create
240
288
  - Channel policy set
@@ -245,38 +293,43 @@ Beyond the actions specified above, it is manifestly **unsafe** to bulk-perform
245
293
  - Message dequeue
246
294
  - Message defer
247
295
  - Message delete
296
+ - Message heartbeat
248
297
 
249
298
  ## Batching Operations
250
299
 
251
300
  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
301
 
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.
302
+ The batch interface mirrors a subset of the queue interface, supporting only the operations that are safe to perform together: message creation and channel policy management.
256
303
 
257
304
  ```typescript
258
305
  const batch = queue.batch()
259
306
 
260
- batch.channel("user-123").message.create({
261
- content: Buffer.from("Welcome email")
307
+ // Create messages (assigned to random channels)
308
+ batch.message.create({
309
+ content: Buffer.from("Welcome email"),
310
+ lockMs: 30000
262
311
  })
263
312
 
264
- batch.channel("user-123").policy.set({
265
- maxConcurrency: 5,
266
- maxSize: 1000,
267
- releaseIntervalMs: 100
313
+ // Create messages on specific channels
314
+ batch.channel("user-notifications").message.create({
315
+ content: Buffer.from("User signup notification"),
316
+ lockMs: 60000
268
317
  })
269
318
 
270
- batch.channel("notifications").message.create({
271
- content: Buffer.from("Daily digest"),
272
- name: "daily-digest-2025-08-29"
319
+ // Set channel policies
320
+ batch.channel("high-priority").policy.set({
321
+ maxConcurrency: 5,
322
+ releaseIntervalMs: 100
273
323
  })
274
324
 
325
+ // Clear channel policies
275
326
  batch.channel("analytics").policy.clear()
276
327
 
277
328
  await batch.execute({ databaseClient })
278
329
  ```
279
330
 
331
+ The batch system ensures all operations are executed in a consistent lexicographical order, eliminating the possibility of deadlocks when multiple workers are performing bulk operations simultaneously.
332
+
280
333
  ## Database Clients
281
334
 
282
335
  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 +340,25 @@ interface DatabaseClient {
287
340
  rows: Array<Record<string, unknown>>
288
341
  }>
289
342
  }
343
+ ```
344
+
345
+ ### Database Client Adapters
346
+
347
+ 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:
348
+
349
+ ```typescript
350
+ import { Queue } from "lonnymq"
351
+ import { Pool } from "pg"
352
+
353
+ const customClient = new SomeOtherPostgresClient()
354
+ const queue = new Queue({
355
+ schema: "lonny",
356
+ adaptor: (client) => ({
357
+ query: async (sql, params) => {
358
+ // Adapt the client's interface to match DatabaseClient
359
+ const result = await client.executeQuery(sql, params)
360
+ return { rows: result.data }
361
+ }
362
+ })
363
+ })
290
364
  ```