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 +74 -39
- package/dist/index.cjs +216 -183
- package/dist/index.d.ts +23 -7
- package/dist/index.js +216 -183
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
# LonnyMQ
|
|
2
2
|
|
|
3
|
-
A high-performance, multi-tenant
|
|
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
|
|
11
|
-
- Message
|
|
12
|
-
- Queue
|
|
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
|
-
-
|
|
14
|
+
- Granular events via PostgreSQL `NOTIFY`
|
|
15
15
|
- Zero dependencies
|
|
16
16
|
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
113
|
+
## Message Creation
|
|
118
114
|
|
|
119
|
-
You can add a message to the queue
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
169
|
+
## Events
|
|
169
170
|
|
|
170
|
-
|
|
171
|
+
Using PostgreSQL `NOTIFY`, we can receive a granular stream of queue events:
|
|
171
172
|
|
|
172
|
-
|
|
173
|
+
1. `MESSAGE_CREATED`
|
|
174
|
+
2. `MESSAGE_DEFERRED`
|
|
175
|
+
4. `MESSAGE_DELETED`
|
|
173
176
|
|
|
174
|
-
|
|
177
|
+
To enable this feature, ensure the optional `eventChannel` is defined when constructing the SQL migrations.
|
|
175
178
|
|
|
176
|
-
|
|
179
|
+
### Improving on Polling
|
|
177
180
|
|
|
178
|
-
|
|
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
|
-
|
|
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({
|
|
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 "
|
|
193
|
+
await client.query(`LISTEN "EVENTS"`)
|
|
191
194
|
client.on("notification", (msg) => {
|
|
192
|
-
if (msg.channel ===
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
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):
|