lonnymq 0.0.8 → 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 Node.js/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
7
+ - High throughput message processing
8
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
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
13
  - Database client agnostic
14
- - Instant reactivity via `LISTEN/NOTIFY`
14
+ - Granular events via PostgreSQL `NOTIFY`
15
15
  - Zero dependencies
16
16
 
17
- N.B. Unlike other queue implementations, LonnyMQ provides direct access to queue methods rather than 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
 
@@ -29,9 +29,7 @@ const queue = new Queue({ schema: "lonny" })
29
29
 
30
30
  // Run migrations first
31
31
  for (const migration of queue.migrations()) {
32
- for (const sql of migration.sql) {
33
- await databaseClient.query(sql, [])
34
- }
32
+ await databaseClient.query(migration, [])
35
33
  }
36
34
 
37
35
  // Create messages
@@ -67,7 +65,7 @@ LonnyMQ can be installed from npm:
67
65
  npm install lonnymq
68
66
  ```
69
67
 
70
- Once the package is installed, you need to install the requisite database machinery. 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.
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.
71
69
 
72
70
  ```typescript
73
71
  const queue = new Queue({ schema: "lonny" })
@@ -77,9 +75,7 @@ const migrations = queue.migrations()
77
75
  await databaseClient.query("BEGIN")
78
76
  try {
79
77
  for (const migration of migrations) {
80
- for (const sql of migration.sql) {
81
- await databaseClient.query(sql, [])
82
- }
78
+ await databaseClient.query(migration, [])
83
79
  }
84
80
  await databaseClient.query("COMMIT")
85
81
  } catch (error) {
@@ -88,11 +84,11 @@ try {
88
84
  }
89
85
  ```
90
86
 
91
- **Note:** Migration SQL is not idempotent and should be executed within a transaction that can be rolled back if needed.
87
+ **Note:** Migration SQL is not idempotent and should be executed within a transaction that can be rolled back if an error occurs.
92
88
 
93
89
  ## Channels
94
90
 
95
- Channels provide LonnyMQ's 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, so they can be assigned on a highly granular basis (e.g., per-user) 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.
96
92
 
97
93
  Channels can be configured with concurrency, capacity, and rate limits by setting their "channel policy":
98
94
 
@@ -114,9 +110,9 @@ await queue
114
110
  .clear({ databaseClient })
115
111
  ```
116
112
 
117
- ## Message creation
113
+ ## Message Creation
118
114
 
119
- You can add a message to the queue (and assign it to a particular channel) using the `create` function:
115
+ You can add a message to the queue and assign it to a particular channel using the `create` function:
120
116
 
121
117
  ```typescript
122
118
  await queue
@@ -131,14 +127,13 @@ await queue
131
127
  })
132
128
  ```
133
129
 
134
- The `name` argument can be provided for de-duplication purposes: if a message that has *never* been dequeued exists with the same name 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.
135
131
 
136
132
  ## Message Processing
137
133
 
138
- Messages can be fetched for processing by calling `dequeue` on the `Queue` - this locks the message. Once processing has completed, messages must be "finalized" via **deletion** or **deferral** (for further processing in the future).
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).
139
135
 
140
136
  ```typescript
141
- const dequeueResult = await queue.dequeue({ databaseClient })
142
137
 
143
138
  if (dequeueResult.resultType === "MESSAGE_DEQUEUED") {
144
139
  const { message } = dequeueResult
@@ -156,47 +151,87 @@ if (dequeueResult.resultType === "MESSAGE_DEQUEUED") {
156
151
  // Defer for retry with updated state
157
152
  await message.defer({
158
153
  databaseClient,
159
- delays: 30000, // Retry in 30 seconds
154
+ delayMs: 30000, // Retry in 30 seconds
160
155
  state: Buffer.from(JSON.stringify({ error: error.message }))
161
156
  })
162
157
  }
163
158
  }
164
159
  ```
165
160
 
166
- When deferring a message, you can optionally specify `delayMs` and `state` arguments. The `delayMs` parameter tells the queue how long to wait before allowing the message to be re-processed, and `state` allows you to "save your work" and implement durable and/or repeating/scheduled tasks.
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.
162
+
163
+ ### Graceful Shutdowns and Message Recovery
164
+
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.
166
+
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.
167
168
 
168
- ### Graceful Shutdowns and Message Sweeping
169
+ ## Events
169
170
 
170
- 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 imperative that you gracefully shutdown by catching unhandled exceptions and signals (i.e., `SIGINT`/`SIGTERM`) and finalizing all outstanding messages prior to exiting.
171
+ Using PostgreSQL `NOTIFY`, we can receive a granular stream of queue events:
171
172
 
172
- That said, despite our best efforts, if we run out of memory, suffer a loss of power, 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 being available again for dequeue. This facility ensures that no matter the nature of the shutdown, the queue will always recover automatically.
173
+ 1. `MESSAGE_CREATED`
174
+ 2. `MESSAGE_DEFERRED`
175
+ 4. `MESSAGE_DELETED`
173
176
 
174
- ## Improvements on Polling
177
+ To enable this feature, ensure the optional `eventChannel` is defined when constructing the SQL migrations.
175
178
 
176
- 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 with this approach is that we lose reactivity as we increase the polling timeout interval.
179
+ ### Improving on Polling
177
180
 
178
- 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 is 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.
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.
179
182
 
180
- Unfortunately, this doesn't help in situations where a message is created/deferred while a worker is sleeping. However, if you deploy LonnyMQ with the `useWake` parameter enabled, message creations and deferrals will trigger a payload to the `queue.wakeChannel()` Postgres channel with the number 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.
181
186
 
182
187
  ```typescript
183
188
  const queue = new Queue({ schema: "lonny" })
184
- const migrations = queue.migrations({ useWake: true })
185
-
186
- // Run migrations with wake enabled...
189
+ const migrations = queue.migrations({ eventChannel: "EVENTS" })
187
190
 
188
191
  // LISTEN/NOTIFY only works with a single connection - not on a connection pool.
189
192
  const client = await databaseClient.connect()
190
- await client.query(`LISTEN "${queue.wakeChannel()}"`)
193
+ await client.query(`LISTEN "EVENTS"`)
191
194
  client.on("notification", (msg) => {
192
- if (msg.channel === queue.wakeChannel()) {
193
- const deferMs = parseInt(msg.payload as string, 10)
194
- console.log(`Should wake in ${deferMs} ms`)
195
- // Wake up your worker loop here
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
+ }
196
202
  }
197
203
  })
198
204
  ```
199
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
+
200
235
  ## Deadlocks
201
236
 
202
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):