lonnymq 1.0.2 → 1.2.0
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/dist/index.d.mts +314 -0
- package/dist/index.mjs +1117 -0
- package/package.json +21 -17
- package/README.md +0 -356
- package/dist/index.cjs +0 -666
- package/dist/index.d.ts +0 -281
- package/dist/index.js +0 -666
package/package.json
CHANGED
|
@@ -1,30 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lonnymq",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build": "bun build.ts",
|
|
7
|
+
"check": "bun run typecheck && bun run lint && bun test",
|
|
8
|
+
"lint": "eslint --max-warnings=0",
|
|
9
|
+
"typecheck": "tsc --noEmit",
|
|
10
|
+
"tsdown": "tsdown"
|
|
11
|
+
},
|
|
5
12
|
"exports": {
|
|
6
13
|
".": {
|
|
7
|
-
"types": "./dist/index.d.
|
|
8
|
-
"import": "./dist/index.
|
|
9
|
-
"require": "./dist/index.cjs"
|
|
14
|
+
"types": "./dist/index.d.mts",
|
|
15
|
+
"import": "./dist/index.mjs"
|
|
10
16
|
}
|
|
11
17
|
},
|
|
12
18
|
"repository": {
|
|
13
|
-
"url": "https://github.com/
|
|
19
|
+
"url": "https://github.com/lonnycorp/lonnymq"
|
|
14
20
|
},
|
|
15
21
|
"files": ["dist"],
|
|
16
22
|
"devDependencies": {
|
|
17
|
-
"@stylistic/eslint-plugin-ts": "
|
|
18
|
-
"@types/bun": "
|
|
19
|
-
"@types/pg": "
|
|
20
|
-
"@typescript-eslint/eslint-plugin": "
|
|
21
|
-
"@typescript-eslint/parser": "
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"typedoc": "
|
|
26
|
-
|
|
27
|
-
"peerDependencies": {
|
|
28
|
-
"typescript": "^5.0.0"
|
|
23
|
+
"@stylistic/eslint-plugin-ts": "catalog:",
|
|
24
|
+
"@types/bun": "catalog:",
|
|
25
|
+
"@types/pg": "catalog:",
|
|
26
|
+
"@typescript-eslint/eslint-plugin": "catalog:",
|
|
27
|
+
"@typescript-eslint/parser": "catalog:",
|
|
28
|
+
"eslint": "catalog:",
|
|
29
|
+
"pg": "catalog:",
|
|
30
|
+
"tsdown": "catalog:",
|
|
31
|
+
"typedoc": "catalog:",
|
|
32
|
+
"typescript": "catalog:"
|
|
29
33
|
}
|
|
30
34
|
}
|
package/README.md
DELETED
|
@@ -1,356 +0,0 @@
|
|
|
1
|
-
# LonnyMQ
|
|
2
|
-
|
|
3
|
-
A high-performance, multi-tenant PostgreSQL message queue implementation for Node.js/TypeScript.
|
|
4
|
-
|
|
5
|
-
## Features
|
|
6
|
-
|
|
7
|
-
- High throughput message processing
|
|
8
|
-
- Multi-tenant concurrency and rate limits
|
|
9
|
-
- Durable message processing
|
|
10
|
-
- Support for retries, recovery and custom back-off strategies
|
|
11
|
-
- Message prioritisation
|
|
12
|
-
- Queue operations as part of *existing* database transactions
|
|
13
|
-
- Database client agnostic with optional adapters
|
|
14
|
-
- Granular events via PostgreSQL `NOTIFY` (Avoid relying on polling to fetch new messages)
|
|
15
|
-
- Zero dependencies
|
|
16
|
-
|
|
17
|
-
**Note:** Unlike other queue implementations, LonnyMQ provides direct access to queue methods rather than providing batteries-included Worker/Processor daemons.
|
|
18
|
-
|
|
19
|
-
### Benchmarking
|
|
20
|
-
|
|
21
|
-
With the following parameters:
|
|
22
|
-
- Everything running in a single Bun instance
|
|
23
|
-
- A locally hosted postgres database
|
|
24
|
-
- The code as-is below in "Quick Look", but with 8 producers and 8 consumers
|
|
25
|
-
|
|
26
|
-
A message throughput of **~1,800** messages per second is observed.
|
|
27
|
-
|
|
28
|
-
## Quick Look
|
|
29
|
-
|
|
30
|
-
```typescript
|
|
31
|
-
import { Queue } from "lonnymq"
|
|
32
|
-
import { Pool } from "pg"
|
|
33
|
-
|
|
34
|
-
const databaseClient = new Pool({ connectionString: process.env.DATABASE_URL })
|
|
35
|
-
const queue = new Queue({ schema: "lonny" })
|
|
36
|
-
const content = Buffer.from("hello world")
|
|
37
|
-
|
|
38
|
-
const produce = async () => {
|
|
39
|
-
while (true) {
|
|
40
|
-
const result = await queue.message.create({
|
|
41
|
-
databaseClient: pool,
|
|
42
|
-
content: content
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
if (result.resultType !== "MESSAGE_CREATED") {
|
|
46
|
-
continue
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (result.channelSize > 1_000) {
|
|
50
|
-
await sleep(result.channelSize)
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const consume = async () => {
|
|
56
|
-
while (true) {
|
|
57
|
-
const result = await queue.dequeue({
|
|
58
|
-
databaseClient: pool,
|
|
59
|
-
lockMs: 1_000
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
if (result.resultType === "MESSAGE_DEQUEUED") {
|
|
63
|
-
await result.message.delete({ databaseClient: pool })
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Kick off producer loop
|
|
69
|
-
produce()
|
|
70
|
-
|
|
71
|
-
// Kick off consumer loop
|
|
72
|
-
consume()
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
## Setup & Installation
|
|
76
|
-
|
|
77
|
-
LonnyMQ can be installed from npm:
|
|
78
|
-
|
|
79
|
-
```bash
|
|
80
|
-
npm install lonnymq
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
Once the package is installed, the queue needs to be "installed" to a postgres schema. The requisite SQL for this can be generated via:
|
|
84
|
-
|
|
85
|
-
```typescript
|
|
86
|
-
const sqlCommands = queue.install({
|
|
87
|
-
eventChannel: "lonnymq-events",
|
|
88
|
-
|
|
89
|
-
for(const sql of sqlCommands) {
|
|
90
|
-
await pool
|
|
91
|
-
}
|
|
92
|
-
})
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
_Optional_ parameters can be passed in to alter default queue behaviour/semantics. If an `eventChannel` is provided, LonnyMQ will publish queue events to the channel provided via `NOTIFY`.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
## Channels
|
|
99
|
-
|
|
100
|
-
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.
|
|
101
|
-
|
|
102
|
-
Channels can be configured with rate limiting, concurrency and capacity constraints by setting their _channel policy_:
|
|
103
|
-
|
|
104
|
-
```typescript
|
|
105
|
-
await queue
|
|
106
|
-
.channel("my-channel")
|
|
107
|
-
.policy
|
|
108
|
-
.set({
|
|
109
|
-
databaseClient,
|
|
110
|
-
maxConcurrency: 1,
|
|
111
|
-
releaseIntervalMs: 1000,
|
|
112
|
-
maxSize: 1_000
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
// Remove all constraints:
|
|
116
|
-
await queue
|
|
117
|
-
.channel("my-channel")
|
|
118
|
-
.policy
|
|
119
|
-
.clear({ databaseClient })
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
## Message Creation
|
|
123
|
-
|
|
124
|
-
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.
|
|
125
|
-
|
|
126
|
-
```typescript
|
|
127
|
-
await queue.message.create({
|
|
128
|
-
databaseClient,
|
|
129
|
-
content: Buffer.from("Hello, world"),
|
|
130
|
-
})
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
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:
|
|
134
|
-
|
|
135
|
-
```typescript
|
|
136
|
-
await queue
|
|
137
|
-
.channel("my-channel")
|
|
138
|
-
.message
|
|
139
|
-
.create({
|
|
140
|
-
databaseClient,
|
|
141
|
-
content: Buffer.from("Hello, world")
|
|
142
|
-
})
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
By default, created messages are immediately available for processing. To delay availability you can pass a `dequeueAt` unix timestamp (in milliseconds) that specifies the earliest time the message may be dequeued.
|
|
146
|
-
|
|
147
|
-
```typescript
|
|
148
|
-
await queue.message.create({
|
|
149
|
-
databaseClient,
|
|
150
|
-
content: Buffer.from("Hello, world"),
|
|
151
|
-
dequeueAt: Date.now() + 5_000 // 5s in the future
|
|
152
|
-
})
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
N.B. `dequeueAt` is compared against the _database_ clock.
|
|
156
|
-
|
|
157
|
-
### Message Prioritization
|
|
158
|
-
|
|
159
|
-
LonnyMQ doesn't use an explicit message priority field for performance reasons. In short, there is no way to find the highest priority message that is also available for dequeue for a particular channel without some degree of linear scanning in the worst case vs. simply using a logarithmic index lookup.
|
|
160
|
-
|
|
161
|
-
However, messages that _are_ available for processing are dequeued from their channels in order of their `dequeueAt` values (oldest first). Thus, by overloading the semantics of the `dequeueAt` field and using _historic_ unix timestamps (i.e. `0`, `1`, `2`, etc.) - messages can trivially be prioritized within a channel.
|
|
162
|
-
|
|
163
|
-
N.B. there is no way to _globally_ prioritize a message - channels will _always_ be accessed in a round-robin fashion.
|
|
164
|
-
|
|
165
|
-
## Message Processing
|
|
166
|
-
|
|
167
|
-
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).
|
|
168
|
-
|
|
169
|
-
```typescript
|
|
170
|
-
const dequeueResult = await queue.dequeue({
|
|
171
|
-
databaseClient,
|
|
172
|
-
lockMs: 60_000
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
if (dequeueResult.resultType === "MESSAGE_DEQUEUED") {
|
|
176
|
-
const { message } = dequeueResult
|
|
177
|
-
console.log(`Processing message: ${message.id}`)
|
|
178
|
-
console.log(`Content: ${message.content.toString()}`)
|
|
179
|
-
console.log(`State: ${message.state?.toString()}`)
|
|
180
|
-
|
|
181
|
-
try {
|
|
182
|
-
// Process the message...
|
|
183
|
-
await processMessage(message.content)
|
|
184
|
-
|
|
185
|
-
// Delete on success
|
|
186
|
-
await message.delete({ databaseClient })
|
|
187
|
-
} catch (error) {
|
|
188
|
-
if (message.numAttempts >= 5) {
|
|
189
|
-
// Too many retries, delete permanently
|
|
190
|
-
await message.delete({ databaseClient })
|
|
191
|
-
} else {
|
|
192
|
-
// Defer for retry with exponential backoff and updated state
|
|
193
|
-
const backoffMs = Math.pow(2, message.numAttempts) * 1_000
|
|
194
|
-
await message.defer({
|
|
195
|
-
databaseClient,
|
|
196
|
-
dequeueAt: Date.now() + backoffMs,
|
|
197
|
-
state: Buffer.from(JSON.stringify({
|
|
198
|
-
error: error.message,
|
|
199
|
-
lastAttempt: new Date().toISOString()
|
|
200
|
-
}))
|
|
201
|
-
})
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
} else {
|
|
205
|
-
console.log("No messages available")
|
|
206
|
-
}
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
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.
|
|
210
|
-
|
|
211
|
-
When a message is deferred it becomes immediately available for re-processing unless you supply a `dequeueAt` timestamp.
|
|
212
|
-
|
|
213
|
-
**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.
|
|
214
|
-
|
|
215
|
-
### Extending Message Locks with Heartbeats
|
|
216
|
-
|
|
217
|
-
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:
|
|
218
|
-
|
|
219
|
-
```typescript
|
|
220
|
-
const dequeueResult = await queue.dequeue({
|
|
221
|
-
databaseClient,
|
|
222
|
-
lockMs: 30_000
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
if (dequeueResult.resultType === "MESSAGE_DEQUEUED") {
|
|
226
|
-
const { message } = dequeueResult
|
|
227
|
-
|
|
228
|
-
// Start long-running process
|
|
229
|
-
const longTask = processLongRunningTask(message.content)
|
|
230
|
-
|
|
231
|
-
// Set up heartbeat to extend lock every 20 seconds
|
|
232
|
-
const heartbeatInterval = setInterval(async () => {
|
|
233
|
-
await message.heartbeat({
|
|
234
|
-
databaseClient,
|
|
235
|
-
lockMs: 30_000
|
|
236
|
-
})
|
|
237
|
-
}, 20_000)
|
|
238
|
-
|
|
239
|
-
try {
|
|
240
|
-
await longTask
|
|
241
|
-
await message.delete({ databaseClient })
|
|
242
|
-
} catch (error) {
|
|
243
|
-
await message.defer({
|
|
244
|
-
databaseClient,
|
|
245
|
-
dequeueAt: Date.now() + 60_000
|
|
246
|
-
})
|
|
247
|
-
} finally {
|
|
248
|
-
clearInterval(heartbeatInterval)
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
### Graceful Shutdowns and Message Recovery
|
|
254
|
-
|
|
255
|
-
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 locks expire. 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.
|
|
256
|
-
|
|
257
|
-
## Events
|
|
258
|
-
|
|
259
|
-
Using PostgreSQL `NOTIFY`, we can receive a granular stream of queue events:
|
|
260
|
-
|
|
261
|
-
1. `MESSAGE_CREATED`
|
|
262
|
-
2. `MESSAGE_DEFERRED`
|
|
263
|
-
4. `MESSAGE_DELETED`
|
|
264
|
-
|
|
265
|
-
```typescript
|
|
266
|
-
const install = queue.install({ eventChannel: "EVENTS"})
|
|
267
|
-
```
|
|
268
|
-
|
|
269
|
-
### Improving on Polling
|
|
270
|
-
|
|
271
|
-
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.
|
|
272
|
-
|
|
273
|
-
```typescript
|
|
274
|
-
// Basic polling approach
|
|
275
|
-
while (true) {
|
|
276
|
-
const result = await queue.dequeue({ databaseClient, lockMs: 30_000 })
|
|
277
|
-
|
|
278
|
-
if (result.resultType === "MESSAGE_NOT_AVAILABLE") {
|
|
279
|
-
await sleep(5_000)
|
|
280
|
-
continue
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Process message...
|
|
284
|
-
await processMessage(result.message)
|
|
285
|
-
await result.message.delete({ databaseClient })
|
|
286
|
-
}
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
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 `dequeueAt` values, you can determine the optimal time to retry dequeuing:
|
|
290
|
-
|
|
291
|
-
```typescript
|
|
292
|
-
// LISTEN/NOTIFY only works with a single connection - not on a connection pool.
|
|
293
|
-
const client = await databaseClient.connect()
|
|
294
|
-
await client.query(`LISTEN "EVENTS"`)
|
|
295
|
-
|
|
296
|
-
let nextWakeTime = Date.now()
|
|
297
|
-
|
|
298
|
-
client.on("notification", (msg) => {
|
|
299
|
-
if (msg.channel === "EVENTS") {
|
|
300
|
-
const event = Queue.decode(msg.payload as string)
|
|
301
|
-
if (event.eventType === "MESSAGE_CREATED" || event.eventType === "MESSAGE_DEFERRED") {
|
|
302
|
-
nextWakeTime = Math.min(nextWakeTime, event.dequeueAt)
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
})
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
### Waiting for Job Completion
|
|
309
|
-
|
|
310
|
-
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.
|
|
311
|
-
|
|
312
|
-
## Deadlocks
|
|
313
|
-
|
|
314
|
-
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:
|
|
315
|
-
|
|
316
|
-
- Message create
|
|
317
|
-
- Channel policy set
|
|
318
|
-
- Channel policy clear
|
|
319
|
-
|
|
320
|
-
Beyond the actions specified above, it is **unsafe** to bulk-perform any of the remaining actions within a single transaction. Each of these actions should be isolated within their **own** transaction:
|
|
321
|
-
|
|
322
|
-
- Message dequeue
|
|
323
|
-
- Message defer
|
|
324
|
-
- Message delete
|
|
325
|
-
- Message heartbeat
|
|
326
|
-
|
|
327
|
-
## Database Clients
|
|
328
|
-
|
|
329
|
-
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:
|
|
330
|
-
|
|
331
|
-
```typescript
|
|
332
|
-
interface DatabaseClient {
|
|
333
|
-
query(sql: string, params: Array<unknown>): Promise<{
|
|
334
|
-
rows: Array<Record<string, unknown>>
|
|
335
|
-
}>
|
|
336
|
-
}
|
|
337
|
-
```
|
|
338
|
-
|
|
339
|
-
### Database Client Adapters
|
|
340
|
-
|
|
341
|
-
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:
|
|
342
|
-
|
|
343
|
-
```typescript
|
|
344
|
-
import { Queue } from "lonnymq"
|
|
345
|
-
|
|
346
|
-
const queue = new Queue<NonCompliantDatabaseClient>({
|
|
347
|
-
schema: "lonny",
|
|
348
|
-
adaptor: (client : NonCompliantDatabaseClient) => ({
|
|
349
|
-
query: async (sql, params) => {
|
|
350
|
-
// Adapt the client's interface to match DatabaseClient
|
|
351
|
-
const result = await client.executeQuery(sql, params)
|
|
352
|
-
return { rows: result.data }
|
|
353
|
-
}
|
|
354
|
-
})
|
|
355
|
-
})
|
|
356
|
-
```
|