@relaybus/rabbitmq 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 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,237 @@
1
- # Temporary Holding Version
1
+ # @relaybus/rabbitmq
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ RabbitMQ transport for [relaybus](https://github.com/berkcansavur/relaybus). It publishes events
4
+ from the outbox with broker confirms and consumes them with manual acknowledgements, so an event is
5
+ never lost between services: it is either delivered, still waiting in the outbox, or in a
6
+ dead-letter queue.
7
+
8
+ - **Publish** resolves only after the broker confirmed the message was persisted and routed.
9
+ - **Consume** acknowledges only after your handler (the relaybus inbox write) succeeded.
10
+ - **Reconnects** by itself with capped, jittered backoff and restores channels, topology and the consumer.
11
+ - **Failing handlers** are retried with growing delays (default: up to one hour) through retry queues, so a short outage of your database never dead-letters an event; **poison messages** go to a dead-letter queue immediately.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pnpm add @relaybus/rabbitmq @relaybus/core amqplib
17
+ ```
18
+
19
+ `amqplib` is a peer dependency (`>=0.10`). RabbitMQ 3.13 or 4.x is required (quorum queues).
20
+
21
+ ## Usage
22
+
23
+ ```ts
24
+ import { RelayBus } from '@relaybus/core';
25
+ import { RabbitMqTransport } from '@relaybus/rabbitmq';
26
+
27
+ const rabbit = new RabbitMqTransport({
28
+ name: 'rabbit',
29
+ url: 'amqp://user:password@broker:5672/vhost?heartbeat=30',
30
+ exchange: 'events',
31
+ consumerGroup: 'billing', // queue: relaybus.billing
32
+ });
33
+
34
+ const bus = new RelayBus({
35
+ store,
36
+ source: 'billing',
37
+ transports: [rabbit],
38
+ // Outbound: committed OrderPaid events are published to the exchange with this routing key.
39
+ routes: [{ event: OrderPaid, transport: 'rabbit', destination: 'billing.order-paid' }],
40
+ // Inbound: routing-key patterns bound to this service's queue.
41
+ subscriptions: [{ transport: 'rabbit', destinations: ['orders.*'] }],
42
+ });
43
+
44
+ await bus.start(); // connects; rejects if the broker is unreachable
45
+ ```
46
+
47
+ A destination is a routing key when publishing and a binding pattern when subscribing
48
+ (`orders.*`, `orders.#`, `order.placed.v1`).
49
+
50
+ Readiness probes: `rabbit.isReady()` is true while the transport can publish and, once subscribed,
51
+ is consuming (connection up, channels open, consumer registered). `rabbit.isConnected()` is the
52
+ narrower check of the connection alone. relaybus health checks use `isReady()`.
53
+
54
+ ## Options
55
+
56
+ | Option | Default | Description |
57
+ | -------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58
+ | `name` | `'rabbitmq'` | Name referenced by routes and subscriptions. |
59
+ | `url` | - | `amqp://` URL or `{ hostname, port, username, password, vhost, heartbeat, protocol }`. Give exactly one of `url` and `connect`. |
60
+ | `connect` | - | Factory returning an amqplib connection. Called again after every loss. Use for TLS options or credentials. |
61
+ | `exchange` | `'relaybus'` | Durable topic exchange, created when missing. |
62
+ | `prefetch` | `50` (`1` when `ordered`) | Unacknowledged messages held by the consumer; also the handler concurrency. |
63
+ | `queuePrefix` | `'relaybus'` | Prefix of derived queue names. |
64
+ | `consumerGroup` | - | Services sharing a group share one queue `<queuePrefix>.<consumerGroup>` and split the messages. Required to subscribe unless `queue` is set. |
65
+ | `queue` | - | Explicit queue name; overrides `queuePrefix` and `consumerGroup`. |
66
+ | `deadLetterExchange` | `'<exchange>.dead'` | Direct exchange poison messages are dead-lettered to. |
67
+ | `deadLetterQueue` | `'<queue>.dead'` | Quorum queue that keeps dead-lettered messages. |
68
+ | `deliveryLimit` | `10` | Quorum queue `x-delivery-limit`: a backstop that makes the broker dead-letter a message that crashes its consumer again and again. Failing handlers do not use it. |
69
+ | `handlerRetry` | 3 attempts, 200 ms, x2, 5 s cap, equal jitter | Handler attempts, with short backoff, on one delivery before the message is scheduled for delayed redelivery (`RetryPolicyOptions`). |
70
+ | `redelivery` | 2 s, x2, 5 min cap, equal jitter, `maxAge` `'1h'` | Delayed redelivery after `handlerRetry`: `initialDelay`, `factor`, `maxDelay`, `jitter`, and `maxAge`, how long a message may keep failing before it is dead-lettered. |
71
+ | `ordered` | `false` | Consume strictly in order: single active consumer, one message at a time, failing messages retried in place. See Ordering. |
72
+ | `reconnect` | 500 ms, x2, 30 s cap, equal jitter | Reconnect backoff (`initialDelay`, `factor`, `maxDelay`, `jitter`; attempts are unlimited), or `false`. |
73
+ | `failOnConnectError` | `true` | `connect()` rejects when the broker is unreachable. When `false` it resolves and keeps trying in the background. |
74
+ | `allowUnroutable` | `false` | When `true`, publishing a message no queue is bound to resolves (the broker discards it) instead of rejecting. |
75
+ | `publishTimeout` | `'30s'` | How long `publish` waits for the confirm. |
76
+ | `shutdownTimeout` | `'30s'` | How long `close()` waits for running handlers. |
77
+ | `logger` | `console` | `log`/`warn`/`error`. |
78
+ | `random` | `Math.random` | Jitter source, for tests. |
79
+
80
+ ## Wire format
81
+
82
+ CloudEvents 1.0 in binary content mode, following the CloudEvents AMQP binding adapted to
83
+ RabbitMQ's AMQP 0-9-1:
84
+
85
+ - Body: the JSON payload, `contentType: application/json`, persistent (`deliveryMode: 2`).
86
+ - Message headers: `cloudEvents:specversion`, `cloudEvents:id`, `cloudEvents:type`,
87
+ `cloudEvents:source`, `cloudEvents:time` (RFC 3339, millisecond precision),
88
+ `cloudEvents:datacontenttype`, and when set `cloudEvents:subject` (the event key),
89
+ `cloudEvents:causationid`, `cloudEvents:correlationid`.
90
+ - AMQP properties mirror them for broker tooling: `messageId`, `type`, `appId` (source),
91
+ `correlationId`, `timestamp` (seconds).
92
+ - Event headers (such as `traceparent`) travel as `relaybus:<name>`, so they cannot collide with
93
+ CloudEvents attributes or broker headers like `x-death`.
94
+
95
+ The envelope round-trips exactly (`occurredAt` as `Date`, `key`, `causationId`, headers, including a
96
+ header literally named `__proto__`). On receive the `cloudEvents:` headers win and the AMQP properties
97
+ are the fallback, so events from other CloudEvents producers work if they follow the binding. The
98
+ time must be strict RFC 3339 (`2026-01-01T10:20:30.456Z`, offsets allowed); the AMQP `timestamp`
99
+ is used only when the `cloudEvents:time` header is absent, never to paper over a malformed one.
100
+
101
+ **Payload validation.** The payload must survive `JSON.stringify` unchanged, like on the Kafka
102
+ transport. `publish` rejects with `UnserializablePayloadError` (a `NonRetryableError`, so the outbox
103
+ does not retry it forever) for `undefined`, functions, symbols, BigInt, NaN and Infinity, array
104
+ holes, symbol keys, cycles, and anything JSON would silently convert: `Date`, `Map`, `Set`,
105
+ typed arrays and class instances. Convert such values to plain data first. Use `null` for "no payload".
106
+
107
+ **AMQP limits.** AMQP 0-9-1 short strings are limited to 255 bytes (UTF-8). The event id (also the
108
+ AMQP `messageId`, which matches broker returns), every header name (`relaybus:<name>`, so at most
109
+ 246 bytes for your own names) and the destination (routing key) must fit; otherwise `publish`
110
+ rejects with `AmqpLimitExceededError` (a `NonRetryableError`) instead of failing on every retry.
111
+ `type`, `appId` and `correlationId` are mirrored into AMQP properties only when they fit; the
112
+ `cloudEvents:` headers always carry them, so long values lose only the broker-tooling mirror.
113
+
114
+ ## Guarantees
115
+
116
+ - **No silent loss on publish.** `publish` rejects (never resolves) when the transport is
117
+ disconnected or closed (`RabbitMqUnavailableError`), the broker nacks
118
+ (`RabbitMqPublishRejectedError`), the confirm does not arrive within `publishTimeout`
119
+ (`RabbitMqUnavailableError`) or no queue is bound to the routing key (`UnroutableMessageError`).
120
+ The outbox keeps the delivery and retries it. A rejection after the broker already stored the
121
+ message (lost confirm, timeout) leads to a duplicate on retry; consumers deduplicate on the event id.
122
+ - **Mandatory publishing trade-off.** Messages are published with the AMQP `mandatory` flag so an
123
+ event nobody is bound to is reported instead of vanishing. The cost is that a publisher fails
124
+ until at least one consumer queue is bound to the routing key (start consumers first, or
125
+ declare queues in advance). Set `allowUnroutable: true` for fire-and-forget topics where "no
126
+ subscriber" is fine.
127
+ - **Acknowledge after handling.** A message is acknowledged after `onMessage` resolved, which in
128
+ relaybus means the event is in the inbox. A crash before that redelivers it; the inbox drops the duplicate.
129
+ - **Outages are waited out, not burned through.** A failing handler is retried on the same delivery
130
+ with short backoff (`handlerRetry`). If it still fails, a copy of the message is published to a
131
+ retry queue and the original is acknowledged **only after the broker confirmed the copy**
132
+ (publisher confirms, mandatory flag), so the message is always in exactly one durable place at
133
+ least. When the copy's delay is over, the broker moves it back to the main queue. Delays grow
134
+ by `redelivery.factor` up to `redelivery.maxDelay`, with jitter. With the defaults a message is
135
+ retried at about 2, 4, 8, 16, 32, 64, 128, 256 s, then every 5 min, and dead-lettered after 1 h
136
+ of failing (`redelivery.maxAge`), about 18 redeliveries of 3 handler attempts each. A multi-minute
137
+ database outage therefore delays events and loses nothing. If the copy cannot be confirmed, the
138
+ original is requeued (after the same delay), never acknowledged.
139
+ - **Delay tiers.** Each distinct delay is its own quorum retry queue (`<queue>.retry.<ms>`, nine with
140
+ the defaults) with a queue-level TTL, so messages in one queue expire in arrival order; one queue
141
+ with per-message TTLs would let a long delay block shorter ones. The jittered delay is rounded to
142
+ the tier below or above with the probability that keeps its average, so a burst of failures still
143
+ spreads across tiers. A retry copy carries `x-relaybus-redeliveries` and
144
+ `x-relaybus-first-failure-at`, which makes the window independent of which replica handles it.
145
+ - **Immediate dead-lettering** for messages that can never succeed: bodies that are not valid
146
+ CloudEvents (logged at `error`) and handler errors that are `NonRetryableError`s, such as
147
+ `InvalidEnvelopeError` from `bus.receive`. The consumer publishes a confirmed copy to the
148
+ dead-letter exchange, then acknowledges the original; the copy keeps the message headers, drops
149
+ the retry state (so replaying it starts a fresh retry window) and adds
150
+ `x-relaybus-dead-letter-reason`. If that publish fails the consumer rejects the message without
151
+ requeue and the broker dead-letters it (at-least-once strategy), so it still ends in `<queue>.dead`.
152
+ - **Broker-level backstop.** `x-delivery-limit` (`deliveryLimit`) dead-letters a message that keeps
153
+ coming back because the consumer or its channel dies while holding it.
154
+ - **Reconnection.** On connection loss, a closed channel or a cancelled consumer the transport
155
+ drops the connection, waits with capped, jittered backoff, reconnects and re-declares the
156
+ exchange, queues, bindings and consumer. Handlers still running during the loss finish; their
157
+ acks fail (logged) and the broker redelivers.
158
+ - **Ordering.** By default order is **not** preserved: `prefetch` handlers run concurrently,
159
+ several replicas compete on the queue, and a delayed redelivery overtakes later messages, so
160
+ `EventEnvelope.key` order does not hold across the broker. Handlers must tolerate reordering
161
+ (versioned state, "ignore older" checks). When per-key order matters, set `ordered: true`: the queue
162
+ gets `x-single-active-consumer` (one replica consumes, the others wait as standby and take over
163
+ when it goes away), prefetch is 1, and a failing message is retried **in place** with the
164
+ `redelivery` delays instead of moving behind newer ones, which blocks the queue until it succeeds or
165
+ `redelivery.maxAge` passes and it is dead-lettered. The cost is throughput: one handler call at a
166
+ time for the whole consumer group, and one stuck message stalls everything behind it. Order is
167
+ queue-wide (a superset of per-key order) and holds for messages published by one producer on one
168
+ connection; the broker cannot order across publishers.
169
+
170
+ ## Topology
171
+
172
+ For a subscription with `queue = relaybus.billing`, `exchange = relaybus`:
173
+
174
+ | Object | Declaration |
175
+ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
176
+ | `relaybus` exchange | topic, durable |
177
+ | `relaybus.dead` exchange | direct, durable |
178
+ | `relaybus.billing.dead` queue | quorum, durable, bound to `relaybus.dead` with key `relaybus.billing` |
179
+ | `relaybus.billing.retry.<ms>` | one quorum queue per delay tier (not when `ordered`): `x-message-ttl`, dead-lettered through the default exchange to `relaybus.billing`, `x-dead-letter-strategy: at-least-once`, `x-overflow: reject-publish` |
180
+ | `relaybus.billing` queue | quorum, durable, `x-delivery-limit`, `x-dead-letter-exchange`, `x-dead-letter-routing-key`, `x-dead-letter-strategy: at-least-once`, `x-overflow: reject-publish`, `x-single-active-consumer` when `ordered`; bound to the exchange once per destination |
181
+
182
+ `at-least-once` dead-lettering makes the quorum queue keep a message until the dead-letter target
183
+ confirmed it, so expiry, rejection and the delivery limit cannot lose messages while the target is
184
+ unavailable. RabbitMQ requires `reject-publish` overflow with it. Queues are unbounded by default,
185
+ so this only matters if you add a `max-length` policy: then publishers are refused instead of the
186
+ oldest message being dropped.
187
+
188
+ Declarations are idempotent. Redeclaring a queue with different arguments makes the broker answer
189
+ `PRECONDITION_FAILED` and `subscribe`/startup rejects; the transport never alters an existing queue.
190
+ Removing a destination from a subscription does not unbind it.
191
+
192
+ ### Migrating an existing queue
193
+
194
+ Queues created by a pre-release version of this package lack `x-dead-letter-strategy` and
195
+ `x-overflow`, and queues created with another `ordered` setting differ in `x-single-active-consumer`.
196
+ RabbitMQ cannot change these arguments in place, so either:
197
+
198
+ 1. use a new `queue` / `consumerGroup` name, let both queues receive events (the new one is bound by
199
+ the new deployment), and delete the old queue once it is empty; or
200
+ 2. stop the consumers, drain or shovel the old queue (`rabbitmqctl shovel` / the Shovel plugin) into
201
+ the exchange, delete it (`rabbitmqctl delete_queue <name>`), and start the new version.
202
+
203
+ The dead-letter queue and exchange are unchanged and need no migration.
204
+
205
+ ## Operational notes
206
+
207
+ - **Heartbeats.** When the URL carries no `heartbeat`, amqplib adopts the interval the broker
208
+ proposes (RabbitMQ defaults to 60 s), in both the 0.10 and the 2.x range, so a dead TCP connection
209
+ is noticed after roughly two intervals. Set `?heartbeat=30` (or `heartbeat` in the settings object)
210
+ to pick a shorter interval; the lower of the two values is negotiated. Beware `heartbeat=0`: from
211
+ amqplib 2.0 it explicitly **disables** heartbeats (before 2.0 it meant "use the server's value"), and
212
+ without heartbeats only `publishTimeout` catches a silently dead connection for publishers while
213
+ consumers can hang. Do not set it to 0.
214
+ - **Dead-letter queue.** Monitor `<queue>.dead`. To replay, move messages back to the exchange
215
+ with the Shovel plugin or a script; the headers (`cloudEvents:*`) are intact and the inbox deduplicates by id.
216
+ - **One subscription per transport.** A transport owns one consumer group's queue. To consume
217
+ several groups from one process create several `RabbitMqTransport` instances with different
218
+ `name` and `consumerGroup`.
219
+ - **Startup.** `failOnConnectError: true` (default) makes `bus.start()` fail fast when the broker
220
+ is down, so orchestrators restart the pod. Use `false` to boot degraded; publishes are rejected
221
+ and retried by the outbox until the broker is back.
222
+ - **TLS.** Pass `connect: () => amqplib.connect('amqps://...', { ca: [...] })`.
223
+ - **Consumer scaling.** Add replicas with the same `consumerGroup`; they compete on one queue.
224
+
225
+ ## Troubleshooting
226
+
227
+ | Symptom | Cause and fix |
228
+ | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
229
+ | `UnroutableMessageError` | No queue is bound to the routing key. Start the consuming service first, check the destination, or set `allowUnroutable`. |
230
+ | `PRECONDITION_FAILED` on start | A queue or exchange exists with different arguments or type. Align the options or delete the old object. |
231
+ | `Not connected to the broker` | Broker unreachable or reconnecting; the outbox retries. Check `isConnected()` and the `[ConnectionSupervisor]` log lines. |
232
+ | Messages pile up in `<queue>.dead` | Handler errors, malformed messages or a dependency that stayed down past `redelivery.maxAge`; the `error` log lines and the `x-relaybus-dead-letter-reason` header name the cause. |
233
+ | `UnserializablePayloadError` / `AmqpLimitExceededError` | The event can never be published as is: fix the payload (plain JSON data only) or shorten the id, header name or routing key. |
234
+ | Events arrive out of order | Expected without `ordered: true`; see Ordering. |
235
+ | `isReady()` is false but `isConnected()` is true | The consumer was cancelled (queue deleted, node failover) and is being restored, or the publisher channel closed. |
236
+ | Publishes hang until `publishTimeout` | Broker is blocked (memory or disk alarm) or the connection died without heartbeats. |
237
+ | `Needs 'consumerGroup' or 'queue' to subscribe` | Set `consumerGroup` (or `queue`) on the transport that subscribes. |