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/package.json CHANGED
@@ -1,30 +1,34 @@
1
1
  {
2
2
  "name": "lonnymq",
3
- "version": "1.0.2",
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.ts",
8
- "import": "./dist/index.js",
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/tlonny/lonnymq"
19
+ "url": "https://github.com/lonnycorp/lonnymq"
14
20
  },
15
21
  "files": ["dist"],
16
22
  "devDependencies": {
17
- "@stylistic/eslint-plugin-ts": "4.2.0",
18
- "@types/bun": "latest",
19
- "@types/pg": "^8.15.4",
20
- "@typescript-eslint/eslint-plugin": "8.26.1",
21
- "@typescript-eslint/parser": "8.26.1",
22
- "bun-plugin-dts": "^0.3.0",
23
- "eslint": "9.22.0",
24
- "pg": "^8.16.3",
25
- "typedoc": "^0.28.15"
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
- ```