@relaybus/mongodb 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +264 -2
- package/dist/index.cjs +756 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +80 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +754 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Berkcan Şavur
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,265 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @relaybus/mongodb
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MongoDB event store for [relaybus](https://github.com/berkcansavur/relaybus): a transactional
|
|
4
|
+
outbox on multi-document transactions, with atomic leasing and per-key ordering.
|
|
5
|
+
|
|
6
|
+
Events are written in the **same MongoDB transaction** as your business data, so a committed
|
|
7
|
+
change always has its event and a rolled-back change never leaks one. Delivery is at-least-once;
|
|
8
|
+
handlers registered with `transactional: true` also get effectively-once processing, because
|
|
9
|
+
their writes and the delivery's completion commit together.
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
- MongoDB 6.0 or newer, **as a replica set or a sharded cluster**. MongoDB only supports
|
|
14
|
+
transactions there. A single-node replica set is enough for development.
|
|
15
|
+
- Node.js 22.12 or newer, `mongodb` driver 6 or 7 (peer dependency), `@relaybus/core`.
|
|
16
|
+
|
|
17
|
+
A standalone `mongod` is rejected with a `ConfigurationError` that says so (from `setup()` and
|
|
18
|
+
from the first transaction). Start a development replica set with
|
|
19
|
+
`mongod --replSet rs0` followed by `rs.initiate()`, or use the `mongo` Docker image the same way.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm install @relaybus/core @relaybus/mongodb mongodb
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { MongoClient } from 'mongodb';
|
|
31
|
+
import { RelayBus, defineEvent } from '@relaybus/core';
|
|
32
|
+
import { MongoEventStore } from '@relaybus/mongodb';
|
|
33
|
+
import type { ClientSession } from 'mongodb';
|
|
34
|
+
|
|
35
|
+
class OrderPlaced extends defineEvent<{ orderId: string }>('order.placed.v1') {}
|
|
36
|
+
|
|
37
|
+
const client = await new MongoClient(process.env.MONGODB_URI!).connect();
|
|
38
|
+
const bus = new RelayBus<ClientSession>({
|
|
39
|
+
store: new MongoEventStore({ client, database: 'shop' }),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
bus.subscribe(OrderPlaced, async (event) => sendConfirmation(event.payload.orderId), {
|
|
43
|
+
id: 'send-confirmation',
|
|
44
|
+
});
|
|
45
|
+
await bus.start(); // creates collections and indexes, then starts dispatching
|
|
46
|
+
|
|
47
|
+
await bus.transaction(async (session) => {
|
|
48
|
+
await client.db('shop').collection('orders').insertOne({ _id: 'o-1' }, { session });
|
|
49
|
+
await bus.publish(new OrderPlaced({ orderId: 'o-1' }), { key: 'o-1' });
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The transaction handle is the driver's `ClientSession`. Pass it to every write that must be
|
|
54
|
+
atomic with the event.
|
|
55
|
+
|
|
56
|
+
### Using an existing session or Mongoose
|
|
57
|
+
|
|
58
|
+
`publish({ transaction })` and `store.append(session, ...)` accept any `ClientSession` that is
|
|
59
|
+
inside a started transaction, so you are not forced to use `bus.transaction`. A session without a
|
|
60
|
+
started transaction is rejected with a `ConfigurationError`: MongoDB would commit each write
|
|
61
|
+
immediately, silently breaking the atomicity the outbox exists for. Mongoose exposes
|
|
62
|
+
the same driver session (`mongoose.startSession()`, `Model.create([doc], { session })`), and
|
|
63
|
+
`mongoose.connection.getClient()` returns the `MongoClient` to give to the store:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const session = await mongoose.startSession();
|
|
67
|
+
await session.withTransaction(async () => {
|
|
68
|
+
await Order.create([{ total: 10 }], { session });
|
|
69
|
+
await bus.publish(new OrderPlaced({ orderId: '1' }), { transaction: session });
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
relaybus does not depend on Mongoose. `session.withTransaction` may run your callback again
|
|
74
|
+
after a transient error (see [Transactions and retries](#transactions-and-retries)).
|
|
75
|
+
|
|
76
|
+
### Transactional handlers
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
bus.subscribe(
|
|
80
|
+
OrderPlaced,
|
|
81
|
+
async (event, context) => {
|
|
82
|
+
await projections.insertOne(
|
|
83
|
+
{ orderId: event.payload.orderId },
|
|
84
|
+
{ session: context.transaction },
|
|
85
|
+
);
|
|
86
|
+
},
|
|
87
|
+
{ id: 'projector', transactional: true },
|
|
88
|
+
);
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The projection write and the delivery's completion commit together or not at all.
|
|
92
|
+
|
|
93
|
+
## Options
|
|
94
|
+
|
|
95
|
+
| Option | Default | Meaning |
|
|
96
|
+
| -------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
97
|
+
| `client` | required | A connected `MongoClient`. The store never closes it. |
|
|
98
|
+
| `database` | the client's default database | Database for the collections. 1-63 characters of letters, digits, `_`, `-`. |
|
|
99
|
+
| `collectionPrefix` | `'relaybus_'` | Collections are `<prefix>events`, `<prefix>deliveries`, `<prefix>key_sequences`. |
|
|
100
|
+
| `transactionOptions` | snapshot read concern, `w: 'majority'`, primary | Passed to `withTransaction`. |
|
|
101
|
+
| `purgeBatchSize` | `500` | Deliveries deleted per purge step. |
|
|
102
|
+
| `generateIdentifier` | UUID v7 | Produces lease tokens. |
|
|
103
|
+
|
|
104
|
+
Non-transactional writes (claims, completions, retries) use write concern `majority` and primary
|
|
105
|
+
reads, whatever the client default is, so a lease survives a failover and the ordering check
|
|
106
|
+
never reads a stale secondary.
|
|
107
|
+
|
|
108
|
+
## How it works
|
|
109
|
+
|
|
110
|
+
**Collections.** `events` (`_id` = event id), `deliveries` (one per event and handler),
|
|
111
|
+
`key_sequences` (one counter document per ordering key).
|
|
112
|
+
|
|
113
|
+
**Idempotent append.** Event ids are deduplicated by reading what exists inside the transaction
|
|
114
|
+
and writing with upserts. A plain insert would raise a duplicate-key error (11000) when two
|
|
115
|
+
transactions race on the same id, and a duplicate-key error aborts the whole transaction without
|
|
116
|
+
a retry label. An upsert race surfaces as a retryable write conflict instead. The unique index on
|
|
117
|
+
`(eventId, handler)` is a backstop; the code never relies on catching it.
|
|
118
|
+
|
|
119
|
+
**Per-key sequence equals commit order.** Appending a keyed event runs
|
|
120
|
+
`findOneAndUpdate({ _id: key }, { $inc: { value: 1 } }, { upsert: true })` on the counter inside
|
|
121
|
+
the append transaction. A transaction holding an uncommitted increment makes every concurrent
|
|
122
|
+
appender of that key hit a write conflict; the loser restarts after the winner committed and reads
|
|
123
|
+
the next value. Two transactions can never both hold sequence `n`, and a lower sequence is always
|
|
124
|
+
committed before a higher one, so ordering by sequence is ordering by commit.
|
|
125
|
+
|
|
126
|
+
**Claim without `SKIP LOCKED`.** MongoDB has no row locks to skip, so claiming is optimistic:
|
|
127
|
+
|
|
128
|
+
1. Scan deliveries that are due (`dueAt <= now`) and in the `ready` lane for the requested
|
|
129
|
+
handlers, ordered by `(dueAt, _id)`. `dueAt` is `availableAt` while pending and
|
|
130
|
+
`leaseExpiresAt` while processing; it is removed when a delivery finishes.
|
|
131
|
+
2. A keyed delivery has to wait in two cases (the rule of the in-memory reference store):
|
|
132
|
+
an **earlier** delivery of the same handler and key is unfinished (`dueAt` present: pending,
|
|
133
|
+
even delayed; processing, even with an expired lease), or **any other** delivery of that
|
|
134
|
+
handler and key is processing under a **live lease** (`leaseExpiresAt > now`). The second
|
|
135
|
+
rule is what keeps a dead letter that an operator requeued from running beside the
|
|
136
|
+
delivery that already took its place.
|
|
137
|
+
3. Take it with one `findOneAndUpdate` whose filter pins the exact state that was read (status,
|
|
138
|
+
lane, attempt count, `dueAt`). If another claimer changed the delivery in between, the filter
|
|
139
|
+
matches nothing and this claimer moves on. Exactly one claimer wins each delivery.
|
|
140
|
+
4. Check the first rule again for a keyed delivery that was just leased. Between steps 2 and 3
|
|
141
|
+
a dead letter can be requeued and claimed by another worker whose own check cannot yet see
|
|
142
|
+
this lease. When this second look finds an unfinished predecessor, the lease is undone
|
|
143
|
+
(the delivery is put back exactly as it was selected, a crashed worker's lease included) and
|
|
144
|
+
the claim moves on. The later delivery always gives way, so of two racing claimers exactly
|
|
145
|
+
one keeps running. Together with the live-lease check of step 2 this leaves no interleaving
|
|
146
|
+
in which two deliveries of a handler and key run at once.
|
|
147
|
+
|
|
148
|
+
One claim call never takes two deliveries of the same key, and what it learns about a key (an
|
|
149
|
+
unfinished predecessor, a live lease, its own lease) answers every later delivery of that key
|
|
150
|
+
without another query. The scan continues past blocked deliveries until `limit` is reached, so a
|
|
151
|
+
hot blocked key cannot starve unrelated work.
|
|
152
|
+
|
|
153
|
+
**Parked backlogs.** A key whose head is slow can accumulate thousands of deliveries that are all
|
|
154
|
+
due and all blocked. Scanning them on every claim would cost time proportional to the backlog.
|
|
155
|
+
When one claim finds 32 or more deliveries of a key waiting behind an unfinished predecessor,
|
|
156
|
+
it moves them to the `parked` lane, which the claim index leaves out. At the start of every
|
|
157
|
+
claim, the lowest parked delivery of each key with no unfinished predecessor is moved back to
|
|
158
|
+
`ready`. This is deliberately level-triggered: it looks at the current state, never at an event,
|
|
159
|
+
so a crash while parking, or a predecessor finishing while its followers were being parked, can
|
|
160
|
+
delay a key until the next claim but never strand it. Measured with 50,000 deliveries waiting
|
|
161
|
+
behind a processing head, a claim went from about 520-590 ms to 11-15 ms; the first claim after
|
|
162
|
+
the backlog formed pays about 1.5 s once to park it. Parked deliveries keep their `dueAt`, so
|
|
163
|
+
`statistics().oldestDueAt` still counts them. The per-claim cost of the move-back step is two
|
|
164
|
+
indexed lookups per key that currently has parked deliveries, so keep the number of such
|
|
165
|
+
simultaneously backlogged keys in the hundreds, not millions.
|
|
166
|
+
|
|
167
|
+
`requeue` is an operator action and makes a dead delivery pending again even if later deliveries
|
|
168
|
+
of its key already ran; it blocks the ones that have not been claimed yet, and it cannot start
|
|
169
|
+
while a later delivery of its key is running.
|
|
170
|
+
|
|
171
|
+
**Fencing.** `complete`, `reschedule`, `release`, `bury` and `extendLease` filter by delivery id,
|
|
172
|
+
`status: 'processing'` and the lease token. A worker that lost its lease changes nothing. An expired
|
|
173
|
+
lease nobody has reclaimed yet is still honoured. `complete(lease, now, session)` joins your
|
|
174
|
+
transaction.
|
|
175
|
+
|
|
176
|
+
**Time.** The store uses only the `now` it is given, never the server clock.
|
|
177
|
+
|
|
178
|
+
**Payloads.** Payload and headers are stored as the JSON-normalized value: `Date` becomes an ISO
|
|
179
|
+
string, `undefined` members disappear, as in every other relaybus store. Keys containing `.` or
|
|
180
|
+
`$` are stored as they are (MongoDB 5+). Text with a **lone UTF-16 surrogate** (a broken emoji,
|
|
181
|
+
for example) is rejected with an `EventValidationError`: BSON strings are UTF-8, so MongoDB would
|
|
182
|
+
silently store U+FFFD instead. Run such text through `String.prototype.toWellFormed()` first.
|
|
183
|
+
|
|
184
|
+
**Untrusted input.** The fields of `listDeliveries` and the ids of `requeue` are coerced to
|
|
185
|
+
strings before they reach a query, so an object parsed from an admin request cannot become a
|
|
186
|
+
query operator such as `{ $ne: ... }`.
|
|
187
|
+
|
|
188
|
+
## Transactions and retries
|
|
189
|
+
|
|
190
|
+
`store.transaction(work)` uses `session.withTransaction`, which restarts the **whole callback**
|
|
191
|
+
after a `TransientTransactionError` (typically a write conflict) and re-sends only the commit after
|
|
192
|
+
an `UnknownTransactionCommitResult`. For everything written through the session this is exactly
|
|
193
|
+
what you want: an aborted attempt leaves nothing behind, and `append` re-reads state at the start
|
|
194
|
+
of every attempt, so a retry never appends twice. The value returned is that of the final attempt.
|
|
195
|
+
|
|
196
|
+
The consequence is that `work` can run more than once, and the same holds for `bus.transaction`,
|
|
197
|
+
which is built on it. Keep side effects that do not go through the session (HTTP calls, other
|
|
198
|
+
databases, message brokers) out of it, or make them idempotent. This fits relaybus: handlers are
|
|
199
|
+
at-least-once anyway, and event publishing is the mechanism meant to replace such side effects.
|
|
200
|
+
|
|
201
|
+
Retries stop after 120 seconds (the driver's limit) with the last error.
|
|
202
|
+
|
|
203
|
+
Transactional handlers run inside such a transaction too, so the same rule applies to them. The
|
|
204
|
+
core does not renew the lease of a transactional handler while it runs (its completion must not
|
|
205
|
+
conflict with a renewal), so the lease must outlast the handler: keep transactional handlers
|
|
206
|
+
short, as the core documentation recommends.
|
|
207
|
+
|
|
208
|
+
## Indexes
|
|
209
|
+
|
|
210
|
+
Created idempotently by `setup()` (also run by `bus.start()` unless `setupStore: false`):
|
|
211
|
+
|
|
212
|
+
| Collection | Index | Used by |
|
|
213
|
+
| ------------ | ---------------------------------- | ------------------------------------------------ |
|
|
214
|
+
| `deliveries` | `(eventId, handler)` unique | idempotent append, lookup by event |
|
|
215
|
+
| `deliveries` | `(handler, dueAt, _id)` partial | claim scan (`ready` lane only) |
|
|
216
|
+
| `deliveries` | `(handler, key, sequence)` partial | predecessor check of keyed deliveries |
|
|
217
|
+
| `deliveries` | `(handler, key, sequence)` partial | lowest parked delivery of each key (`parked`) |
|
|
218
|
+
| `deliveries` | `(handler, key, dueAt)` partial | live-lease check (`processing` keyed deliveries) |
|
|
219
|
+
| `deliveries` | `(dueAt)` partial | `statistics().oldestDueAt` |
|
|
220
|
+
| `deliveries` | `(completedAt)` partial | purge of completed deliveries |
|
|
221
|
+
| `deliveries` | `(status, createdAt)` | status counts, listing by status |
|
|
222
|
+
| `deliveries` | `(status, updatedAt)` | purge of dead deliveries |
|
|
223
|
+
| `deliveries` | `(handler, createdAt)` | listing by handler |
|
|
224
|
+
| `deliveries` | `(eventType, createdAt)` | listing by event type |
|
|
225
|
+
|
|
226
|
+
`setup()` also creates the three collections explicitly, because collections created implicitly
|
|
227
|
+
inside concurrent transactions conflict with each other. If you manage the schema yourself, create
|
|
228
|
+
the collections and indexes before the first publish.
|
|
229
|
+
|
|
230
|
+
Partial indexes contain only unfinished (or completed) deliveries, so they stay small however much
|
|
231
|
+
history you keep. If you never use `listDeliveries` filters, the three listing indexes can be
|
|
232
|
+
dropped at the cost of slower operator queries.
|
|
233
|
+
|
|
234
|
+
## Retention
|
|
235
|
+
|
|
236
|
+
`bus.purgeExpired()` (run on a schedule by the bus) calls `store.purge`, which deletes matching
|
|
237
|
+
deliveries in batches of `purgeBatchSize`, each batch together with the events no delivery
|
|
238
|
+
references any more **in one transaction**, so a crash can never leave an event without
|
|
239
|
+
deliveries behind. An append that adds a delivery to an event while it is being purged conflicts
|
|
240
|
+
with the purge and one of them restarts, so a delivery never loses its event; a delivery requeued
|
|
241
|
+
since the batch was read conflicts with its deletion and is read again.
|
|
242
|
+
|
|
243
|
+
One counter document per distinct ordering key stays in `key_sequences`. They are tiny; if your
|
|
244
|
+
keys are unbounded (for example one per request), clean up counters of long-finished keys yourself.
|
|
245
|
+
|
|
246
|
+
## Troubleshooting
|
|
247
|
+
|
|
248
|
+
- **`MongoEventStore needs multi-document transactions`**: the server is a standalone `mongod`.
|
|
249
|
+
Use a replica set or sharded cluster.
|
|
250
|
+
- **`Transaction numbers are only allowed on a replica set member or mongos`** raised by your own
|
|
251
|
+
code: same cause, for your own transactions.
|
|
252
|
+
- **`TransientTransactionError` / write conflicts under load**: expected when many publishers append
|
|
253
|
+
the same key at once; they serialize through the key's counter and retry. Spread hot keys or batch
|
|
254
|
+
publishes with `publishAll`.
|
|
255
|
+
- **Deliveries stay pending**: check `statistics().oldestDueAt`, and that the process that publishes
|
|
256
|
+
also registered the handler (deliveries are created from the publisher's registrations).
|
|
257
|
+
- **A key is stuck**: a pending, delayed, processing or requeued delivery earlier in the same key
|
|
258
|
+
blocks later ones, and so does any delivery of the key running under a live lease; inspect it
|
|
259
|
+
with `listDeliveries({ handler })` and `bus.requeue` or bury it.
|
|
260
|
+
- **Slow claims with a very large backlog of one key**: backlogs of 32 or more waiting deliveries
|
|
261
|
+
are parked automatically; check that the `claim`, `ordering`, `parked` and `running` indexes
|
|
262
|
+
exist (`setup()`). Deliveries with `lane: 'parked'` are expected while their key is blocked.
|
|
263
|
+
- **`MongoEventStore.append needs a ClientSession inside a started transaction`**: the session
|
|
264
|
+
passed to `publish({ transaction })` or `append` has no transaction. Start one with
|
|
265
|
+
`session.withTransaction` or `session.startTransaction()`.
|