Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,19 @@ is that audit, not a lint.

A tracker entry is now open at #524.

**Amended 2026-09-28: further instances of the trigger shape, after promotion.** A local pre-PR review of the #533
`Chatter.MessageBrokers.RabbitMQ` branch returned a cluster of findings against the `INVARIANT:` comments on
`RabbitMqReceiver` that described the native quorum counters. The comments asserted a universal negative — that the
Receiver reads `x-acquired-count` on no delivery shape — and named facts that pinned particular delivery shapes; no
finite set of facts pins the negative over all of them. Each pass tightened one clause and the next pass found another
the named oracles did not reach. Each false clause lived on one surface, the code comment itself, so these are the trigger's shape and are not
excluded as restatement drift. A `grep` lint would have passed every one of them, because each named a real, passing,
relevant `[Fact]`. They change nothing recorded above: the trigger is already tripped and #524's scope stands as filed.
The response was again a re-keying, not a check. Consumption of the broker's counters moved to the one point where a
delivery is captured, so the comments now make one claim each about that one site, each naming an oracle and a measured
mutation; the universal negative is no longer claimed, because downstream of that site the counters do not exist.
ADR-0042 records the mechanism, under *Amendment: the native counters are consumed at the receive boundary*.

## Consequences

- An `INVARIANT:` comment is now a claim with an obligation attached, and `NOTE:` is the unobligated form. A
Expand Down

Large diffs are not rendered by default.

28 changes: 19 additions & 9 deletions docs/design/rabbitmq-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,9 @@ model:

- An `AsyncEventingBasicConsumer` (or equivalent async consumer) is registered on the single
serialized receive channel during `InitializeAsync`. Its delivery callback **does not handle**
the message; it wraps each delivery (body + headers + delivery tag + owning channel-epoch) and
**writes it into a bounded `Channel<T>` buffer**.
the message; it wraps each delivery (body + headers + delivery tag + owning channel-epoch + the
prior-delivery count read from the delivery's counter header) and **writes it into a bounded
`Channel<T>` buffer**. The broker's native counter headers are consumed here and not buffered (§6).
- `ReceiveMessageAsync` **reads** from that `Channel<T>` with `await reader.ReadAsync(ct)`. When the
buffer is empty the read **asynchronously parks** the loop — no CPU, no polling — until the push
consumer enqueues the next delivery or cancellation fires. This satisfies the blocking-pull
Expand Down Expand Up @@ -235,9 +236,11 @@ The adapter maps the core's three terminal operations onto AMQP, all on the gate
and all epoch-guarded:

- **Ack on success** (`AckMessageAsync`) → `BasicAck(deliveryTag)`. Epoch-guarded.
- **Nack → redelivery on failure** (`NackMessageAsync`) → `BasicNack(deliveryTag, requeue: true)`
(Quorum) — the broker increments native `x-delivery-count` and redelivers. (Classic uses the
header-stamped republish counter; see §6.) Epoch-guarded.
- **Nack → redelivery on failure** (`NackMessageAsync`) → `BasicReject(deliveryTag, requeue: true)`
(Quorum) — the broker counts a failed delivery attempt, increments native `x-delivery-count` and
redelivers. `BasicReject`, not `BasicNack`: from RabbitMQ 4.3 a quorum queue counts only
`basic.reject` as a failed delivery, and before 4.3 the two verbs run the same broker code (ADR 0042).
(Classic uses the header-stamped republish counter; see §6.) Epoch-guarded.
- **Deadletter once Max Receives Exceeded** (`DeadletterMessageAsync`) → the adapter **republishes**
the body to the **attribute-declared** DeadletterQueueName / ErrorQueueName (an adapter-owned
republish, authoritative over any broker-side DLX configuration), then **acks the original**. This
Expand All @@ -255,7 +258,7 @@ stateDiagram-v2
[*] --> Received: push consumer buffers delivery
Received --> Handling: core pulls + dispatches to handler
Handling --> Acked: handler success → BasicAck
Handling --> NackedRedelivered: handler failure,<br/>count ≤ limit → BasicNack(requeue) / classic republish
Handling --> NackedRedelivered: handler failure,<br/>count ≤ limit → BasicReject(requeue) / classic republish
Handling --> DeadLettered: Max Receives Exceeded →<br/>republish to declared DLQ/Error, then ack original
NackedRedelivered --> Received: broker redelivers
Acked --> [*]
Expand All @@ -278,8 +281,15 @@ single value (ADR 0001).
A `QueueType` option selects the strategy (default **Quorum**, recommended):

- **Quorum strategy** — reads RabbitMQ's **native `x-delivery-count`** header, which the broker
increments per redelivery. Adapter computes attempts = `x-delivery-count + 1` and stamps
`ReceiveAttempts`. Redelivery on failure is a plain `BasicNack(requeue: true)`.
increments per failed delivery attempt. Adapter computes attempts = `x-delivery-count + 1` and
stamps `ReceiveAttempts`. Redelivery on failure is a plain `BasicReject(requeue: true)`, which
RabbitMQ counts as a failed delivery attempt; from RabbitMQ 4.3 `BasicNack(requeue: true)` does not
advance `x-delivery-count` (ADR 0042). The 4.3+ `x-acquired-count` header counts assignments to a
consumer, not failures, so the adapter never reads it as the attempt count. Both native counters are
broker-owned and consumed at the receive boundary: the push consumer reads the prior-delivery count
once, from the key the queue type selects, and removes `x-delivery-count` and `x-acquired-count`
from the headers it buffers, so neither reaches `ReceivedMessage.Headers`, the emitted
`MessageContext`, a send made while handling, or any republished copy (ADR 0042).
- **Classic strategy** — classic queues expose no native counter, so the adapter uses a
**header-stamped republish counter**: on retry it republishes the message to its own queue with a
custom `x-chatter-delivery-count` header incremented by 1 (publisher-confirmed), then acks the
Expand All @@ -298,7 +308,7 @@ flowchart TD
Count --> Stamp["stamp MessageContext.ReceiveAttempts<br/>(MANDATORY — absent or unusable falls to the core's dead-letter sentinel)"]
Stamp --> Decide{"attempts > maxReceiveAttempts?"}
Decide -- no, handler succeeded --> Ack["BasicAck"]
Decide -- "no, handler failed" --> Redeliver["Quorum: BasicNack(requeue)<br/>Classic: republish w/ incremented header + ack original"]
Decide -- "no, handler failed" --> Redeliver["Quorum: BasicReject(requeue)<br/>Classic: republish w/ incremented header + ack original"]
Decide -- yes --> DLQ["republish to declared<br/>Deadletter / Error path, then ack original"]
```

Expand Down
9 changes: 5 additions & 4 deletions src/Chatter.MessageBrokers.RabbitMQ/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,15 @@ _Avoid_: dispatcher (reserved for the Message Brokers Brokered Message Dispatche

**Dead-Letter Exchange (DLX) / Dead-letter Queue**: The destination for messages that exhausted Recovery; the adapter republishes to the attribute-declared DeadletterQueueName / ErrorQueueName rather than relying on broker DLX configuration.

**RabbitMq Settlement**: The RabbitMQ realization of Settlement (Message Brokers context). Under `TransactionMode.None` the AMQP push consumer is registered with `autoAck`, so RabbitMQ removed the delivery at receive time and acknowledge, negative acknowledge and deadletter all report the **Not Required** Settlement Outcome — the message is simply dropped, which is what at-most-once means. Otherwise an ack, a requeue/republish nack, or a deadletter republish-then-ack reports **Settled**, and a settlement whose delivery is absent from the message broker context reports **Failed**.
**RabbitMq Settlement**: The RabbitMQ realization of Settlement (Message Brokers context). Under `TransactionMode.None` the AMQP push consumer is registered with `autoAck`, so RabbitMQ removed the delivery at receive time and acknowledge, negative acknowledge and deadletter all report the **Not Required** Settlement Outcome — the message is simply dropped, which is what at-most-once means. Otherwise an ack, a negative acknowledge (a requeue `basic.reject` on a quorum queue, a republish-then-ack on a classic queue; ADR 0042), or a deadletter republish-then-ack reports **Settled**, and a settlement whose delivery is absent from the message broker context reports **Failed**.

**Channel Epoch**: The generation counter of the receive channel, carried on every buffered delivery. A settlement runs under the receive-channel gate and compares the delivery's carried epoch with the current one; on a mismatch the channel was recycled since delivery, so the delivery tag is meaningless on the new channel and RabbitMQ has already redelivered the message. The settlement is skipped and reports the **Failed** Settlement Outcome — it was ATTEMPTED and did not happen — never Not Required.

**Error-Queue Write Ownership** (`WritesToErrorQueue`): This receiver owns the Error Queue write exactly when NO Dead-letter Queue is configured — the ERROR-ONLY configuration, where deadlettering republishes the failed delivery to the Error Queue itself (publisher-confirmed) before acking the original. That path truthfully reports **Settled**, and the separate ownership signal is what keeps the Brokered Message Receiver from forwarding a SECOND copy of the same poison message to the SAME Error Queue: the single-copy rule holds because the duplicate is suppressed by ownership, never by misreporting the Settlement Outcome. With a Dead-letter Queue configured the receiver republishes there and never touches the Error Queue, so ownership stays with the Brokered Message Receiver and a copy is forwarded to the Error Queue as well. Configuring neither queue is rejected at startup, except under at-most-once (`TransactionMode.None`), which has no poison target to require.
_Avoid_: gating the Error Queue write on the Settlement Outcome (a truthful Settled would then write two copies of every poison message).

**Delivery Count Strategy**: How redeliveries are counted — Quorum (native `x-delivery-count`, recommended) or Classic (header-stamped republish counter). See ADR 0001.
**Delivery Count Strategy**: How redeliveries are counted — Quorum (native `x-delivery-count`, recommended) or Classic (header-stamped republish counter). On a quorum queue the Receiver returns a failed delivery with `basic.reject` and requeue, which RabbitMQ counts as a failed delivery attempt and records in `x-delivery-count`; from RabbitMQ 4.3 a `basic.nack` is not counted. The native `x-acquired-count` (RabbitMQ 4.3+) counts assignments to a consumer, not failures, and is never the attempt count. See ADR 0001 and ADR 0042.
_Avoid_: reading `x-acquired-count` as the attempt count (a consumer timeout or a partition advances it with no handler failure).

**RabbitMq Options**: Configuration for the connection, prefetch, queue type, TLS, and body settings, supplied via the options builder. TLS (`UseTls` / `TlsServerName` / `WithTls(...)`) applies only to the discrete host/credential connection path — an `amqps://` connection URI already enables TLS and takes precedence — and offers no surface to weaken or disable certificate validation.

Expand All @@ -45,11 +46,11 @@ _Avoid_: gating the Error Queue write on the Settlement Outcome (a truthful Sett
## Example dialogue

> **Dev:** "On a classic queue, how does it know a message is poison if RabbitMQ won't count deliveries?"
> **Domain expert:** "The Classic Delivery Count Strategy republishes the message to its own queue with an incremented `x-chatter-delivery-count` header, then acks the original — the count rides in the message. On a quorum queue we just read the native `x-delivery-count` instead, which is why quorum is the recommended default."
> **Domain expert:** "The Classic Delivery Count Strategy republishes the message to its own queue with an incremented `x-chatter-delivery-count` header, then acks the original — the count rides in the message. On a quorum queue the Receiver returns the failed delivery with `basic.reject` and requeue, RabbitMQ counts that as a failed delivery attempt, and we read the native `x-delivery-count` instead, which is why quorum is the recommended default."

## Flagged ambiguities

- **Quorum vs Classic delivery-count semantics**: quorum queues count redeliveries natively; classic queues do not, so the count is carried in a republish header (ADR 0001) with a rare-duplicate trade-off.
- **Quorum vs Classic delivery-count semantics**: quorum queues count failed deliveries natively, and from RabbitMQ 4.3 only a `basic.reject` (or a lost consumer) counts as one, not a `basic.nack` (ADR 0042); classic queues do not, so the count is carried in a republish header (ADR 0001) with a rare-duplicate trade-off.
- **Default-exchange-as-queue-name convention**: when no Exchange override is given, publishing uses the default exchange with Routing Key equal to the destination Queue name — Routing Key and Queue name coincide only under this convention.

## Known limitations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) an

## [Unreleased]

## [0.6.2] - 2026-09-28

### Fixed

- **A quorum-queue message whose handler kept failing was redelivered indefinitely and never dead-lettered, on RabbitMQ 4.3 and later.** From RabbitMQ 4.3, a `basic.nack` with requeue no longer advances the queue's native `x-delivery-count`, so the receiver's attempt count never rose and `maxReceiveAttempts` was never reached. The receiver now returns a failed delivery with `basic.reject` and requeue instead, which RabbitMQ counts as a failed delivery attempt on every supported version. When `maxReceiveAttempts` is kept below the queue's `delivery-limit` (20 by default from RabbitMQ 4.0), the message is dead-lettered once `maxReceiveAttempts` is hit (to the Dead-letter Queue when one is configured, otherwise the Error Queue); at or above it, RabbitMQ can drop the message or dead-letter it through the queue's DLX first. No configuration or API change, and no behavior change on RabbitMQ before 4.3 or on classic queues, which already counted redeliveries through their own header. The earlier advice to use classic queues on RabbitMQ 4.3 and later no longer applies — see [Quorum queues](https://github.com/brenpike/Chatter/blob/master/src/Chatter.MessageBrokers.RabbitMQ/src/README.md#quorum-queues). (#533)
- **The native quorum counters `x-delivery-count` and `x-acquired-count` are now consumed when a delivery is received, not carried onto anything downstream.** They are stripped before `ReceivedMessage` is built, so `ReceivedMessage.Headers` never contains them, and they are never copied onto messages sent while handling a delivery or onto the message republished to the Dead-letter Queue, the Error Queue, or a classic redelivery. `MessageContext.ReceiveAttempts` is unaffected. (#533)

## [0.6.1] - 2026-09-24

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<PackageId>Chatter.MessageBrokers.RabbitMQ</PackageId>
<Version>0.6.1</Version>
<Version>0.6.2</Version>
<Authors>Brennan Pike</Authors>
<Owners>Brennan Pike</Owners>
<Description>An implementation of the Chatter.MessageBrokers adapter library for RabbitMQ.</Description>
Expand Down
Loading
Loading