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 CHANGED
@@ -1,20 +1,20 @@
1
1
  # LonnyMQ
2
2
 
3
- A high performance, multi-tenant Postgres message queue implementation for NodeJS/Typescript.
3
+ A high-performance, multi-tenant PostgreSQL message queue implementation for Node.js/TypeScript.
4
4
 
5
5
  ## Features
6
6
 
7
- - High throughput.
8
- - Multi-tenant concurrency and capacity constraints.
9
- - Durable message processing.
10
- - Flexible message processing retries/deferrals.
11
- - Message de-duplication.
12
- - Queue actions as part of *existing* database transactions.
13
- - Database client agnostic.
14
- - Instant reactivity via `LISTEN/NOTIFY`.
15
- - Zero dependencies.
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
- N.B. unlike other queue implementations, LonnyMQ provides direct access to queue methods vs. providing batteries-included Worker/Processor daemons.
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
- for(let ix = 0; ix < 500; ix += 1) {
31
- queue
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
- while(true) {
46
+ // Process messages
47
+ while (true) {
41
48
  const dequeueResult = await queue.dequeue({ databaseClient })
42
- if(dequeueResult.resultType === "MESSAGE_NOT_AVAILABLE") {
43
- await sleep(Math.min(1_000, dequeueResult.retryMs))
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 via:
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, we need to install the requisite DB machinery. LonnyMQ is agnostic to DB client/migration process and thus simply provides users an ordered list of "Migrations" - each containing a unique name and some SQL fragments to be executed.
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
- N.B. Migration SQL is not idempotent and thus these migrations should be executed in the context of a transaction that can be rolled back.
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 are the mechanism by which LonnyMQ provides multi-tenancy support. They can be considered lightweight sub-queues that are read from in a round-robin fashion. There is no performance penalty associated with using large numbers of channels and thus can be assigned on a highly granular (i.e. per-user) basis to ensure work is scheduled fairly.
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 capacity limits by setting their "channel policy".
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
- maxConcurrency: 1,
81
- maxSize: null,
82
- databaseClient
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 creation
113
+ ## Message Creation
93
114
 
94
- We can add a message to the queue (and assign it to a particular channel) with the `create` function:
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
- A `name` argument can be provided for de-duplication purposes: if a message that has _never_ been dequeued exists with the same name, and within the same channel, no new message will be created.
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
- ## Message processing
163
+ ### Graceful Shutdowns and Message Recovery
109
164
 
110
- A message can be fetched for processing by calling `dequeue` on the `Queue` - locking the message. Once processing has completed, messages can then be "finalized" via **deletion** or **deferral** (for further processing in the future).
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
- When deferring a message, we can optionally specify `deferMs` and `state` arguments. `deferMs` tells the queue how long to wait before allowing the message to be re-processed, and `state` allows us to "save our working" and implement durable and/or repeating/scheduled tasks.
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
- ### Graceful shutdowns and message sweeping
169
+ ## Events
115
170
 
116
- If your program ends unexpectedly, messages that are in the middle of being processed may well be "orphaned" in a locked state - causing channel blockages and reducing throughput. To mitigate this problem, it is imperative that we gracefully shutdown by catching unhandled exceptions and signals (i.e. `SIGINT`/`SIGTERM`) - finalizing all outstanding messages prior to exiting.
171
+ Using PostgreSQL `NOTIFY`, we can receive a granular stream of queue events:
117
172
 
118
- That said, despite our best efforts, should we run out of memory, suffer a loss of power, or receieve 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 being available again for dequeue. This facilities ensures that no matter the nature of the shutdown, the queue will always un-clog itself.
173
+ 1. `MESSAGE_CREATED`
174
+ 2. `MESSAGE_DEFERRED`
175
+ 4. `MESSAGE_DELETED`
119
176
 
120
- ## Improvements on polling
177
+ To enable this feature, ensure the optional `eventChannel` is defined when constructing the SQL migrations.
121
178
 
122
- The simplest approach for processing messages is to call `dequeue` in a loop, and backing off with a sleep when no messages are available. The downside with this approach is that we lose reactivity as we increase the polling timeout interval.
179
+ ### Improving on Polling
123
180
 
124
- To improve reactivity, we can use the `retryMs` returned when we fail to dequeue a message. This will either be `null` or tell us how long until the next message is available for processing (a message might be deferred for a period time). Thus, we can tune our sleep to use the minimum of our `retryMs` and a default poll timeout.
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
- Unfortunately, this doesn't help us in situations where a message is created/deferred while a worker is sleeping. However, if we deploy LonnyMQ, with the `useWake` parameter enabled, message creations and deferrals will trigger a payload to the `queue.wakeChannel()` Postgres channel with the amount of milliseconds until said message becomes available for processing encoded as a string payload.
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({ useWake: true })
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 pool.connect()
134
- await client.query(`LISTEN "${dep.wakeChannel()}"`)
192
+ const client = await databaseClient.connect()
193
+ await client.query(`LISTEN "EVENTS"`)
135
194
  client.on("notification", (msg) => {
136
- if (msg.channel === queue.wakeChannel()) {
137
- const deferMs = parseInt(msg.payload as string, 10)
138
- console.log(`Should wake in ${deferMs} ms`)
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 0 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):
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
- - Message create
148
- - Channel policy set
149
- - Channel policy clear
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
- - Message dequeue
154
- - Message defer
155
- - Message delete
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
- To help with ensuring commands are ordered consistently, we can create a "Batch" object by calling:
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
- This batch object provides a familiar API for message creation and channel policy mutations, but doesn't execute the underlying commands until the underlying batch is explicitly "executed".
260
+ batch.channel("user-123").message.create({
261
+ content: Buffer.from("Welcome email")
262
+ })
164
263
 
165
- ```typescript
166
- const results = [
167
- batch.channel("foo").message({ content: "hi" }),
168
- batch.channel("bar").policy.clear(),
169
- batch.channel("bar").message({ content: "hi", name: "foo" }),
170
- batch.channel("bar").message({ content: "hi" }),
171
- ]
172
- ```
173
- Prior to execution, the batch object will perform a sort to ensure actions are ordered consistently
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
- // Get the 3rd command that was submitted to the batch
179
- console.log(await results[2].get())
284
+ ```typescript
285
+ interface DatabaseClient {
286
+ query(sql: string, params: Array<unknown>): Promise<{
287
+ rows: Array<Record<string, unknown>>
288
+ }>
289
+ }
180
290
  ```