From 3e0a5f256434ef4167d90a8949a3acb91bd7f65f Mon Sep 17 00:00:00 2001 From: Toby Henderson Date: Mon, 27 Apr 2026 22:54:57 +0100 Subject: [PATCH 1/8] docs(adr): propose strategy for RabbitMQ delayed messaging post plugin archival MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ADR 0057 analysing five options for replacing the archived rabbitmq_delayed_message_exchange plugin (Mnesia-based, incompatible with RabbitMQ 4.3+ Khepri-only). Recommends combining Option B (route delay through IAmAMessageScheduler — infrastructure Brighter already ships) with Option E (deprecate and remove Exchange.SupportDelay over a major), and documents Option D (Tanzu RabbitMQ delayed queues) as a supported configuration for paying users. Tracks issue #4105. Co-Authored-By: Claude Opus 4.7 --- ...057-rabbitmq-delayed-messaging-strategy.md | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/adr/0057-rabbitmq-delayed-messaging-strategy.md diff --git a/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md b/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md new file mode 100644 index 0000000000..a396d0f993 --- /dev/null +++ b/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md @@ -0,0 +1,145 @@ +# 57. RabbitMQ Delayed Messaging Strategy (Post Plugin Archival) + +Date: 2026-04-27 + +## Status + +Proposed + +## Context + +Brighter's RabbitMQ transports — both [`Paramore.Brighter.MessagingGateway.RMQ.Sync`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Sync) and [`Paramore.Brighter.MessagingGateway.RMQ.Async`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async) — expose `Exchange.SupportDelay = true` as the opt-in for broker-side message delay. Setting that flag changes the exchange declaration to type `x-delayed-message`, which requires the [`rabbitmq_delayed_message_exchange`](https://github.com/rabbitmq/rabbitmq-delayed-message-exchange) community plugin to be installed on the broker. Brighter's CI test image (`brightercommand/rabbitmq`) bundles the plugin to exercise this path. + +Three upstream events converge to make the current design untenable: + +1. **The plugin's repository was archived** by Team RabbitMQ on 2026-01-29. Its final release is `v4.2.0-rc.1` — a release candidate. No stable 4.2 release will ever ship. +2. **RabbitMQ 4.3** (released 2026-04-21) [removed Mnesia entirely](https://www.rabbitmq.com/blog/2026/04/23/rabbitmq-4.3-release) and is Khepri-only. The plugin's persistence layer is built on Mnesia, so the plugin cannot run on 4.3+. +3. **Team RabbitMQ now recommends** TTL + DLX patterns or external schedulers for OSS users, and steers paying users to VMware Tanzu RabbitMQ's [`rabbitmq_delayed_queue`](https://techdocs.broadcom.com/us/en/vmware-tanzu/data-solutions/tanzu-rabbitmq-ova/4-2/tanzu-rabbitmq-ova-virtual-machine/site-delayed-queues.html) (Raft-replicated, commercial). + +The terminal working combination — RMQ 4.2 + plugin v4.2.0-rc.1 — is being adopted in PR #4104, but that is a holding pattern. We need a long-term answer. + +### What Brighter already has + +A scheduler-based fallback already exists. In [`RmqMessageConsumer.RequeueAsync`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageConsumer.cs): + +```csharp +if (DelaySupported || timeout <= TimeSpan.Zero) +{ + var rmqMessagePublisher = new RmqMessagePublisher(Channel, Connection); + await rmqMessagePublisher.RequeueMessageAsync(message, _queueName, timeout.Value, cancellationToken); +} +else +{ + EnsureProducer(); + await _requeueProducer!.SendWithDelayAsync(message, timeout, cancellationToken); +} +``` + +When `DelaySupported` is `false`, requeue-with-delay routes through `IAmAMessageScheduler`. Brighter ships six production schedulers — `Paramore.Brighter.MessageScheduler.{Aws, AWS.V4, Azure, Hangfire, Quartz, TickerQ}` — plus an `InMemoryScheduler`. The pieces for a no-broker-plugin path are already in the box. + +The `SendWithDelay` API surface (used directly by callers, not just by requeue) is the other consumer of the plugin path; that one currently has no equivalent fallback when `SupportDelay = false`. + +## Options under consideration + +### Option A — Stay on RabbitMQ 4.2 + plugin (status quo after PR #4104) + +Pin the test broker and any user opting into `SupportDelay` at RMQ 4.2 forever. Plugin remains at `v4.2.0-rc.1`. + +- **Pros**: zero code change; existing users keep working; familiar semantics; one-line `x-delay` header per message. +- **Cons**: locks Brighter's test broker at RMQ 4.2 indefinitely; users on `SupportDelay` cannot upgrade to 4.3+; plugin is unmaintained, so a future OTP/Erlang/RabbitMQ patch could silently break it; we own a deprecation surface that upstream has already disowned. +- **Migration cost**: nil. +- **Time horizon**: viable until Erlang/OTP or RabbitMQ 4.2.x maintenance ends — likely 1–2 years. + +### Option B — Drop the plugin path; rely on `IAmAMessageScheduler` + +Treat `IAmAMessageScheduler` as the only supported delay mechanism. Mark `Exchange.SupportDelay` `[Obsolete]`; route both `SendWithDelay` and `RequeueAsync(timeout)` through the scheduler when no plugin is present. Remove the plugin-aware code paths in the next major. + +- **Pros**: leverages infrastructure Brighter already ships; decouples from broker-version drift; works on RMQ 3.x, 4.x, 5.x, and any future major; same code path as AWS/Azure/Kafka transports that lack native delay; consistent operator story. +- **Cons**: requires consumers to register a scheduler (Hangfire/Quartz/TickerQ/etc.); introduces a second piece of infrastructure; semantics shift slightly — delayed messages no longer sit in a broker queue, they sit in the scheduler's store; visibility moves from RabbitMQ Management UI to whatever the scheduler exposes. +- **Migration cost**: low for users — register an `IAmAMessageScheduler` in DI; ~moderate for Brighter — extend `SendWithDelay` to use scheduler, deprecate `SupportDelay`. +- **Time horizon**: durable across RMQ majors. + +### Option C — Implement TTL + DLX in the RMQ transport + +Make Brighter's RMQ transport synthesise broker-native delay via a queue with `x-message-ttl` (or per-message TTL) plus `x-dead-letter-exchange` pointing at the destination. Two sub-variants: + +- **C1 — Per-queue TTL with delay buckets.** Declare a fixed set of holding queues with TTLs (e.g. 1s / 10s / 1m / 5m / 1h / 1d), each dead-lettering to the target exchange. Round each `SendWithDelay` request up to the nearest bucket. +- **C2 — Per-message TTL into a single buffer queue.** One buffer queue per destination, dead-lettering on expiry. Each message carries `expiration` (per-message TTL). + +- **Pros**: pure RabbitMQ — no plugin, no external infrastructure; works on any RMQ version. +- **Cons (C1)**: precision is quantised to bucket size; many bucket queues to manage; a 7-second delay either rounds up to 10s (correct-ish) or down to 1s (wrong); not a drop-in replacement for the plugin's millisecond-precision per-message delay. +- **Cons (C2)**: **head-of-line blocking** — RabbitMQ only dead-letters from the *head* of a queue. If a 1-hour-TTL message is queued before a 1-second-TTL message, the 1-second message waits an hour. The [official docs](https://www.rabbitmq.com/docs/ttl) call this out explicitly. This makes C2 a poor fit for arbitrary-precision delay. +- **Migration cost**: moderate-high for Brighter — non-trivial transport change, queue topology management, test coverage; nil for users on the API surface. +- **Time horizon**: durable. + +### Option D — Adopt VMware Tanzu RabbitMQ's `rabbitmq_delayed_queue` + +Tanzu RabbitMQ ships a closed-source replacement: a new queue type `x-queue-type: delayed` using Ra (Raft) for replication, scaling to "tens or hundreds of millions of delayed messages", supporting `x-delay`, `x-opt-delivery-delay`, and `x-opt-delivery-time` headers. + +- **Pros**: official, supported, replicated, scalable; `x-delay` header is preserved, so very small Brighter-side change (declare the queue with the new type instead of declaring an `x-delayed-message` exchange). +- **Cons**: **commercial only** — Tanzu RabbitMQ is a Broadcom product. OSS RabbitMQ users are excluded. Brighter cannot make this its default path without alienating its OSS user base. +- **Migration cost**: small for Tanzu users; impossible for OSS users. +- **Time horizon**: durable for paying users. + +### Option E — Mark `Exchange.SupportDelay` `[Obsolete]` and remove next major + +A signalling option, not standalone. Layered on top of A, B, or C. + +- **Pros**: cheapest possible response; aligns Brighter with upstream's archival signal; gives users a release window to migrate. +- **Cons**: alone, it doesn't pick a replacement — must combine with B and/or C. + +## Decision + +**Recommendation: B + E combined, with documentation for D.** + +1. **In the current major**, keep the plugin path working (RMQ 4.2 + `v4.2.0-rc.1`, as PR #4104 lands). Mark `Exchange.SupportDelay` `[Obsolete("Configure an IAmAMessageScheduler instead — the rabbitmq_delayed_message_exchange plugin is archived upstream and incompatible with RabbitMQ 4.3+. See ADR 0057.")]`. Extend `SendWithDelay` to use `IAmAMessageScheduler` when registered, falling through to the plugin path only when the scheduler is absent and `SupportDelay = true`. +2. **In the next major**, remove the plugin-aware code paths. `SendWithDelay` and requeue-with-delay route exclusively through `IAmAMessageScheduler`. The CI test image stops bundling the plugin and pins to plain `rabbitmq:management`. +3. **For Tanzu users**, add a documentation note in [`docs/contents/RabbitMQConfiguration.md`](../contents/RabbitMQConfiguration.md) describing how to declare `x-queue-type: delayed` queues if they want to keep broker-side delay. No code change needed — Tanzu accepts the same `x-delay` header Brighter already emits, so Brighter's wire format is forward-compatible. + +We **reject Option A** because indefinite RMQ 4.2 lock-in is a slow-motion failure mode. + +We **reject Option C** because the engineering cost (especially for fair per-queue-bucket implementations with their own ADR-worthy edge cases) is high relative to the value, given Brighter already has six scheduler implementations and an established "register a scheduler when the transport lacks native delay" pattern (used by AWS classic and Azure where applicable). + +We **reject Option D as Brighter's default** because it excludes OSS users, but document it as a supported configuration for Tanzu users. + +## Consequences + +### Positive + +- One delay implementation across all transports without native broker-side delay (RMQ, Redis), reducing surface area. +- Decouples Brighter's RMQ support from broker-version drift; we can move freely to RMQ 4.3, 4.4, 5.0+. +- Operators get a single set of metrics/dashboards (their scheduler) for all delayed messages regardless of transport. +- No new transport code to maintain; we lean on infrastructure already in `Paramore.Brighter.MessageScheduler.*`. +- Removes a known-broken-on-4.3 dependency that we'd otherwise have to keep patching around. + +### Negative + +- Users currently relying on `SupportDelay = true` must register an `IAmAMessageScheduler` before the next major. This is a real migration, not just a renaming. +- Delayed messages are no longer visible in RabbitMQ Management UI as queued-but-not-yet-delivered; they sit in the scheduler's store. Operators using the RMQ UI for visibility will need to look elsewhere. +- Sub-second delay precision now depends on the scheduler implementation. Hangfire and Quartz have polling intervals (default ~15s for Hangfire); TickerQ is finer-grained. Users with strict sub-second SLAs need to pick the scheduler appropriately. +- Adds a deployment dependency for delay users (a scheduler backend), which the plugin path did not require beyond the broker. + +### Risks and Mitigations + +- **Risk**: The deprecation window catches users by surprise, breaking production at the next major. **Mitigation**: ship the `[Obsolete]` warning one minor before removal; flag prominently in release notes; leave the plugin path working in the current major so warning-suppression is reversible. +- **Risk**: Some user has built around the broker-side queue visibility for delayed messages. **Mitigation**: document the visibility shift in the migration guide; for users on Tanzu, point them at Option D. +- **Risk**: Scheduler choice paralysis — users don't know which of six to pick. **Mitigation**: add a table to the RMQ docs comparing the six scheduler backends (in-process vs distributed, polling interval, persistence). Recommend TickerQ for in-process, Hangfire or Quartz for distributed deployments. + +## Alternatives Considered + +The five options above are the alternatives. A summary of why each non-recommended option was rejected: + +- **A** (status quo): defers the problem rather than solves it; locks the broker version. +- **C** (TTL+DLX): higher engineering cost than B for inferior semantics (precision quantised or head-of-line blocked). +- **D** (Tanzu plugin): not viable as a default for an OSS framework; documented as a supported configuration. + +## References + +- Tracking issue: [#4105](https://github.com/BrighterCommand/Brighter/issues/4105) +- Holding-pattern PR: [#4104](https://github.com/BrighterCommand/Brighter/pull/4104) (RMQ 4.2 + plugin v4.2.0-rc.1) +- Plugin archival: +- RabbitMQ 4.3 (Mnesia removed): +- TTL + DLX official docs: , +- Tanzu delayed queues: +- Brighter scheduler interface: [`src/Paramore.Brighter/IAmAMessageScheduler.cs`](../../src/Paramore.Brighter/IAmAMessageScheduler.cs) +- Existing fallback site: [`src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageConsumer.cs`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageConsumer.cs) (the `else` branch in `RequeueAsync`) From 87776d7b83146c95257557fe9f9ce965b8e744ef Mon Sep 17 00:00:00 2001 From: Toby Henderson Date: Mon, 27 Apr 2026 22:58:16 +0100 Subject: [PATCH 2/8] docs(adr): restructure ADR 0057 to match Brighter ADR template Follows .claude/commands/adr/adr.md conventions: - Context split into Scope / The Problem / Constraints sub-sections - Alternatives Considered uses canonical "Alternative N: Name" headings with "Rejected because" bullet lists - Adds Responsibilities sub-section per design-principles review (Responsibility-Driven Design: knowing/doing/deciding) - References reformatted as bulleted list - Adds explicit ambiguity-during-Phase-1 risk and mitigation Substantive recommendation unchanged: B (route through IAmAMessageScheduler) combined with E (deprecate Exchange.SupportDelay), with D documented as a supported configuration for Tanzu users. Co-Authored-By: Claude Opus 4.7 --- ...057-rabbitmq-delayed-messaging-strategy.md | 170 +++++++++--------- 1 file changed, 90 insertions(+), 80 deletions(-) diff --git a/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md b/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md index a396d0f993..dd5a8a6d8f 100644 --- a/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md +++ b/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md @@ -8,7 +8,11 @@ Proposed ## Context -Brighter's RabbitMQ transports — both [`Paramore.Brighter.MessagingGateway.RMQ.Sync`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Sync) and [`Paramore.Brighter.MessagingGateway.RMQ.Async`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async) — expose `Exchange.SupportDelay = true` as the opt-in for broker-side message delay. Setting that flag changes the exchange declaration to type `x-delayed-message`, which requires the [`rabbitmq_delayed_message_exchange`](https://github.com/rabbitmq/rabbitmq-delayed-message-exchange) community plugin to be installed on the broker. Brighter's CI test image (`brightercommand/rabbitmq`) bundles the plugin to exercise this path. +**Scope**: This ADR addresses how Brighter's RabbitMQ transports should provide delayed messaging now that the upstream [`rabbitmq_delayed_message_exchange`](https://github.com/rabbitmq/rabbitmq-delayed-message-exchange) plugin has been archived and is incompatible with RabbitMQ 4.3+. + +### The Problem + +Brighter's RabbitMQ transports — both [`Paramore.Brighter.MessagingGateway.RMQ.Sync`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Sync) and [`Paramore.Brighter.MessagingGateway.RMQ.Async`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async) — expose `Exchange.SupportDelay = true` as the opt-in for broker-side message delay. Setting that flag changes the exchange declaration to type `x-delayed-message`, which requires the community plugin to be installed on the broker. Brighter's CI test image (`brightercommand/rabbitmq`) bundles the plugin to exercise this path. Three upstream events converge to make the current design untenable: @@ -16,122 +20,127 @@ Three upstream events converge to make the current design untenable: 2. **RabbitMQ 4.3** (released 2026-04-21) [removed Mnesia entirely](https://www.rabbitmq.com/blog/2026/04/23/rabbitmq-4.3-release) and is Khepri-only. The plugin's persistence layer is built on Mnesia, so the plugin cannot run on 4.3+. 3. **Team RabbitMQ now recommends** TTL + DLX patterns or external schedulers for OSS users, and steers paying users to VMware Tanzu RabbitMQ's [`rabbitmq_delayed_queue`](https://techdocs.broadcom.com/us/en/vmware-tanzu/data-solutions/tanzu-rabbitmq-ova/4-2/tanzu-rabbitmq-ova-virtual-machine/site-delayed-queues.html) (Raft-replicated, commercial). -The terminal working combination — RMQ 4.2 + plugin v4.2.0-rc.1 — is being adopted in PR #4104, but that is a holding pattern. We need a long-term answer. +The terminal working combination — RMQ 4.2 + plugin v4.2.0-rc.1 — is being adopted in PR #4104 as a holding pattern. We need a long-term answer. -### What Brighter already has +### Constraints -A scheduler-based fallback already exists. In [`RmqMessageConsumer.RequeueAsync`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageConsumer.cs): +- **Existing role available.** `IAmAMessageScheduler` is already a role in Brighter, with six production implementations: `Paramore.Brighter.MessageScheduler.{Aws, AWS.V4, Azure, Hangfire, Quartz, TickerQ}`, plus an `InMemoryScheduler`. Other transports without native broker-side delay (notably AWS classic where applicable) already use this role. +- **Partial fallback already wired.** [`RmqMessageConsumer.RequeueAsync`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageConsumer.cs#L419) routes through the scheduler when `DelaySupported = false`: + ```csharp + if (DelaySupported || timeout <= TimeSpan.Zero) + await rmqMessagePublisher.RequeueMessageAsync(message, _queueName, timeout.Value, cancellationToken); + else + await _requeueProducer!.SendWithDelayAsync(message, timeout, cancellationToken); + ``` + The `SendWithDelay` producer surface, used directly by callers (not just by requeue), does **not** yet have an equivalent fallback. +- **OSS users must keep working.** Brighter cannot mandate Tanzu RabbitMQ — its OSS user base is significant and would be excluded by a commercial-only path. +- **Migration window required.** Existing users with `Exchange.SupportDelay = true` should not break at the next minor release. A deprecation warning before removal is required. +- **Wire format compatibility.** RabbitMQ's various delay implementations (community plugin, Tanzu plugin) all accept the `x-delay` header Brighter already emits. Decisions about *where* the delay is enforced are independent of *how* the message is shaped on the wire. -```csharp -if (DelaySupported || timeout <= TimeSpan.Zero) -{ - var rmqMessagePublisher = new RmqMessagePublisher(Channel, Connection); - await rmqMessagePublisher.RequeueMessageAsync(message, _queueName, timeout.Value, cancellationToken); -} -else -{ - EnsureProducer(); - await _requeueProducer!.SendWithDelayAsync(message, timeout, cancellationToken); -} -``` +## Decision -When `DelaySupported` is `false`, requeue-with-delay routes through `IAmAMessageScheduler`. Brighter ships six production schedulers — `Paramore.Brighter.MessageScheduler.{Aws, AWS.V4, Azure, Hangfire, Quartz, TickerQ}` — plus an `InMemoryScheduler`. The pieces for a no-broker-plugin path are already in the box. +Combine **Option B** (route delay through `IAmAMessageScheduler`) with **Option E** (deprecate `Exchange.SupportDelay` and remove next major), with **Option D** (Tanzu) documented as a supported configuration for paying users. -The `SendWithDelay` API surface (used directly by callers, not just by requeue) is the other consumer of the plugin path; that one currently has no equivalent fallback when `SupportDelay = false`. +### Phase 1 — Current major -## Options under consideration +1. Mark `Exchange.SupportDelay` `[Obsolete("Configure an IAmAMessageScheduler instead — the rabbitmq_delayed_message_exchange plugin is archived upstream and incompatible with RabbitMQ 4.3+. See ADR 0057.")]`. +2. Extend the producer's `SendWithDelay` path to use `IAmAMessageScheduler` when one is registered, falling through to the plugin path only when no scheduler is registered and `SupportDelay = true` (preserves current behaviour for users who haven't migrated). +3. Keep the plugin path operational against RMQ 4.2 + plugin v4.2.0-rc.1 (PR #4104 already adopts this pinning). +4. Add a migration guide section to [`docs/contents/RabbitMQConfiguration.md`](../contents/RabbitMQConfiguration.md) showing how to register a scheduler and remove `SupportDelay`. -### Option A — Stay on RabbitMQ 4.2 + plugin (status quo after PR #4104) +### Phase 2 — Next major -Pin the test broker and any user opting into `SupportDelay` at RMQ 4.2 forever. Plugin remains at `v4.2.0-rc.1`. +1. Remove the plugin-aware code paths in `ExchangeConfigurationHelper`, the `x-delay` exchange declaration, and the `DelaySupported` branch in `RmqMessageConsumer.RequeueAsync`. +2. Make `IAmAMessageScheduler` registration mandatory for any caller of `SendWithDelay` or `RequeueAsync(timeout: nonzero)` on RMQ. Throw a `ConfigurationException` at startup if delay is used without a registered scheduler. +3. Stop bundling the delay plugin in the CI test image; pin to plain `rabbitmq:management`. -- **Pros**: zero code change; existing users keep working; familiar semantics; one-line `x-delay` header per message. -- **Cons**: locks Brighter's test broker at RMQ 4.2 indefinitely; users on `SupportDelay` cannot upgrade to 4.3+; plugin is unmaintained, so a future OTP/Erlang/RabbitMQ patch could silently break it; we own a deprecation surface that upstream has already disowned. -- **Migration cost**: nil. -- **Time horizon**: viable until Erlang/OTP or RabbitMQ 4.2.x maintenance ends — likely 1–2 years. +### Tanzu RabbitMQ support -### Option B — Drop the plugin path; rely on `IAmAMessageScheduler` +For users on Tanzu, document — in [`docs/contents/RabbitMQConfiguration.md`](../contents/RabbitMQConfiguration.md) — how to declare `x-queue-type: delayed` queues and rely on Tanzu's `rabbitmq_delayed_queue` plugin. No code change is required: Tanzu accepts the same `x-delay` header Brighter already emits, so Brighter's wire format is forward-compatible. -Treat `IAmAMessageScheduler` as the only supported delay mechanism. Mark `Exchange.SupportDelay` `[Obsolete]`; route both `SendWithDelay` and `RequeueAsync(timeout)` through the scheduler when no plugin is present. Remove the plugin-aware code paths in the next major. +### Responsibilities -- **Pros**: leverages infrastructure Brighter already ships; decouples from broker-version drift; works on RMQ 3.x, 4.x, 5.x, and any future major; same code path as AWS/Azure/Kafka transports that lack native delay; consistent operator story. -- **Cons**: requires consumers to register a scheduler (Hangfire/Quartz/TickerQ/etc.); introduces a second piece of infrastructure; semantics shift slightly — delayed messages no longer sit in a broker queue, they sit in the scheduler's store; visibility moves from RabbitMQ Management UI to whatever the scheduler exposes. -- **Migration cost**: low for users — register an `IAmAMessageScheduler` in DI; ~moderate for Brighter — extend `SendWithDelay` to use scheduler, deprecate `SupportDelay`. -- **Time horizon**: durable across RMQ majors. +Following Responsibility-Driven Design (per [.agent_instructions/design_principles.md](../../.agent_instructions/design_principles.md)): -### Option C — Implement TTL + DLX in the RMQ transport +- **Knowing the delay value** — the `Message` and its routing slip carry the requested delay. Unchanged. +- **Deciding where to enforce delay** — currently split between `Exchange.SupportDelay` (broker enforces) and the absence of that flag (scheduler enforces, in the requeue path only). After Phase 2, this responsibility consolidates into a single *coordinator* role: the producer asks "is a scheduler registered?" and there is exactly one path. +- **Doing the delay** — currently a *service-provider* role split between RabbitMQ-via-plugin (broker side) and `IAmAMessageScheduler` (Brighter side). After Phase 2, only the latter remains. -Make Brighter's RMQ transport synthesise broker-native delay via a queue with `x-message-ttl` (or per-message TTL) plus `x-dead-letter-exchange` pointing at the destination. Two sub-variants: +This consolidation matches the design-principles guidance: "There should be one — and preferably only one — obvious way to do it." -- **C1 — Per-queue TTL with delay buckets.** Declare a fixed set of holding queues with TTLs (e.g. 1s / 10s / 1m / 5m / 1h / 1d), each dead-lettering to the target exchange. Round each `SendWithDelay` request up to the nearest bucket. -- **C2 — Per-message TTL into a single buffer queue.** One buffer queue per destination, dead-lettering on expiry. Each message carries `expiration` (per-message TTL). +## Consequences -- **Pros**: pure RabbitMQ — no plugin, no external infrastructure; works on any RMQ version. -- **Cons (C1)**: precision is quantised to bucket size; many bucket queues to manage; a 7-second delay either rounds up to 10s (correct-ish) or down to 1s (wrong); not a drop-in replacement for the plugin's millisecond-precision per-message delay. -- **Cons (C2)**: **head-of-line blocking** — RabbitMQ only dead-letters from the *head* of a queue. If a 1-hour-TTL message is queued before a 1-second-TTL message, the 1-second message waits an hour. The [official docs](https://www.rabbitmq.com/docs/ttl) call this out explicitly. This makes C2 a poor fit for arbitrary-precision delay. -- **Migration cost**: moderate-high for Brighter — non-trivial transport change, queue topology management, test coverage; nil for users on the API surface. -- **Time horizon**: durable. +### Positive -### Option D — Adopt VMware Tanzu RabbitMQ's `rabbitmq_delayed_queue` +- One delay implementation across all transports without native broker-side delay (RMQ, Redis), reducing surface area. +- Decouples Brighter's RMQ support from broker-version drift; we can move freely to RMQ 4.3, 4.4, 5.0+. +- Operators get a single set of metrics/dashboards (their scheduler) for all delayed messages regardless of transport. +- No new transport code to maintain; we lean on the `IAmAMessageScheduler` role already in use elsewhere in Brighter. +- Removes a known-broken-on-4.3 dependency that we'd otherwise have to keep patching around. +- Aligns Brighter's signal with RabbitMQ upstream's archival signal. -Tanzu RabbitMQ ships a closed-source replacement: a new queue type `x-queue-type: delayed` using Ra (Raft) for replication, scaling to "tens or hundreds of millions of delayed messages", supporting `x-delay`, `x-opt-delivery-delay`, and `x-opt-delivery-time` headers. +### Negative -- **Pros**: official, supported, replicated, scalable; `x-delay` header is preserved, so very small Brighter-side change (declare the queue with the new type instead of declaring an `x-delayed-message` exchange). -- **Cons**: **commercial only** — Tanzu RabbitMQ is a Broadcom product. OSS RabbitMQ users are excluded. Brighter cannot make this its default path without alienating its OSS user base. -- **Migration cost**: small for Tanzu users; impossible for OSS users. -- **Time horizon**: durable for paying users. +- Users currently relying on `SupportDelay = true` must register an `IAmAMessageScheduler` before the next major. This is a real migration, not just a renaming. +- Delayed messages are no longer visible in the RabbitMQ Management UI as queued-but-not-yet-delivered; they sit in the scheduler's store. Operators using the RMQ UI for delay visibility will need to look elsewhere. +- Sub-second delay precision now depends on the scheduler implementation. Hangfire and Quartz have polling intervals (default ~15s for Hangfire); TickerQ is finer-grained. Users with strict sub-second SLAs need to pick the scheduler appropriately. +- Adds a deployment dependency for delay users (a scheduler backend), which the plugin path did not require beyond the broker. -### Option E — Mark `Exchange.SupportDelay` `[Obsolete]` and remove next major +### Risks and Mitigations -A signalling option, not standalone. Layered on top of A, B, or C. +**Risk**: The deprecation window catches users by surprise, breaking production at the next major. +- **Mitigation**: Ship the `[Obsolete]` warning one minor before removal; flag prominently in release notes; leave the plugin path working in the current major so warning-suppression is reversible. -- **Pros**: cheapest possible response; aligns Brighter with upstream's archival signal; gives users a release window to migrate. -- **Cons**: alone, it doesn't pick a replacement — must combine with B and/or C. +**Risk**: A user has built around the broker-side queue visibility for delayed messages. +- **Mitigation**: Document the visibility shift in the migration guide; for users on Tanzu, point them at the documented Tanzu configuration. -## Decision +**Risk**: Scheduler choice paralysis — users don't know which of six to pick. +- **Mitigation**: Add a comparison table to the RMQ docs (in-process vs distributed, polling interval, persistence backend). Recommend TickerQ for in-process, Hangfire or Quartz for distributed deployments. -**Recommendation: B + E combined, with documentation for D.** +**Risk**: The Phase 1 fallback ordering (scheduler-if-registered, else plugin) introduces ambiguity for users who have *both* a scheduler and `SupportDelay = true`. +- **Mitigation**: Document explicitly that registering a scheduler takes precedence; emit an `[Obsolete]` warning that says exactly this; surface it in `[Obsolete]` message text. -1. **In the current major**, keep the plugin path working (RMQ 4.2 + `v4.2.0-rc.1`, as PR #4104 lands). Mark `Exchange.SupportDelay` `[Obsolete("Configure an IAmAMessageScheduler instead — the rabbitmq_delayed_message_exchange plugin is archived upstream and incompatible with RabbitMQ 4.3+. See ADR 0057.")]`. Extend `SendWithDelay` to use `IAmAMessageScheduler` when registered, falling through to the plugin path only when the scheduler is absent and `SupportDelay = true`. -2. **In the next major**, remove the plugin-aware code paths. `SendWithDelay` and requeue-with-delay route exclusively through `IAmAMessageScheduler`. The CI test image stops bundling the plugin and pins to plain `rabbitmq:management`. -3. **For Tanzu users**, add a documentation note in [`docs/contents/RabbitMQConfiguration.md`](../contents/RabbitMQConfiguration.md) describing how to declare `x-queue-type: delayed` queues if they want to keep broker-side delay. No code change needed — Tanzu accepts the same `x-delay` header Brighter already emits, so Brighter's wire format is forward-compatible. +## Alternatives Considered -We **reject Option A** because indefinite RMQ 4.2 lock-in is a slow-motion failure mode. +The five candidate options were examined in full. Summary: -We **reject Option C** because the engineering cost (especially for fair per-queue-bucket implementations with their own ADR-worthy edge cases) is high relative to the value, given Brighter already has six scheduler implementations and an established "register a scheduler when the transport lacks native delay" pattern (used by AWS classic and Azure where applicable). +| | Option | Migration cost | Time horizon | Outcome | +|---|---|---|---|---| +| **A** | Stay on RMQ 4.2 + plugin (status quo) | nil | 1–2 yrs | rejected as default | +| **B** | Drop plugin path; route via `IAmAMessageScheduler` | low (users) / moderate (Brighter) | durable | **chosen** | +| **C** | Implement TTL + DLX in the RMQ transport | moderate-high (Brighter) | durable | rejected | +| **D** | Adopt Tanzu RabbitMQ `rabbitmq_delayed_queue` | small (Tanzu) / impossible (OSS) | durable for paying users | rejected as default; documented as supported config | +| **E** | Mark `[Obsolete]`, remove next major | nil — signalling option | — | **adopted as part of B** | -We **reject Option D as Brighter's default** because it excludes OSS users, but document it as a supported configuration for Tanzu users. +### Alternative A: Stay on RabbitMQ 4.2 + plugin -## Consequences +Pin the test broker and any user opting into `SupportDelay` at RMQ 4.2 forever. Plugin remains at `v4.2.0-rc.1`. -### Positive +**Rejected because**: +- Locks Brighter's test broker (and any user opting into `SupportDelay`) at RMQ 4.2 indefinitely. +- The plugin is unmaintained — a future OTP/Erlang/RabbitMQ patch could silently break it with no upstream fix path. +- Brighter would own a deprecation surface that upstream has already disowned. +- Defers the problem rather than solves it; lifetime ~1–2 years until 4.2.x maintenance ends. -- One delay implementation across all transports without native broker-side delay (RMQ, Redis), reducing surface area. -- Decouples Brighter's RMQ support from broker-version drift; we can move freely to RMQ 4.3, 4.4, 5.0+. -- Operators get a single set of metrics/dashboards (their scheduler) for all delayed messages regardless of transport. -- No new transport code to maintain; we lean on infrastructure already in `Paramore.Brighter.MessageScheduler.*`. -- Removes a known-broken-on-4.3 dependency that we'd otherwise have to keep patching around. +### Alternative C: Implement TTL + DLX inside the RMQ transport -### Negative +Synthesise broker-native delay using `x-message-ttl` plus `x-dead-letter-exchange`. Two sub-variants: (C1) per-queue TTL with a fixed set of bucket queues (1s/10s/1m/5m/1h/1d) routing on expiry to the destination; (C2) per-message TTL into a single buffer queue. -- Users currently relying on `SupportDelay = true` must register an `IAmAMessageScheduler` before the next major. This is a real migration, not just a renaming. -- Delayed messages are no longer visible in RabbitMQ Management UI as queued-but-not-yet-delivered; they sit in the scheduler's store. Operators using the RMQ UI for visibility will need to look elsewhere. -- Sub-second delay precision now depends on the scheduler implementation. Hangfire and Quartz have polling intervals (default ~15s for Hangfire); TickerQ is finer-grained. Users with strict sub-second SLAs need to pick the scheduler appropriately. -- Adds a deployment dependency for delay users (a scheduler backend), which the plugin path did not require beyond the broker. +**Rejected because**: +- C1 quantises delay precision to bucket size — a 7-second delay rounds up to 10s or down to 1s; not a drop-in replacement for the plugin's millisecond-precision `x-delay`. +- C2 suffers [head-of-line blocking](https://www.rabbitmq.com/docs/ttl): RabbitMQ only dead-letters from the *head* of a queue, so a 1-hour-TTL message queued before a 1-second-TTL message blocks the latter for an hour. +- Higher engineering cost than B (queue topology management, test coverage across two semantics) for inferior semantics. +- Brighter already ships and tests `IAmAMessageScheduler`; reimplementing scheduling inside the RMQ transport duplicates knowledge. -### Risks and Mitigations - -- **Risk**: The deprecation window catches users by surprise, breaking production at the next major. **Mitigation**: ship the `[Obsolete]` warning one minor before removal; flag prominently in release notes; leave the plugin path working in the current major so warning-suppression is reversible. -- **Risk**: Some user has built around the broker-side queue visibility for delayed messages. **Mitigation**: document the visibility shift in the migration guide; for users on Tanzu, point them at Option D. -- **Risk**: Scheduler choice paralysis — users don't know which of six to pick. **Mitigation**: add a table to the RMQ docs comparing the six scheduler backends (in-process vs distributed, polling interval, persistence). Recommend TickerQ for in-process, Hangfire or Quartz for distributed deployments. +### Alternative D as default: VMware Tanzu RabbitMQ delayed queues -## Alternatives Considered +Tanzu RabbitMQ ships a closed-source replacement: a queue type `x-queue-type: delayed` using Ra (Raft) replication, scaling to "tens or hundreds of millions of delayed messages", supporting `x-delay`, `x-opt-delivery-delay`, and `x-opt-delivery-time` headers. -The five options above are the alternatives. A summary of why each non-recommended option was rejected: +**Rejected as default because**: +- Tanzu RabbitMQ is a commercial Broadcom product. OSS RabbitMQ users would be excluded. +- Brighter cannot make this its default path without alienating its OSS user base. -- **A** (status quo): defers the problem rather than solves it; locks the broker version. -- **C** (TTL+DLX): higher engineering cost than B for inferior semantics (precision quantised or head-of-line blocked). -- **D** (Tanzu plugin): not viable as a default for an OSS framework; documented as a supported configuration. +**Documented as a supported configuration** — Brighter's wire format is already compatible (it emits `x-delay`), so Tanzu users only need to declare delayed queues at their topology layer. See §Decision/Tanzu RabbitMQ support. ## References @@ -143,3 +152,4 @@ The five options above are the alternatives. A summary of why each non-recommend - Tanzu delayed queues: - Brighter scheduler interface: [`src/Paramore.Brighter/IAmAMessageScheduler.cs`](../../src/Paramore.Brighter/IAmAMessageScheduler.cs) - Existing fallback site: [`src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageConsumer.cs`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageConsumer.cs) (the `else` branch in `RequeueAsync`) +- Design principles applied: [.agent_instructions/design_principles.md](../../.agent_instructions/design_principles.md) (Responsibility-Driven Design — knowing/doing/deciding) From 31d699aa92109980cda0f945a41c3f2e47f9d8ef Mon Sep 17 00:00:00 2001 From: Toby Henderson Date: Tue, 28 Apr 2026 09:34:32 +0100 Subject: [PATCH 3/8] =?UTF-8?q?docs(adr):=20correct=20ADR=200057=20?= =?UTF-8?q?=E2=80=94=20fallback=20exists=20on=20every=20RMQ=20delay=20surf?= =?UTF-8?q?ace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on PR #4106: - Constraints: corrected the claim that SendWithDelay had no scheduler fallback. Both Sync and Async producers (RmqMessageProducer) and the Async consumer (RmqMessageConsumer.RequeueAsync) already route through IAmAMessageScheduler when one is registered. Replaced the consumer snippet with the producer pattern (the more common surface) and cross-referenced all three call sites. - Decision: simplified Phase 1 — runtime work is already done; what remains is [Obsolete], precedence-rule documentation, and a migration guide. Added "(target version: TBD on acceptance)" to both phases. - Decision: replaced the broken docs/contents/RabbitMQConfiguration.md reference with a generic phrasing and a note that the location is to be confirmed during implementation (Brighter's RMQ user docs may live outside this repo). - Responsibilities: dropped the .agent_instructions internal link (harness-only directory, not public-facing). - Risks: reworded the precedence-ambiguity risk to point at the now-explicit rule in Decision/Phase 1 step 2 instead of duplicating the mitigation. - References: expanded the fallback-sites bullet to list all three call sites with line-number anchors; removed the .agent_instructions link. Co-Authored-By: Claude Opus 4.7 --- ...057-rabbitmq-delayed-messaging-strategy.md | 58 +++++++++++-------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md b/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md index dd5a8a6d8f..12f680ede2 100644 --- a/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md +++ b/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md @@ -24,15 +24,25 @@ The terminal working combination — RMQ 4.2 + plugin v4.2.0-rc.1 — is being a ### Constraints -- **Existing role available.** `IAmAMessageScheduler` is already a role in Brighter, with six production implementations: `Paramore.Brighter.MessageScheduler.{Aws, AWS.V4, Azure, Hangfire, Quartz, TickerQ}`, plus an `InMemoryScheduler`. Other transports without native broker-side delay (notably AWS classic where applicable) already use this role. -- **Partial fallback already wired.** [`RmqMessageConsumer.RequeueAsync`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageConsumer.cs#L419) routes through the scheduler when `DelaySupported = false`: +- **Existing role available.** `IAmAMessageScheduler` is already a role in Brighter, with six production implementations: `Paramore.Brighter.MessageScheduler.{Aws, AWS.V4, Azure, Hangfire, Quartz, TickerQ}`, plus an `InMemoryScheduler`. Other transports without native broker-side delay already use this role. +- **Fallback already wired in both transports and on both surfaces.** Whenever a scheduler is registered and `DelaySupported = false`, both producers and the requeue path route through `IAmAMessageScheduler` today. Identical branching exists in [`RmqMessageProducer.SendWithDelayAsync`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageProducer.cs#L168) (Async), [`RmqMessageProducer.SendWithDelay`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Sync/RmqMessageProducer.cs#L155) (Sync), and [`RmqMessageConsumer.RequeueAsync`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageConsumer.cs#L419): ```csharp - if (DelaySupported || timeout <= TimeSpan.Zero) - await rmqMessagePublisher.RequeueMessageAsync(message, _queueName, timeout.Value, cancellationToken); + if (delay == TimeSpan.Zero || DelaySupported || Scheduler == null) + { + // plugin path: publish to the x-delayed-message exchange + } + else if (useSchedulerAsync) + { + var schedulerAsync = (IAmAMessageSchedulerAsync)Scheduler!; + await schedulerAsync.ScheduleAsync(message, delay.Value, cancellationToken); + } else - await _requeueProducer!.SendWithDelayAsync(message, timeout, cancellationToken); + { + var schedulerSync = (IAmAMessageSchedulerSync)Scheduler!; + schedulerSync.Schedule(message, delay.Value); + } ``` - The `SendWithDelay` producer surface, used directly by callers (not just by requeue), does **not** yet have an equivalent fallback. + Runtime work for Option B is therefore essentially complete; what remains is signalling (deprecation), documentation, and eventual removal. - **OSS users must keep working.** Brighter cannot mandate Tanzu RabbitMQ — its OSS user base is significant and would be excluded by a commercial-only path. - **Migration window required.** Existing users with `Exchange.SupportDelay = true` should not break at the next minor release. A deprecation warning before removal is required. - **Wire format compatibility.** RabbitMQ's various delay implementations (community plugin, Tanzu plugin) all accept the `x-delay` header Brighter already emits. Decisions about *where* the delay is enforced are independent of *how* the message is shaped on the wire. @@ -41,32 +51,32 @@ The terminal working combination — RMQ 4.2 + plugin v4.2.0-rc.1 — is being a Combine **Option B** (route delay through `IAmAMessageScheduler`) with **Option E** (deprecate `Exchange.SupportDelay` and remove next major), with **Option D** (Tanzu) documented as a supported configuration for paying users. -### Phase 1 — Current major +### Phase 1 — Current major (target version: TBD on acceptance) -1. Mark `Exchange.SupportDelay` `[Obsolete("Configure an IAmAMessageScheduler instead — the rabbitmq_delayed_message_exchange plugin is archived upstream and incompatible with RabbitMQ 4.3+. See ADR 0057.")]`. -2. Extend the producer's `SendWithDelay` path to use `IAmAMessageScheduler` when one is registered, falling through to the plugin path only when no scheduler is registered and `SupportDelay = true` (preserves current behaviour for users who haven't migrated). -3. Keep the plugin path operational against RMQ 4.2 + plugin v4.2.0-rc.1 (PR #4104 already adopts this pinning). -4. Add a migration guide section to [`docs/contents/RabbitMQConfiguration.md`](../contents/RabbitMQConfiguration.md) showing how to register a scheduler and remove `SupportDelay`. +1. **Mark `Exchange.SupportDelay` `[Obsolete]`** with text along the lines of: `"Configure an IAmAMessageScheduler instead — the rabbitmq_delayed_message_exchange plugin is archived upstream and incompatible with RabbitMQ 4.3+. See ADR 0057."` +2. **Document the runtime precedence rule** explicitly. When both `Scheduler` is registered *and* `Exchange.SupportDelay = true`, the **scheduler wins** — this is how the existing producer code already behaves (see code in §Constraints), but the rule has not been documented externally and must be made explicit so users migrating in either direction know what to expect. Surface the rule in the `[Obsolete]` message text and in the migration guide. +3. **Keep the plugin path operational** against RMQ 4.2 + plugin v4.2.0-rc.1 for users who haven't yet migrated. PR #4104 already adopts this pinning. +4. **Write a migration guide** in the user-facing RabbitMQ configuration documentation (location to be confirmed during implementation — `docs/transports/` in this repo, or the public docs site, depending on where Brighter's RMQ guide currently lives). Include: how to register a scheduler, the precedence rule, and a comparison table of scheduler backends to help users pick (in-process vs distributed; polling interval; persistence). -### Phase 2 — Next major +### Phase 2 — Next major (target version: TBD on acceptance) -1. Remove the plugin-aware code paths in `ExchangeConfigurationHelper`, the `x-delay` exchange declaration, and the `DelaySupported` branch in `RmqMessageConsumer.RequeueAsync`. -2. Make `IAmAMessageScheduler` registration mandatory for any caller of `SendWithDelay` or `RequeueAsync(timeout: nonzero)` on RMQ. Throw a `ConfigurationException` at startup if delay is used without a registered scheduler. -3. Stop bundling the delay plugin in the CI test image; pin to plain `rabbitmq:management`. +1. **Remove the plugin-aware code paths** in `ExchangeConfigurationHelper`, the `x-delay` exchange declaration, and the `DelaySupported` branch in both producers and `RmqMessageConsumer.RequeueAsync`. +2. **Make `IAmAMessageScheduler` registration mandatory** for any caller of `SendWithDelay` or `RequeueAsync(timeout: nonzero)` on RMQ. Throw a `ConfigurationException` at startup if delay is used without a registered scheduler. +3. **Stop bundling the delay plugin** in the CI test image; pin to plain `rabbitmq:management`. ### Tanzu RabbitMQ support -For users on Tanzu, document — in [`docs/contents/RabbitMQConfiguration.md`](../contents/RabbitMQConfiguration.md) — how to declare `x-queue-type: delayed` queues and rely on Tanzu's `rabbitmq_delayed_queue` plugin. No code change is required: Tanzu accepts the same `x-delay` header Brighter already emits, so Brighter's wire format is forward-compatible. +For users on Tanzu, document how to declare `x-queue-type: delayed` queues and rely on Tanzu's `rabbitmq_delayed_queue` plugin. No code change is required: Tanzu accepts the same `x-delay` header Brighter already emits, so Brighter's wire format is forward-compatible. Documentation will live alongside the migration guide added in Phase 1, step 4. ### Responsibilities -Following Responsibility-Driven Design (per [.agent_instructions/design_principles.md](../../.agent_instructions/design_principles.md)): +Following Responsibility-Driven Design: - **Knowing the delay value** — the `Message` and its routing slip carry the requested delay. Unchanged. -- **Deciding where to enforce delay** — currently split between `Exchange.SupportDelay` (broker enforces) and the absence of that flag (scheduler enforces, in the requeue path only). After Phase 2, this responsibility consolidates into a single *coordinator* role: the producer asks "is a scheduler registered?" and there is exactly one path. +- **Deciding where to enforce delay** — currently split between `Exchange.SupportDelay` (broker enforces) and the absence of that flag (scheduler enforces). After Phase 2, this responsibility consolidates into a single *coordinator* role: the producer asks "is a scheduler registered?" and there is exactly one path. - **Doing the delay** — currently a *service-provider* role split between RabbitMQ-via-plugin (broker side) and `IAmAMessageScheduler` (Brighter side). After Phase 2, only the latter remains. -This consolidation matches the design-principles guidance: "There should be one — and preferably only one — obvious way to do it." +This consolidation matches the long-standing principle in Brighter's design guidelines: "There should be one — and preferably only one — obvious way to do it." ## Consequences @@ -97,8 +107,8 @@ This consolidation matches the design-principles guidance: "There should be one **Risk**: Scheduler choice paralysis — users don't know which of six to pick. - **Mitigation**: Add a comparison table to the RMQ docs (in-process vs distributed, polling interval, persistence backend). Recommend TickerQ for in-process, Hangfire or Quartz for distributed deployments. -**Risk**: The Phase 1 fallback ordering (scheduler-if-registered, else plugin) introduces ambiguity for users who have *both* a scheduler and `SupportDelay = true`. -- **Mitigation**: Document explicitly that registering a scheduler takes precedence; emit an `[Obsolete]` warning that says exactly this; surface it in `[Obsolete]` message text. +**Risk**: The Phase 1 fallback ordering — scheduler wins when both a scheduler and `SupportDelay = true` are configured — is current runtime behaviour but has never been documented. Users who configured both could be surprised when their scheduler unexpectedly runs. +- **Mitigation**: Phase 1 step 2 makes this rule explicit in the migration guide and `[Obsolete]` message. The behaviour itself does not change — only the documentation. ## Alternatives Considered @@ -151,5 +161,7 @@ Tanzu RabbitMQ ships a closed-source replacement: a queue type `x-queue-type: de - TTL + DLX official docs: , - Tanzu delayed queues: - Brighter scheduler interface: [`src/Paramore.Brighter/IAmAMessageScheduler.cs`](../../src/Paramore.Brighter/IAmAMessageScheduler.cs) -- Existing fallback site: [`src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageConsumer.cs`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageConsumer.cs) (the `else` branch in `RequeueAsync`) -- Design principles applied: [.agent_instructions/design_principles.md](../../.agent_instructions/design_principles.md) (Responsibility-Driven Design — knowing/doing/deciding) +- Existing fallback sites: + - Async producer: [`src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageProducer.cs`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageProducer.cs#L168) + - Sync producer: [`src/Paramore.Brighter.MessagingGateway.RMQ.Sync/RmqMessageProducer.cs`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Sync/RmqMessageProducer.cs#L155) + - Async consumer requeue: [`src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageConsumer.cs`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageConsumer.cs#L419) From 5a0baf32b215d8295ce8d3ee979b0b7b18fe8efb Mon Sep 17 00:00:00 2001 From: Toby Henderson Date: Tue, 28 Apr 2026 09:58:51 +0100 Subject: [PATCH 4/8] docs(adr): correct precedence rule, add deprecation scope and InMemoryScheduler caveat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses second review pass on PR #4106: - Phase 1 step 2: corrected the precedence claim. The current code in RmqMessageProducer takes the *plugin* path when SupportDelay=true, regardless of whether a scheduler is registered (the scheduler path activates only when SupportDelay=false AND Scheduler!=null AND delay!=Zero). Earlier wording reversed this. Phase 1 now poses the explicit choice: (a) keep current behaviour and rely on [Obsolete] to nudge users, or (b) flip to scheduler-wins. Recommends (a) — silent behaviour changes inside a major are exactly what [Obsolete] is meant to avoid. - Phase 1 step 1: clarified [Obsolete] scope — applies to the property and to constructor overloads/parameters that set it, to avoid partial deprecation that confuses IDEs. - Phase 1 step 4: explicit caveat that InMemoryScheduler is non-durable and not appropriate for production delayed messaging. - Risks: reworded the precedence-ambiguity risk to point at the corrected Phase 1 step 2 and remove the wrong "scheduler wins" claim. Confirmed during review: ConfigurationException already exists at src/Paramore.Brighter/ConfigurationException.cs, so the Phase 2 use of that type is not a TBD API decision. Co-Authored-By: Claude Opus 4.7 --- ...057-rabbitmq-delayed-messaging-strategy.md | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md b/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md index 12f680ede2..b8ff60e71e 100644 --- a/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md +++ b/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md @@ -53,10 +53,19 @@ Combine **Option B** (route delay through `IAmAMessageScheduler`) with **Option ### Phase 1 — Current major (target version: TBD on acceptance) -1. **Mark `Exchange.SupportDelay` `[Obsolete]`** with text along the lines of: `"Configure an IAmAMessageScheduler instead — the rabbitmq_delayed_message_exchange plugin is archived upstream and incompatible with RabbitMQ 4.3+. See ADR 0057."` -2. **Document the runtime precedence rule** explicitly. When both `Scheduler` is registered *and* `Exchange.SupportDelay = true`, the **scheduler wins** — this is how the existing producer code already behaves (see code in §Constraints), but the rule has not been documented externally and must be made explicit so users migrating in either direction know what to expect. Surface the rule in the `[Obsolete]` message text and in the migration guide. +1. **Mark `Exchange.SupportDelay` `[Obsolete]`** on every public surface that exposes it: the `RmqMessagingGatewayConnection.Exchange` property, the `Exchange` constructor parameter, and the `Exchange(name, type, ..., supportDelay: true)` constructor overload. `[Obsolete]` text along the lines of: `"Configure an IAmAMessageScheduler instead — the rabbitmq_delayed_message_exchange plugin is archived upstream and incompatible with RabbitMQ 4.3+. See ADR 0057."` +2. **Pick and document the runtime precedence rule.** The current code in `RmqMessageProducer` reads: + ```csharp + if (delay == TimeSpan.Zero || DelaySupported || Scheduler == null) { /* plugin path */ } + else if (...) { /* scheduler path */ } + ``` + So today, **the plugin wins whenever `SupportDelay = true`**, regardless of whether a scheduler is also registered. The scheduler path activates only when `SupportDelay = false` AND a scheduler is registered AND `delay != TimeSpan.Zero`. Phase 1 must make a decision and document it: + - **(a) Keep current behaviour** — plugin wins when `SupportDelay = true`. `[Obsolete]` is a signal only; users must remove `SupportDelay = true` to activate the scheduler. Phase 1 is documentation-only. + - **(b) Flip the precedence** — when `Scheduler` is registered, scheduler wins even if `SupportDelay = true`. This is a silent behaviour change for users who set both, but smoothes the migration. Phase 1 includes a one-line condition change. + + **Recommended: (a).** Silent behaviour changes inside a major are exactly what `[Obsolete]` is meant to avoid; the warning gives users an explicit prompt to remove the flag, after which they get the new path on their schedule. 3. **Keep the plugin path operational** against RMQ 4.2 + plugin v4.2.0-rc.1 for users who haven't yet migrated. PR #4104 already adopts this pinning. -4. **Write a migration guide** in the user-facing RabbitMQ configuration documentation (location to be confirmed during implementation — `docs/transports/` in this repo, or the public docs site, depending on where Brighter's RMQ guide currently lives). Include: how to register a scheduler, the precedence rule, and a comparison table of scheduler backends to help users pick (in-process vs distributed; polling interval; persistence). +4. **Write a migration guide** in the user-facing RabbitMQ configuration documentation (location to be confirmed during implementation — Brighter's RMQ guide may live in a sibling docs repository rather than this one; check ADR 0043 / 0054 cross-link patterns for precedent). Include: how to register a scheduler; the precedence rule from step 2; a note that `InMemoryScheduler` is **not durable and not safe for production delayed messaging** (use TickerQ for in-process production, Hangfire/Quartz for distributed); a comparison table of scheduler backends (polling interval, persistence, distributed vs in-process). ### Phase 2 — Next major (target version: TBD on acceptance) @@ -104,11 +113,11 @@ This consolidation matches the long-standing principle in Brighter's design guid **Risk**: A user has built around the broker-side queue visibility for delayed messages. - **Mitigation**: Document the visibility shift in the migration guide; for users on Tanzu, point them at the documented Tanzu configuration. -**Risk**: Scheduler choice paralysis — users don't know which of six to pick. -- **Mitigation**: Add a comparison table to the RMQ docs (in-process vs distributed, polling interval, persistence backend). Recommend TickerQ for in-process, Hangfire or Quartz for distributed deployments. +**Risk**: Scheduler choice paralysis — users don't know which of six to pick, and `InMemoryScheduler` looks attractive because it needs no extra infrastructure. +- **Mitigation**: Add a comparison table to the RMQ docs (in-process vs distributed, polling interval, persistence backend). Call out explicitly that `InMemoryScheduler` is non-durable and not appropriate for production delayed messaging — its delays are lost on process restart. Recommend TickerQ for in-process production, Hangfire or Quartz for distributed deployments. -**Risk**: The Phase 1 fallback ordering — scheduler wins when both a scheduler and `SupportDelay = true` are configured — is current runtime behaviour but has never been documented. Users who configured both could be surprised when their scheduler unexpectedly runs. -- **Mitigation**: Phase 1 step 2 makes this rule explicit in the migration guide and `[Obsolete]` message. The behaviour itself does not change — only the documentation. +**Risk**: Users who configured both `SupportDelay = true` and a scheduler may misunderstand which path runs today. Current code takes the **plugin path** in that case (see §Decision/Phase 1 step 2); the scheduler activates only after the user removes `SupportDelay = true`. +- **Mitigation**: Under recommended sub-option (a) the runtime behaviour is unchanged — only the documentation is new. The `[Obsolete]` warning surfaces in the IDE the moment users open the file, prompting them to remove the flag at their pace. ## Alternatives Considered From f77cf7a2c5b5f059b7d953a4c4d117dc4cebef80 Mon Sep 17 00:00:00 2001 From: Toby Henderson Date: Tue, 28 Apr 2026 10:03:23 +0100 Subject: [PATCH 5/8] docs(adr): replace TBD doc location with concrete BrighterCommand/Docs links Brighter's user-facing documentation lives in the sibling repository BrighterCommand/Docs under contents/, not in this repo. Replaces the "location TBD" placeholders in Phase 1 step 4 and the Tanzu support sub-section with concrete links to: - contents/RabbitMQConfiguration.md (primary target for the migration guide and Tanzu configuration notes) - contents/BrighterSchedulerSupport.md (scheduler overview to cross-link) - The seven per-scheduler pages already published in that repo (TickerQ, Hangfire, Quartz, InMemory, Aws, Azure, Custom). Notes that the docs PR will land separately against BrighterCommand/Docs. Co-Authored-By: Claude Opus 4.7 --- docs/adr/0057-rabbitmq-delayed-messaging-strategy.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md b/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md index b8ff60e71e..b9c56999da 100644 --- a/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md +++ b/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md @@ -65,7 +65,7 @@ Combine **Option B** (route delay through `IAmAMessageScheduler`) with **Option **Recommended: (a).** Silent behaviour changes inside a major are exactly what `[Obsolete]` is meant to avoid; the warning gives users an explicit prompt to remove the flag, after which they get the new path on their schedule. 3. **Keep the plugin path operational** against RMQ 4.2 + plugin v4.2.0-rc.1 for users who haven't yet migrated. PR #4104 already adopts this pinning. -4. **Write a migration guide** in the user-facing RabbitMQ configuration documentation (location to be confirmed during implementation — Brighter's RMQ guide may live in a sibling docs repository rather than this one; check ADR 0043 / 0054 cross-link patterns for precedent). Include: how to register a scheduler; the precedence rule from step 2; a note that `InMemoryScheduler` is **not durable and not safe for production delayed messaging** (use TickerQ for in-process production, Hangfire/Quartz for distributed); a comparison table of scheduler backends (polling interval, persistence, distributed vs in-process). +4. **Update the user-facing RabbitMQ documentation** in the [`BrighterCommand/Docs`](https://github.com/BrighterCommand/Docs) sibling repository — primarily [`contents/RabbitMQConfiguration.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/RabbitMQConfiguration.md), with cross-links to [`contents/BrighterSchedulerSupport.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/BrighterSchedulerSupport.md) and the per-scheduler pages already there ([`TickerQScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/TickerQScheduler.md), [`HangfireScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/HangfireScheduler.md), [`QuartzScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/QuartzScheduler.md), [`InMemoryScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/InMemoryScheduler.md), [`AwsScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/AwsScheduler.md), [`AzureScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/AzureScheduler.md), [`CustomScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/CustomScheduler.md)). The migration section should add: how to register a scheduler for RMQ delay; the precedence rule from step 2; an explicit caveat that `InMemoryScheduler` is **not durable and not safe for production delayed messaging** (use TickerQ for in-process production, Hangfire/Quartz for distributed); a comparison table of scheduler backends (polling interval, persistence, distributed vs in-process). Lands as a separate PR against `BrighterCommand/Docs`. ### Phase 2 — Next major (target version: TBD on acceptance) @@ -75,7 +75,7 @@ Combine **Option B** (route delay through `IAmAMessageScheduler`) with **Option ### Tanzu RabbitMQ support -For users on Tanzu, document how to declare `x-queue-type: delayed` queues and rely on Tanzu's `rabbitmq_delayed_queue` plugin. No code change is required: Tanzu accepts the same `x-delay` header Brighter already emits, so Brighter's wire format is forward-compatible. Documentation will live alongside the migration guide added in Phase 1, step 4. +For users on Tanzu, document how to declare `x-queue-type: delayed` queues and rely on Tanzu's `rabbitmq_delayed_queue` plugin. No code change is required: Tanzu accepts the same `x-delay` header Brighter already emits, so Brighter's wire format is forward-compatible. Documentation lands in [`BrighterCommand/Docs/contents/RabbitMQConfiguration.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/RabbitMQConfiguration.md) alongside the migration guide added in Phase 1, step 4. ### Responsibilities @@ -174,3 +174,6 @@ Tanzu RabbitMQ ships a closed-source replacement: a queue type `x-queue-type: de - Async producer: [`src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageProducer.cs`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageProducer.cs#L168) - Sync producer: [`src/Paramore.Brighter.MessagingGateway.RMQ.Sync/RmqMessageProducer.cs`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Sync/RmqMessageProducer.cs#L155) - Async consumer requeue: [`src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageConsumer.cs`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageConsumer.cs#L419) +- User-facing documentation (sibling repo [`BrighterCommand/Docs`](https://github.com/BrighterCommand/Docs)): + - [`contents/RabbitMQConfiguration.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/RabbitMQConfiguration.md) — primary target for the migration guide + - [`contents/BrighterSchedulerSupport.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/BrighterSchedulerSupport.md) — scheduler overview to cross-link from the migration guide From 408509c97fd311cb1294f358d7e455834e0468a9 Mon Sep 17 00:00:00 2001 From: Toby Henderson Date: Tue, 28 Apr 2026 10:20:15 +0100 Subject: [PATCH 6/8] =?UTF-8?q?docs(adr):=20comprehensive=20review=20?= =?UTF-8?q?=E2=80=94=20symmetry,=20call-time=20check,=20orphan=20iface,=20?= =?UTF-8?q?DI=20auto-wire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses both bot reviews on PR #4106 plus a Serena-assisted code review: Factual corrections (from bot reviews): - Constraints: replaced the "Identical branching" claim. The consumer paths (RequeueAsync / Requeue) have a SIMPLER condition than the producer — no Scheduler == null guard. They delegate to a _requeueProducer constructed with Scheduler = _scheduler in EnsureProducer, inheriting the fallback by composition rather than duplicating the guard. Asymmetric but correct. - Phase 2 step 2: changed "throw ConfigurationException at startup" to "at call-time inside SendWithDelay/RequeueAsync when delay > Zero && Scheduler == null". Delay is a per-call decision; startup can't know. - Phase 1 step 1: added RmqMessageGateway.DelaySupported (the public read-only property) to the [Obsolete] target list, since it's derived public surface that mirrors SupportDelay. New findings via Serena LSP review (uncovered by symbol search, not in either bot review): - Constraints: documented that DI auto-wires Scheduler onto producers. ServiceCollectionExtensions.BuildCommandProcessor does producerRegistry.Producers.Each(x => x.Scheduler ??= factory.Create(...)), so users only need to register an IAmAMessageSchedulerFactory — no per-producer wiring. This significantly de-risks the migration story. - Phase 2 step 1: added Paramore.Brighter.IAmAMessageGatewaySupportingDelay to the removal list. This is an orphan public interface — defined in core but with zero implementations, zero callers, and zero tests (verified via Serena find_referencing_symbols + grep across src, tests, samples). Safe to delete alongside the rest. Other refinements: - Tanzu support: added a citation that the Tanzu delayed-queues docs list x-delay among the recognised delay headers, supporting the "no code change required" claim. - Negative consequences: documented the pre-existing silent no-delay failure mode (delay > 0, SupportDelay = false, Scheduler == null → publishes with x-delay header to a normal exchange, broker ignores it, message delivered immediately with no error). Phase 2's call-time check closes this permanently. - InMemoryScheduler caveat in Phase 1 step 4 sharpened: pending delays are held in process memory only and silently disappear on restart with no error raised. - References section: replaced fragile #L168/#L155/#L419 line anchors with method-name references; expanded fallback-sites bullet to enumerate all four call sites; added orphan-interface and DI-wiring references. Co-Authored-By: Claude Opus 4.7 --- ...057-rabbitmq-delayed-messaging-strategy.md | 47 +++++++++++++++---- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md b/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md index b9c56999da..ea2b92e016 100644 --- a/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md +++ b/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md @@ -25,7 +25,7 @@ The terminal working combination — RMQ 4.2 + plugin v4.2.0-rc.1 — is being a ### Constraints - **Existing role available.** `IAmAMessageScheduler` is already a role in Brighter, with six production implementations: `Paramore.Brighter.MessageScheduler.{Aws, AWS.V4, Azure, Hangfire, Quartz, TickerQ}`, plus an `InMemoryScheduler`. Other transports without native broker-side delay already use this role. -- **Fallback already wired in both transports and on both surfaces.** Whenever a scheduler is registered and `DelaySupported = false`, both producers and the requeue path route through `IAmAMessageScheduler` today. Identical branching exists in [`RmqMessageProducer.SendWithDelayAsync`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageProducer.cs#L168) (Async), [`RmqMessageProducer.SendWithDelay`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Sync/RmqMessageProducer.cs#L155) (Sync), and [`RmqMessageConsumer.RequeueAsync`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageConsumer.cs#L419): +- **Fallback already wired across both transports and both surfaces.** When a scheduler is registered and `DelaySupported = false`, the producers route through `IAmAMessageScheduler` directly. The producer guard is in `RmqMessageProducer.SendWithDelayAsync` (Async transport) and `RmqMessageProducer.SendWithDelay` (Sync transport): ```csharp if (delay == TimeSpan.Zero || DelaySupported || Scheduler == null) { @@ -42,7 +42,15 @@ The terminal working combination — RMQ 4.2 + plugin v4.2.0-rc.1 — is being a schedulerSync.Schedule(message, delay.Value); } ``` + The consumer requeue path — `RmqMessageConsumer.RequeueAsync` (Async) and `RmqMessageConsumer.Requeue` (Sync) — has a *simpler* condition (`if (DelaySupported || timeout <= TimeSpan.Zero) plugin else producer.SendWithDelay(Async)`); it has no `Scheduler == null` guard of its own. The consumer **delegates** to a `_requeueProducer` constructed in `EnsureProducer` with `Scheduler = _scheduler` set, inheriting the scheduler-fallback by composition rather than duplicating the guard. This is correct but asymmetric, and matters in §Phase 1: any change to the producer condition automatically flows through to requeue. + Runtime work for Option B is therefore essentially complete; what remains is signalling (deprecation), documentation, and eventual removal. +- **DI auto-wires `Scheduler` onto producers.** `Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionExtensions.BuildCommandProcessor` does: + ```csharp + producerRegistry?.Producers + .Each(x => x.Scheduler ??= messageSchedulerFactory.Create(command)); + ``` + Users who register an `IAmAMessageSchedulerFactory` (via the standard `AddBrighter(...).UseScheduler(...)` pattern) get the scheduler propagated to every producer in the registry automatically — no per-producer wiring required. Migration is therefore: remove `SupportDelay = true`, register a scheduler factory, done. - **OSS users must keep working.** Brighter cannot mandate Tanzu RabbitMQ — its OSS user base is significant and would be excluded by a commercial-only path. - **Migration window required.** Existing users with `Exchange.SupportDelay = true` should not break at the next minor release. A deprecation warning before removal is required. - **Wire format compatibility.** RabbitMQ's various delay implementations (community plugin, Tanzu plugin) all accept the `x-delay` header Brighter already emits. Decisions about *where* the delay is enforced are independent of *how* the message is shaped on the wire. @@ -53,7 +61,11 @@ Combine **Option B** (route delay through `IAmAMessageScheduler`) with **Option ### Phase 1 — Current major (target version: TBD on acceptance) -1. **Mark `Exchange.SupportDelay` `[Obsolete]`** on every public surface that exposes it: the `RmqMessagingGatewayConnection.Exchange` property, the `Exchange` constructor parameter, and the `Exchange(name, type, ..., supportDelay: true)` constructor overload. `[Obsolete]` text along the lines of: `"Configure an IAmAMessageScheduler instead — the rabbitmq_delayed_message_exchange plugin is archived upstream and incompatible with RabbitMQ 4.3+. See ADR 0057."` +1. **Mark every public surface that exposes the plugin opt-in `[Obsolete]`.** Specifically: + - `Exchange.SupportDelay` (the property setter and the `supportDelay` constructor parameter on the `Exchange` type) + - `RmqMessageGateway.DelaySupported` (the public read-only property derived from `SupportDelay`, in both the Sync and Async transports) + + `[Obsolete]` text along the lines of: `"Configure an IAmAMessageScheduler instead — the rabbitmq_delayed_message_exchange plugin is archived upstream and incompatible with RabbitMQ 4.3+. See ADR 0057."` Implementation should choose the most-narrow target (the `supportDelay` parameter via documentation rather than the whole `Exchange` constructor, if other parameters remain valid) — this is a PR-level decision, not an ADR one. 2. **Pick and document the runtime precedence rule.** The current code in `RmqMessageProducer` reads: ```csharp if (delay == TimeSpan.Zero || DelaySupported || Scheduler == null) { /* plugin path */ } @@ -65,17 +77,22 @@ Combine **Option B** (route delay through `IAmAMessageScheduler`) with **Option **Recommended: (a).** Silent behaviour changes inside a major are exactly what `[Obsolete]` is meant to avoid; the warning gives users an explicit prompt to remove the flag, after which they get the new path on their schedule. 3. **Keep the plugin path operational** against RMQ 4.2 + plugin v4.2.0-rc.1 for users who haven't yet migrated. PR #4104 already adopts this pinning. -4. **Update the user-facing RabbitMQ documentation** in the [`BrighterCommand/Docs`](https://github.com/BrighterCommand/Docs) sibling repository — primarily [`contents/RabbitMQConfiguration.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/RabbitMQConfiguration.md), with cross-links to [`contents/BrighterSchedulerSupport.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/BrighterSchedulerSupport.md) and the per-scheduler pages already there ([`TickerQScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/TickerQScheduler.md), [`HangfireScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/HangfireScheduler.md), [`QuartzScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/QuartzScheduler.md), [`InMemoryScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/InMemoryScheduler.md), [`AwsScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/AwsScheduler.md), [`AzureScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/AzureScheduler.md), [`CustomScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/CustomScheduler.md)). The migration section should add: how to register a scheduler for RMQ delay; the precedence rule from step 2; an explicit caveat that `InMemoryScheduler` is **not durable and not safe for production delayed messaging** (use TickerQ for in-process production, Hangfire/Quartz for distributed); a comparison table of scheduler backends (polling interval, persistence, distributed vs in-process). Lands as a separate PR against `BrighterCommand/Docs`. +4. **Update the user-facing RabbitMQ documentation** in the [`BrighterCommand/Docs`](https://github.com/BrighterCommand/Docs) sibling repository — primarily [`contents/RabbitMQConfiguration.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/RabbitMQConfiguration.md), with cross-links to [`contents/BrighterSchedulerSupport.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/BrighterSchedulerSupport.md) and the per-scheduler pages already there ([`TickerQScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/TickerQScheduler.md), [`HangfireScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/HangfireScheduler.md), [`QuartzScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/QuartzScheduler.md), [`InMemoryScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/InMemoryScheduler.md), [`AwsScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/AwsScheduler.md), [`AzureScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/AzureScheduler.md), [`CustomScheduler.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/CustomScheduler.md)). The migration section should add: how to register a scheduler for RMQ delay; the precedence rule from step 2; an explicit caveat that `InMemoryScheduler` is **not durable and not safe for production delayed messaging** — pending delays are held in process memory only and **silently disappear on restart with no error raised**; use TickerQ for in-process production, Hangfire/Quartz for distributed deployments; a comparison table of scheduler backends (polling interval, persistence, distributed vs in-process). Lands as a separate PR against `BrighterCommand/Docs`. ### Phase 2 — Next major (target version: TBD on acceptance) -1. **Remove the plugin-aware code paths** in `ExchangeConfigurationHelper`, the `x-delay` exchange declaration, and the `DelaySupported` branch in both producers and `RmqMessageConsumer.RequeueAsync`. -2. **Make `IAmAMessageScheduler` registration mandatory** for any caller of `SendWithDelay` or `RequeueAsync(timeout: nonzero)` on RMQ. Throw a `ConfigurationException` at startup if delay is used without a registered scheduler. +1. **Remove the plugin-aware code paths**: + - `ExchangeConfigurationHelper` — drop the `if (connection.Exchange.SupportDelay) { x-delayed-type / x-delayed-message }` block in **both** Sync and Async transports. + - `RmqMessageProducer` — drop the `DelaySupported` branch in the `SendWithDelay` condition in **both** transports. + - `RmqMessageConsumer.RequeueAsync` (Async) and `RmqMessageConsumer.Requeue` (Sync) — drop the `DelaySupported` branch. + - `RmqMessageGateway.DelaySupported` and `Exchange.SupportDelay` — delete (after the `[Obsolete]` window). + - `Paramore.Brighter.IAmAMessageGatewaySupportingDelay` — orphan public interface in `src/Paramore.Brighter/IAmAMessageGatewaySupportingDelay.cs` with **zero current implementations or callers** (a Serena/LSP search across `src`, `tests`, and `samples` finds none). It's the abstract role for "broker supports delay" that the new strategy retires; safe to delete in Phase 2 alongside the rest. +2. **Throw `ConfigurationException` at call-time** (not at startup — delay is decided per-call by the caller, so the check has to live inside `SendWithDelay`/`RequeueAsync` when `delay > TimeSpan.Zero && Scheduler == null`). Brighter already exposes `Paramore.Brighter.ConfigurationException`. 3. **Stop bundling the delay plugin** in the CI test image; pin to plain `rabbitmq:management`. ### Tanzu RabbitMQ support -For users on Tanzu, document how to declare `x-queue-type: delayed` queues and rely on Tanzu's `rabbitmq_delayed_queue` plugin. No code change is required: Tanzu accepts the same `x-delay` header Brighter already emits, so Brighter's wire format is forward-compatible. Documentation lands in [`BrighterCommand/Docs/contents/RabbitMQConfiguration.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/RabbitMQConfiguration.md) alongside the migration guide added in Phase 1, step 4. +For users on Tanzu, document how to declare `x-queue-type: delayed` queues and rely on Tanzu's `rabbitmq_delayed_queue` plugin. No code change is required: Tanzu's [delayed-queues documentation](https://techdocs.broadcom.com/us/en/vmware-tanzu/data-solutions/tanzu-rabbitmq-ova/4-2/tanzu-rabbitmq-ova-virtual-machine/site-delayed-queues.html) lists `x-delay` as a recognised delay header (alongside the higher-priority `x-opt-delivery-time` and `x-opt-delivery-delay`), so Brighter's existing wire format is forward-compatible. Documentation lands in [`BrighterCommand/Docs/contents/RabbitMQConfiguration.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/RabbitMQConfiguration.md) alongside the migration guide added in Phase 1, step 4. ### Responsibilities @@ -104,6 +121,7 @@ This consolidation matches the long-standing principle in Brighter's design guid - Delayed messages are no longer visible in the RabbitMQ Management UI as queued-but-not-yet-delivered; they sit in the scheduler's store. Operators using the RMQ UI for delay visibility will need to look elsewhere. - Sub-second delay precision now depends on the scheduler implementation. Hangfire and Quartz have polling intervals (default ~15s for Hangfire); TickerQ is finer-grained. Users with strict sub-second SLAs need to pick the scheduler appropriately. - Adds a deployment dependency for delay users (a scheduler backend), which the plugin path did not require beyond the broker. +- **Pre-existing silent no-delay failure mode is exposed by Phase 1 documentation, not introduced.** Today, when a caller passes `delay > TimeSpan.Zero` to `SendWithDelay`/`RequeueAsync` with `SupportDelay = false` and no `Scheduler` registered, the producer condition `delay == TimeSpan.Zero || DelaySupported || Scheduler == null` evaluates `false || false || true` and takes the plugin path, publishing with the `x-delay` header to a normal exchange. The broker silently ignores the header and the message is delivered immediately, with no error. Documenting the migration surfaces this gap; Phase 2's call-time `ConfigurationException` (step 2) closes it permanently. ### Risks and Mitigations @@ -170,10 +188,19 @@ Tanzu RabbitMQ ships a closed-source replacement: a queue type `x-queue-type: de - TTL + DLX official docs: , - Tanzu delayed queues: - Brighter scheduler interface: [`src/Paramore.Brighter/IAmAMessageScheduler.cs`](../../src/Paramore.Brighter/IAmAMessageScheduler.cs) -- Existing fallback sites: - - Async producer: [`src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageProducer.cs`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageProducer.cs#L168) - - Sync producer: [`src/Paramore.Brighter.MessagingGateway.RMQ.Sync/RmqMessageProducer.cs`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Sync/RmqMessageProducer.cs#L155) - - Async consumer requeue: [`src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageConsumer.cs`](../../src/Paramore.Brighter.MessagingGateway.RMQ.Async/RmqMessageConsumer.cs#L419) +- Brighter `ConfigurationException` (used by Phase 2 step 2): [`src/Paramore.Brighter/ConfigurationException.cs`](../../src/Paramore.Brighter/ConfigurationException.cs) +- Plugin opt-in surfaces (targets for `[Obsolete]` in Phase 1 step 1): + - `Exchange.SupportDelay` / `supportDelay` ctor param — referenced in `src/Paramore.Brighter.MessagingGateway.RMQ.{Sync,Async}/RmqMessagingGatewayConnection.cs` + - `RmqMessageGateway.DelaySupported` — derived public property, both transports +- Orphan public interface to delete in Phase 2 step 1: [`src/Paramore.Brighter/IAmAMessageGatewaySupportingDelay.cs`](../../src/Paramore.Brighter/IAmAMessageGatewaySupportingDelay.cs) — no implementations, no callers +- DI scheduler propagation: [`src/Paramore.Brighter.Extensions.DependencyInjection/ServiceCollectionExtensions.cs`](../../src/Paramore.Brighter.Extensions.DependencyInjection/ServiceCollectionExtensions.cs) (`BuildCommandProcessor` — `producerRegistry.Producers.Each(x => x.Scheduler ??= ...)`) +- Plugin-aware code paths (targets for removal in Phase 2 step 1): + - Producer guard: `RmqMessageProducer.SendWithDelayAsync` (Async) and `RmqMessageProducer.SendWithDelay` (Sync) + - Consumer requeue guard: `RmqMessageConsumer.RequeueAsync` (Async) and `RmqMessageConsumer.Requeue` (Sync) — note these delegate to the producer rather than duplicating its `Scheduler == null` guard + - Exchange declaration: `ExchangeConfigurationHelper.CreateExchange` in both transports (`x-delayed-type` argument and `x-delayed-message` exchange-type override) +- User-facing documentation (sibling repo [`BrighterCommand/Docs`](https://github.com/BrighterCommand/Docs)): + - [`contents/RabbitMQConfiguration.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/RabbitMQConfiguration.md) — primary target for the migration guide + - [`contents/BrighterSchedulerSupport.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/BrighterSchedulerSupport.md) — scheduler overview to cross-link from the migration guide - User-facing documentation (sibling repo [`BrighterCommand/Docs`](https://github.com/BrighterCommand/Docs)): - [`contents/RabbitMQConfiguration.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/RabbitMQConfiguration.md) — primary target for the migration guide - [`contents/BrighterSchedulerSupport.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/BrighterSchedulerSupport.md) — scheduler overview to cross-link from the migration guide From 8d7a2f47c637e265d7ae5823e876427468f45661 Mon Sep 17 00:00:00 2001 From: Toby Henderson Date: Tue, 28 Apr 2026 11:11:36 +0100 Subject: [PATCH 7/8] docs(adr): fix duplicate references block, clarify silent-failure path and Phase 2 guard location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the 09:22Z bot review on PR #4106: - Removed a duplicate three-line block in the References section that was left behind from an earlier edit. The "User-facing documentation" bullet was repeated verbatim. - Inline producer-condition snippet: relabelled the first branch from "// plugin path" to make explicit that the same branch also catches the silent-no-delay fall-through (delay > 0, !DelaySupported, Scheduler == null), pointing readers at §Negative consequences. - Phase 2 step 2: clarified that the ConfigurationException guard belongs in the producer only, not in both producer and consumer. The consumer's RequeueAsync/Requeue delegates to a _requeueProducer carrying Scheduler = _scheduler, so the producer's exception surfaces on requeue automatically without duplicating the guard. - Negative consequences: added a durability-boundary note. Pending delayed messages move from the broker's persistent-queue guarantees to whatever store the chosen IAmAMessageScheduler uses; operators must size and back up the scheduler's store accordingly. Co-Authored-By: Claude Opus 4.7 --- docs/adr/0057-rabbitmq-delayed-messaging-strategy.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md b/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md index ea2b92e016..cbad75b19c 100644 --- a/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md +++ b/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md @@ -29,7 +29,9 @@ The terminal working combination — RMQ 4.2 + plugin v4.2.0-rc.1 — is being a ```csharp if (delay == TimeSpan.Zero || DelaySupported || Scheduler == null) { - // plugin path: publish to the x-delayed-message exchange + // plugin path (or, when no scheduler and !DelaySupported, a silent + // no-delay fall-through — the broker ignores x-delay on a normal + // exchange; see §Negative consequences) } else if (useSchedulerAsync) { @@ -87,7 +89,7 @@ Combine **Option B** (route delay through `IAmAMessageScheduler`) with **Option - `RmqMessageConsumer.RequeueAsync` (Async) and `RmqMessageConsumer.Requeue` (Sync) — drop the `DelaySupported` branch. - `RmqMessageGateway.DelaySupported` and `Exchange.SupportDelay` — delete (after the `[Obsolete]` window). - `Paramore.Brighter.IAmAMessageGatewaySupportingDelay` — orphan public interface in `src/Paramore.Brighter/IAmAMessageGatewaySupportingDelay.cs` with **zero current implementations or callers** (a Serena/LSP search across `src`, `tests`, and `samples` finds none). It's the abstract role for "broker supports delay" that the new strategy retires; safe to delete in Phase 2 alongside the rest. -2. **Throw `ConfigurationException` at call-time** (not at startup — delay is decided per-call by the caller, so the check has to live inside `SendWithDelay`/`RequeueAsync` when `delay > TimeSpan.Zero && Scheduler == null`). Brighter already exposes `Paramore.Brighter.ConfigurationException`. +2. **Throw `ConfigurationException` at call-time**. Delay is decided per-call by the caller, so the check has to fire on each `SendWithDelay`/`SendWithDelayAsync` invocation when `delay > TimeSpan.Zero && Scheduler == null`. Add the guard **once, in the producer** (where the existing condition lives) — the consumer's `RequeueAsync`/`Requeue` delegates to a `_requeueProducer` constructed with `Scheduler = _scheduler`, so the producer's exception will surface naturally on requeue without a duplicated guard. Brighter already exposes `Paramore.Brighter.ConfigurationException`. 3. **Stop bundling the delay plugin** in the CI test image; pin to plain `rabbitmq:management`. ### Tanzu RabbitMQ support @@ -121,6 +123,7 @@ This consolidation matches the long-standing principle in Brighter's design guid - Delayed messages are no longer visible in the RabbitMQ Management UI as queued-but-not-yet-delivered; they sit in the scheduler's store. Operators using the RMQ UI for delay visibility will need to look elsewhere. - Sub-second delay precision now depends on the scheduler implementation. Hangfire and Quartz have polling intervals (default ~15s for Hangfire); TickerQ is finer-grained. Users with strict sub-second SLAs need to pick the scheduler appropriately. - Adds a deployment dependency for delay users (a scheduler backend), which the plugin path did not require beyond the broker. +- **Durability boundary shifts.** A pending delayed message is no longer protected by RabbitMQ's persistent-queue guarantees; it now lives in whichever store the chosen `IAmAMessageScheduler` uses (process memory for `InMemoryScheduler`, a SQL DB for Hangfire/Quartz, etc.). Operators must size and back up the scheduler's store with the same care they applied to the broker's queues, and tolerate the scheduler's failure modes (DB row loss, replica lag, polling-interval skew) for delayed messages. - **Pre-existing silent no-delay failure mode is exposed by Phase 1 documentation, not introduced.** Today, when a caller passes `delay > TimeSpan.Zero` to `SendWithDelay`/`RequeueAsync` with `SupportDelay = false` and no `Scheduler` registered, the producer condition `delay == TimeSpan.Zero || DelaySupported || Scheduler == null` evaluates `false || false || true` and takes the plugin path, publishing with the `x-delay` header to a normal exchange. The broker silently ignores the header and the message is delivered immediately, with no error. Documenting the migration surfaces this gap; Phase 2's call-time `ConfigurationException` (step 2) closes it permanently. ### Risks and Mitigations @@ -201,6 +204,3 @@ Tanzu RabbitMQ ships a closed-source replacement: a queue type `x-queue-type: de - User-facing documentation (sibling repo [`BrighterCommand/Docs`](https://github.com/BrighterCommand/Docs)): - [`contents/RabbitMQConfiguration.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/RabbitMQConfiguration.md) — primary target for the migration guide - [`contents/BrighterSchedulerSupport.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/BrighterSchedulerSupport.md) — scheduler overview to cross-link from the migration guide -- User-facing documentation (sibling repo [`BrighterCommand/Docs`](https://github.com/BrighterCommand/Docs)): - - [`contents/RabbitMQConfiguration.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/RabbitMQConfiguration.md) — primary target for the migration guide - - [`contents/BrighterSchedulerSupport.md`](https://github.com/BrighterCommand/Docs/blob/master/contents/BrighterSchedulerSupport.md) — scheduler overview to cross-link from the migration guide From d5478bd67005384b6c311ee7e2f4fc41c5d2c5d5 Mon Sep 17 00:00:00 2001 From: Toby Henderson Date: Sat, 6 Jun 2026 15:25:40 +0100 Subject: [PATCH 8/8] docs(adr): renumber to 0062, soften Hangfire polling claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Master has moved on since the ADR was opened; 0057 already has three sibling ADRs (box-schema-versioning, in-memory-box-abstract-expiry, span-based-performance), and 0061 is the highest taken. Renames the file from 0057 to 0062 — the next free slot — and updates: - The "# 57." heading to "# 62." - The [Obsolete] suggested text inside Phase 1 step 1 ("See ADR 0057") to "See ADR 0062" Also softens the Hangfire "~15s default" polling claim per bot review: the default is version-dependent, and the ADR shouldn't pin a number that may not match what users actually ship. Co-Authored-By: Claude Opus 4.7 --- ...ategy.md => 0062-rabbitmq-delayed-messaging-strategy.md} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename docs/adr/{0057-rabbitmq-delayed-messaging-strategy.md => 0062-rabbitmq-delayed-messaging-strategy.md} (98%) diff --git a/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md b/docs/adr/0062-rabbitmq-delayed-messaging-strategy.md similarity index 98% rename from docs/adr/0057-rabbitmq-delayed-messaging-strategy.md rename to docs/adr/0062-rabbitmq-delayed-messaging-strategy.md index cbad75b19c..5be8ee5dbe 100644 --- a/docs/adr/0057-rabbitmq-delayed-messaging-strategy.md +++ b/docs/adr/0062-rabbitmq-delayed-messaging-strategy.md @@ -1,4 +1,4 @@ -# 57. RabbitMQ Delayed Messaging Strategy (Post Plugin Archival) +# 62. RabbitMQ Delayed Messaging Strategy (Post Plugin Archival) Date: 2026-04-27 @@ -67,7 +67,7 @@ Combine **Option B** (route delay through `IAmAMessageScheduler`) with **Option - `Exchange.SupportDelay` (the property setter and the `supportDelay` constructor parameter on the `Exchange` type) - `RmqMessageGateway.DelaySupported` (the public read-only property derived from `SupportDelay`, in both the Sync and Async transports) - `[Obsolete]` text along the lines of: `"Configure an IAmAMessageScheduler instead — the rabbitmq_delayed_message_exchange plugin is archived upstream and incompatible with RabbitMQ 4.3+. See ADR 0057."` Implementation should choose the most-narrow target (the `supportDelay` parameter via documentation rather than the whole `Exchange` constructor, if other parameters remain valid) — this is a PR-level decision, not an ADR one. + `[Obsolete]` text along the lines of: `"Configure an IAmAMessageScheduler instead — the rabbitmq_delayed_message_exchange plugin is archived upstream and incompatible with RabbitMQ 4.3+. See ADR 0062."` Implementation should choose the most-narrow target (the `supportDelay` parameter via documentation rather than the whole `Exchange` constructor, if other parameters remain valid) — this is a PR-level decision, not an ADR one. 2. **Pick and document the runtime precedence rule.** The current code in `RmqMessageProducer` reads: ```csharp if (delay == TimeSpan.Zero || DelaySupported || Scheduler == null) { /* plugin path */ } @@ -121,7 +121,7 @@ This consolidation matches the long-standing principle in Brighter's design guid - Users currently relying on `SupportDelay = true` must register an `IAmAMessageScheduler` before the next major. This is a real migration, not just a renaming. - Delayed messages are no longer visible in the RabbitMQ Management UI as queued-but-not-yet-delivered; they sit in the scheduler's store. Operators using the RMQ UI for delay visibility will need to look elsewhere. -- Sub-second delay precision now depends on the scheduler implementation. Hangfire and Quartz have polling intervals (default ~15s for Hangfire); TickerQ is finer-grained. Users with strict sub-second SLAs need to pick the scheduler appropriately. +- Sub-second delay precision now depends on the scheduler implementation. Hangfire and Quartz have polling intervals (Hangfire's default is on the order of seconds and is version-dependent — verify against the version you ship); TickerQ is finer-grained. Users with strict sub-second SLAs need to pick the scheduler appropriately. - Adds a deployment dependency for delay users (a scheduler backend), which the plugin path did not require beyond the broker. - **Durability boundary shifts.** A pending delayed message is no longer protected by RabbitMQ's persistent-queue guarantees; it now lives in whichever store the chosen `IAmAMessageScheduler` uses (process memory for `InMemoryScheduler`, a SQL DB for Hangfire/Quartz, etc.). Operators must size and back up the scheduler's store with the same care they applied to the broker's queues, and tolerate the scheduler's failure modes (DB row loss, replica lag, polling-interval skew) for delayed messages. - **Pre-existing silent no-delay failure mode is exposed by Phase 1 documentation, not introduced.** Today, when a caller passes `delay > TimeSpan.Zero` to `SendWithDelay`/`RequeueAsync` with `SupportDelay = false` and no `Scheduler` registered, the producer condition `delay == TimeSpan.Zero || DelaySupported || Scheduler == null` evaluates `false || false || true` and takes the plugin path, publishing with the `x-delay` header to a normal exchange. The broker silently ignores the header and the message is delivered immediately, with no error. Documenting the migration surfaces this gap; Phase 2's call-time `ConfigurationException` (step 2) closes it permanently.