lonnymq 0.0.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/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # LonnyMQ
2
+
3
+ A high performance, multi-tenant Postgres message queue implementation for NodeJS/Typescript.
4
+
5
+ ## Features
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.
16
+
17
+ N.B. unlike other queue implementations, LonnyMQ provides direct access to queue methods vs. providing batteries-included Worker/Processor daemons.
18
+
19
+ ## Quick Look
20
+
21
+ ```typescript
22
+ import { Queue, type DatabaseClient } from "lonnymq"
23
+ import { Pool } from "pg"
24
+
25
+ const databaseClient = new Pool({ connectionString: process.env.DATABASE_URL })
26
+ databaseClient satisfies DatabaseClient
27
+
28
+ const queue = new Queue({ schema: "lonny" })
29
+
30
+ for(let ix = 0; ix < 500; ix += 1) {
31
+ queue
32
+ .channel("myChannel")
33
+ .message
34
+ .create({
35
+ content: "Hello",
36
+ databaseClient,
37
+ })
38
+ }
39
+
40
+ while(true) {
41
+ const dequeueResult = await queue.dequeue({ databaseClient })
42
+ if(dequeueResult.resultType === "MESSAGE_NOT_AVAILABLE") {
43
+ await sleep(Math.min(1_000, dequeueResult.retryMs))
44
+ continue
45
+ }
46
+
47
+ console.log(dequeueResult.message.content)
48
+ await dequeueResult.message.delete({ databaseClient })
49
+ }
50
+ ```
51
+
52
+ ## Setup & Installation
53
+
54
+ LonnyMQ can be installed from npm via:
55
+
56
+ ```bash
57
+ npm install lonnymq
58
+ ```
59
+
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.
61
+
62
+ ```typescript
63
+ const queue = new Queue({ schema: "lonny" })
64
+ const migrations = queue.migrations()
65
+ ```
66
+
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.
68
+
69
+ ## Channels
70
+
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.
72
+
73
+ Channels can be configured with concurrency and capacity limits by setting their "channel policy".
74
+
75
+ ```typescript
76
+ await queue
77
+ .channel("my-channel")
78
+ .policy
79
+ .set({
80
+ maxConcurrency: 1,
81
+ maxSize: null,
82
+ databaseClient
83
+ })
84
+
85
+ // Remove all constraints:
86
+ await queue
87
+ .channel("my-channel")
88
+ .policy
89
+ .clear({ databaseClient })
90
+ ```
91
+
92
+ ## Message creation
93
+
94
+ We can add a message to the queue (and assign it to a particular channel) with the `create` function:
95
+
96
+ ```typescript
97
+ await queue
98
+ .channel("my-channel")
99
+ .message
100
+ .create({
101
+ databaseClient,
102
+ content: "Hello, world"
103
+ })
104
+ ```
105
+
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.
107
+
108
+ ## Message processing
109
+
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).
111
+
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.
113
+
114
+ ### Graceful shutdowns and message sweeping
115
+
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.
117
+
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.
119
+
120
+ ## Improvements on polling
121
+
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.
123
+
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.
125
+
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.
127
+
128
+ ```typescript
129
+ const queue = new Queue({ schema: "lonny" })
130
+ const migrations = queue.migrations({ useWake: true })
131
+
132
+ // 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()}"`)
135
+ 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`)
139
+ }
140
+ })
141
+ ```
142
+
143
+ ## Deadlocks
144
+
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):
146
+
147
+ - Message create
148
+ - Channel policy set
149
+ - Channel policy clear
150
+
151
+ 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
+
153
+ - Message dequeue
154
+ - Message defer
155
+ - Message delete
156
+
157
+ To help with ensuring commands are ordered consistently, we can create a "Batch" object by calling:
158
+
159
+ ```typescript
160
+ const batch = queue.batch()
161
+ ```
162
+
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".
164
+
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
174
+
175
+ ```typescript
176
+ await batch.execute({ databaseClient })
177
+
178
+ // Get the 3rd command that was submitted to the batch
179
+ console.log(await results[2].get())
180
+ ```