From 42079f4901ee7bacc428ac98654214a3cbe2318c Mon Sep 17 00:00:00 2001 From: Luke Bakken Date: Mon, 8 Dec 2025 12:10:07 -0800 Subject: [PATCH 1/3] Add `ConfirmationChannel` for async publisher confirmations Traditional publisher confirms in the Java client require manual tracking of sequence numbers and correlation of Basic.Return messages. This makes per-message error handling complex and provides no built-in async pattern, backpressure mechanism, or message correlation support. This change introduces `ConfirmationChannel`, a wrapper that provides automatic publisher confirmation tracking with a `CompletableFuture`-based API, optional throttling, and generic context parameter for message correlation. The implementation uses listener-based integration with existing `Channel` instances, requiring no modifications to `ChannelN`. New API components: - `ConfirmationChannel` interface - Extends `Channel` and adds `basicPublishAsync()` methods that return `CompletableFuture` - `ConfirmationChannelN` implementation - Wraps any `Channel` instance and tracks confirmations via return/confirm/shutdown listeners - `PublishException` - Exception thrown when message is nack'd or returned, with sequence number, routing details, and user context The wrapper maintains independent sequence numbers using `AtomicLong` and stores confirmation state in a `ConcurrentHashMap`. Each entry holds the future, rate limiter permit, and user-provided context. Messages include an `x-seq-no` header for correlating Basic.Return responses. Rate limiting is optional via `RateLimiter` parameter. The `ThrottlingRateLimiter` implementation uses progressive delays (0-1000ms) based on capacity usage, applying backpressure when available permits fall below a threshold (default 50%). The `basicPublish()` and `waitForConfirms()` methods throw `UnsupportedOperationException` on `ConfirmationChannel` to prevent mixing synchronous and asynchronous patterns. All other `Channel` methods delegate to the wrapped instance. Tests include 9 unit tests for `ThrottlingRateLimiter` and 24 integration tests for publisher confirmation tracking with context parameter verification, rate limiting scenarios, and error handling. --- RUNNING_TESTS.md | 14 +- doc/publisher-confirmations/README.md | 358 +++++++ .../rabbitmq/client/ConfirmationChannel.java | 149 +++ .../java/com/rabbitmq/client/Connection.java | 14 +- .../com/rabbitmq/client/PublishException.java | 153 +++ .../java/com/rabbitmq/client/RateLimiter.java | 67 ++ .../client/ThrottlingRateLimiter.java | 156 +++ .../com/rabbitmq/client/impl/ChannelN.java | 1 + .../client/impl/ConfirmationChannelN.java | 934 ++++++++++++++++++ .../impl/recovery/RecoveryAwareChannelN.java | 1 + .../test/ThrottlingRateLimiterTest.java | 236 +++++ .../client/test/functional/Confirm.java | 268 +++++ 12 files changed, 2343 insertions(+), 8 deletions(-) create mode 100644 doc/publisher-confirmations/README.md create mode 100644 src/main/java/com/rabbitmq/client/ConfirmationChannel.java create mode 100644 src/main/java/com/rabbitmq/client/PublishException.java create mode 100644 src/main/java/com/rabbitmq/client/RateLimiter.java create mode 100644 src/main/java/com/rabbitmq/client/ThrottlingRateLimiter.java create mode 100644 src/main/java/com/rabbitmq/client/impl/ConfirmationChannelN.java create mode 100644 src/test/java/com/rabbitmq/client/test/ThrottlingRateLimiterTest.java diff --git a/RUNNING_TESTS.md b/RUNNING_TESTS.md index f948127ba1..21a45046db 100644 --- a/RUNNING_TESTS.md +++ b/RUNNING_TESTS.md @@ -88,6 +88,18 @@ top-level directory of the source tree: -Dit.test=DeadLetterExchange ``` +* To run a single test class: + +``` +./mvnw verify -Dit.test=Confirm +``` + +* To run a specific test method within a test class: + +``` +./mvnw verify -Dit.test=Confirm#testBasicPublishAsync +``` + Test reports can be found in `target/failsafe-reports`. ## Running Against a Broker in a Docker Container @@ -95,7 +107,7 @@ Test reports can be found in `target/failsafe-reports`. Run the broker: ``` -docker run -it --rm --name rabbitmq -p 5672:5672 rabbitmq:3.8 +docker run --pull always --rm --tty --interactive --name rabbitmq --publish 5672:5672 rabbitmq:latest ``` Launch the tests: diff --git a/doc/publisher-confirmations/README.md b/doc/publisher-confirmations/README.md new file mode 100644 index 0000000000..837ffb5453 --- /dev/null +++ b/doc/publisher-confirmations/README.md @@ -0,0 +1,358 @@ +# ConfirmationChannel - Asynchronous Publisher Confirmations + +**Status:** Complete +**Date:** 2025-12-10 + +## Overview + +`ConfirmationChannel` provides asynchronous publisher confirmation tracking with a `CompletableFuture`-based API, optional rate limiting, and generic context parameter for message correlation. The implementation wraps existing `Channel` instances using listener-based integration, requiring no modifications to the core `ChannelN` class. + +## Motivation + +Traditional publisher confirms in the Java client require manual tracking of sequence numbers and correlation of Basic.Return messages. This makes per-message error handling complex and provides no built-in async pattern, backpressure mechanism, or message correlation support. + +`ConfirmationChannel` addresses these limitations by providing: +- Automatic confirmation tracking via `CompletableFuture` API +- Generic context parameter for message correlation +- Optional rate limiting for backpressure control +- Clean separation from core `Channel` implementation + +## Architecture + +### Interface Hierarchy + +``` +Channel (existing interface) + ↑ + | +ConfirmationChannel (new interface) + ↑ + | +ConfirmationChannelN (new implementation) +``` + +### Key Components + +**ConfirmationChannel Interface** +- Extends `Channel` interface +- Adds `basicPublishAsync()` methods (with and without mandatory flag) +- Generic `` context parameter for correlation +- Returns `CompletableFuture` + +**ConfirmationChannelN Implementation** +- Wraps an existing `Channel` instance (composition, not inheritance) +- Maintains its own sequence number counter (`AtomicLong`) +- Registers return and confirm listeners on the wrapped channel +- Delegates all other `Channel` methods to the wrapped instance +- Throws `UnsupportedOperationException` for `basicPublish()` methods + +### Sequence Number Management + +**Independent Sequence Space:** +- `ConfirmationChannelN` maintains its own `AtomicLong nextSeqNo` +- No coordination with `ChannelN`'s sequence numbers +- Sequence numbers start at 1 and increment for each `basicPublishAsync()` call +- Sequence number added to message headers as `x-seq-no` + +**Why Independent?** +- `basicPublish()` is disallowed on `ConfirmationChannel` +- No risk of sequence number conflicts +- Simpler implementation - no need to access `ChannelN` internals +- Clean separation of concerns + +### Confirmation Tracking + +**State Management:** +```java +private final ConcurrentHashMap> confirmations; + +private static class ConfirmationEntry { + final CompletableFuture future; + final RateLimiter.Permit permit; + final T context; +} +``` + +**Lifecycle:** +1. `basicPublishAsync()` called +2. Acquire rate limiter permit (if configured) +3. Get next sequence number +4. Create `CompletableFuture` and `ConfirmationEntry` +5. Add `x-seq-no` header to message +6. Store entry in `confirmations` map +7. Call `delegate.basicPublish()` +8. Return future to caller + +**Completion Paths:** +- **Basic.Ack** → Complete future with context value, release permit +- **Basic.Nack** → Complete exceptionally with `PublishException`, release permit +- **Basic.Return** → Complete exceptionally with `PublishException`, release permit +- **Channel close** → Complete all pending futures exceptionally, release all permits + +### Listener Integration + +**Return Listener:** +```java +delegate.addReturnListener((replyCode, replyText, exchange, routingKey, props, body) -> { + long seqNo = extractSequenceNumber(props.getHeaders()); + ConfirmationEntry entry = confirmations.remove(seqNo); + if (entry != null) { + entry.future.completeExceptionally( + new PublishException(seqNo, true, exchange, routingKey, replyCode, replyText, entry.context) + ); + entry.releasePermit(); + } +}); +``` + +**Confirm Listeners:** +```java +delegate.addConfirmListener( + (seqNo, multiple) -> handleAck(seqNo, multiple), + (seqNo, multiple) -> handleNack(seqNo, multiple) +); +``` + +**Multiple Acknowledgments:** +When `multiple=true`, all sequence numbers ≤ `seqNo` are processed: +```java +for (Long seq : new ArrayList<>(confirmations.keySet())) { + if (seq <= seqNo) { + ConfirmationEntry entry = confirmations.remove(seq); + // Complete future and release permit + } +} +``` + +## API Design + +### Constructor + +```java +public ConfirmationChannelN(Channel delegate, RateLimiter rateLimiter) +``` + +**Parameters:** +- `delegate` - The underlying `Channel` instance (typically `ChannelN`) +- `rateLimiter` - Optional rate limiter for controlling publish concurrency (can be null) + +**Initialization:** +- Calls `delegate.confirmSelect()` to enable publisher confirmations +- Registers return and confirm listeners +- Initializes confirmation tracking map + +### basicPublishAsync Methods + +```java + CompletableFuture basicPublishAsync(String exchange, String routingKey, + AMQP.BasicProperties props, byte[] body, T context) + + CompletableFuture basicPublishAsync(String exchange, String routingKey, + boolean mandatory, + AMQP.BasicProperties props, byte[] body, T context) +``` + +**Context Parameter:** +- Generic type `` allows any user-defined correlation object +- Returned in the completed future on success +- Available in `PublishException.getContext()` on failure +- Can be null if correlation not needed + +**Return Value:** +- `CompletableFuture` that completes when broker confirms/rejects +- Completes successfully with context value on Basic.Ack +- Completes exceptionally with `PublishException` on Basic.Nack or Basic.Return + +### basicPublish Methods (Disallowed) + +All `basicPublish()` method overloads throw `UnsupportedOperationException`: + +```java +@Override +public void basicPublish(String exchange, String routingKey, + AMQP.BasicProperties props, byte[] body) { + throw new UnsupportedOperationException( + "basicPublish() is not supported on ConfirmationChannel. Use basicPublishAsync() instead." + ); +} +``` + +**Rationale:** +- Prevents mixing synchronous and asynchronous publish patterns +- Eliminates sequence number coordination complexity +- Clear API contract - this channel is for async confirmations only + +### Delegated Methods + +All other `Channel` methods are delegated to the wrapped instance: +- `basicConsume()`, `basicGet()`, `basicAck()`, etc. +- `exchangeDeclare()`, `queueDeclare()`, etc. +- `addReturnListener()`, `addConfirmListener()`, etc. +- `close()`, `abort()`, etc. + +## Rate Limiting + +**Optional Feature:** +- Pass `RateLimiter` to constructor to enable +- Limits concurrent in-flight messages +- Blocks in `basicPublishAsync()` until permit available +- Permits released when confirmation received (ack/nack/return) + +**Integration:** +```java +RateLimiter.Permit permit = null; +if (rateLimiter != null) { + permit = rateLimiter.acquire(); // May block +} +// ... publish message ... +// Store permit in ConfirmationEntry for later release +``` + +## Error Handling + +### PublishException + +Enhanced with context parameter: +```java +public class PublishException extends IOException { + private final Object context; // User-provided correlation object + + // Constructor for nacks (no routing details available) + public PublishException(long sequenceNumber, Object context) + + // Constructor for returns (full routing details) + public PublishException(long sequenceNumber, boolean isReturn, + String exchange, String routingKey, + Integer replyCode, String replyText, Object context) +} +``` + +### Exception Scenarios + +**Basic.Nack:** +- Broker rejected the message +- `isReturn() == false` +- Exchange, routingKey, replyCode, replyText are null +- Only sequence number and context available + +**Basic.Return:** +- Message unroutable (mandatory flag set) +- `isReturn() == true` +- Full routing details available +- Reply code indicates reason (NO_ROUTE, NO_CONSUMERS, etc.) + +**Channel Closed:** +- All pending futures completed with `AlreadyClosedException` +- All rate limiter permits released +- Confirmations map cleared + +**I/O Error:** +- Future completed with the I/O exception +- Rate limiter permit released +- Entry removed from confirmations map + +## Usage Examples + +### Basic Usage + +```java +Connection connection = factory.newConnection(); +Channel channel = connection.createChannel(); +ConfirmationChannel confirmChannel = ConfirmationChannel.create(channel, null); + +confirmChannel.basicPublishAsync("exchange", "routing.key", props, body, "msg-123") + .thenAccept(msgId -> System.out.println("Confirmed: " + msgId)) + .exceptionally(ex -> { + System.err.println("Failed: " + ex.getMessage()); + return null; + }); +``` + +### With Rate Limiting + +```java +RateLimiter rateLimiter = new ThrottlingRateLimiter(1000); // Max 1000 in-flight +ConfirmationChannel confirmChannel = ConfirmationChannel.create(channel, rateLimiter); + +for (int i = 0; i < 10000; i++) { + String msgId = "msg-" + i; + confirmChannel.basicPublishAsync("exchange", "key", props, body, msgId) + .thenAccept(id -> System.out.println("Confirmed: " + id)) + .exceptionally(ex -> { + if (ex.getCause() instanceof PublishException) { + PublishException pe = (PublishException) ex.getCause(); + System.err.println("Failed: " + pe.getContext()); + } + return null; + }); +} +``` + +### With Context Objects + +```java +class MessageContext { + final String orderId; + final Instant timestamp; + + MessageContext(String orderId) { + this.orderId = orderId; + this.timestamp = Instant.now(); + } +} + +MessageContext ctx = new MessageContext("order-12345"); +confirmChannel.basicPublishAsync("orders", "new", props, body, ctx) + .thenAccept(context -> { + Duration latency = Duration.between(context.timestamp, Instant.now()); + System.out.println("Order " + context.orderId + " confirmed in " + latency.toMillis() + "ms"); + }); +``` + +## Test Results + +- **Confirm tests:** 24/24 passing +- **ThrottlingRateLimiterTest:** 9/9 passing +- **Total:** 33/33 tests passing + +## Testing Strategy + +### Unit Tests +- Sequence number generation and tracking +- Confirmation entry lifecycle +- Rate limiter integration +- Exception handling + +### Integration Tests (Existing) +- All 25 tests in `Confirm.java` adapted to use `ConfirmationChannel` +- Basic.Ack handling (single and multiple) +- Basic.Nack handling (single and multiple) +- Basic.Return handling +- Context parameter correlation +- Channel close cleanup + +### Rate Limiter Tests (Existing) +- 9 tests in `ThrottlingRateLimiterTest.java` +- No changes needed (rate limiter is independent) + +## Trade-offs + +**Pros:** +- Clean architecture with clear boundaries +- No risk of breaking existing functionality +- Easy to understand and maintain +- Can evolve independently of `ChannelN` + +**Cons:** +- Requires wrapping a channel (extra object) +- Two ways to do publisher confirmations (`waitForConfirms()` vs `basicPublishAsync()`) +- Cannot mix `basicPublish()` and `basicPublishAsync()` on same channel +- Slightly more verbose setup code + +## Future Enhancements + +1. **Factory method on Connection** - `connection.createConfirmationChannel(rateLimiter)` +2. **Batch operations** - `basicPublishAsyncBatch()` for multiple messages +3. **Metrics integration** - Add metrics for `basicPublishAsync()` +4. **Observability** - Integration with observation collectors +5. **Alternative rate limiters** - Token bucket, sliding window, etc. diff --git a/src/main/java/com/rabbitmq/client/ConfirmationChannel.java b/src/main/java/com/rabbitmq/client/ConfirmationChannel.java new file mode 100644 index 0000000000..0736062317 --- /dev/null +++ b/src/main/java/com/rabbitmq/client/ConfirmationChannel.java @@ -0,0 +1,149 @@ +// Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. +// +// This software, the RabbitMQ Java client library, is triple-licensed under the +// Mozilla Public License 2.0 ("MPL"), the GNU General Public License version 2 +// ("GPL") and the Apache License version 2 ("ASL"). For the MPL, please see +// LICENSE-MPL-RabbitMQ. For the GPL, please see LICENSE-GPL2. For the ASL, +// please see LICENSE-APACHE2. +// +// This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, +// either express or implied. See the LICENSE file for specific language governing +// rights and limitations of this software. +// +// If you have any questions regarding licensing, please contact us at +// info@rabbitmq.com. + +package com.rabbitmq.client; + +import com.rabbitmq.client.impl.ConfirmationChannelN; + +import java.io.IOException; +import java.util.concurrent.CompletableFuture; + +/** + * A channel that supports asynchronous publisher confirmations. + *

+ * This interface extends {@link Channel} to add the {@link #basicPublishAsync} method, + * which returns a {@link CompletableFuture} that completes when the broker confirms + * or rejects the published message. + *

+ * Publisher confirmations are automatically enabled when using this channel type. + * Messages are tracked using sequence numbers, and the future completes when: + *

    + *
  • The broker sends Basic.Ack (successful confirmation)
  • + *
  • The broker sends Basic.Nack (rejection) - future completes exceptionally with {@link PublishException}
  • + *
  • The broker returns the message via Basic.Return (unroutable) - future completes exceptionally with {@link PublishException}
  • + *
+ *

+ * Example usage: + *

{@code
+ * Channel channel = connection.createChannel();
+ * ConfirmationChannel confirmChannel = ConfirmationChannel.create(channel, rateLimiter);
+ *
+ * confirmChannel.basicPublishAsync("exchange", "routing.key", props, body, "msg-123")
+ *     .thenAccept(msgId -> System.out.println("Confirmed: " + msgId))
+ *     .exceptionally(ex -> {
+ *         if (ex.getCause() instanceof PublishException) {
+ *             PublishException pe = (PublishException) ex.getCause();
+ *             System.err.println("Failed: " + pe.getContext());
+ *         }
+ *         return null;
+ *     });
+ * }
+ * + * @see Channel + * @see PublishException + * @see com.rabbitmq.client.impl.ConfirmationChannelN + */ +public interface ConfirmationChannel extends Channel +{ + /** + * Creates a new ConfirmationChannel by wrapping an existing channel. + *

+ * This factory method enables asynchronous publisher confirmation tracking + * on any existing channel. The wrapped channel will have publisher confirmations + * automatically enabled via {@link Channel#confirmSelect()}. + *

+ * Example usage: + *

{@code
+     * Channel channel = connection.createChannel();
+     * RateLimiter limiter = new ThrottlingRateLimiter(100, 50);
+     * ConfirmationChannel confirmChannel = ConfirmationChannel.create(channel, limiter);
+     *
+     * confirmChannel.basicPublishAsync("exchange", "key", props, body, "msg-123")
+     *     .thenAccept(msgId -> System.out.println("Confirmed: " + msgId));
+     * }
+ * + * @param channel the channel to wrap + * @param rateLimiter optional rate limiter for controlling publish concurrency (null for unlimited) + * @return a new ConfirmationChannel instance + * @throws IOException if enabling publisher confirmations fails + * @see RateLimiter + * @see ThrottlingRateLimiter + */ + static ConfirmationChannel create(Channel channel, RateLimiter rateLimiter) throws IOException + { + return new ConfirmationChannelN(channel, rateLimiter); + } + + /** + * Asynchronously publish a message with publisher confirmation tracking. + *

+ * This method publishes a message and returns a {@link CompletableFuture} that completes + * when the broker confirms or rejects the message. The future's value is the context + * parameter provided, allowing correlation between publish requests and confirmations. + *

+ * The future completes: + *

    + *
  • Successfully with the context value when the broker sends Basic.Ack
  • + *
  • Exceptionally with {@link PublishException} when: + *
      + *
    • The broker sends Basic.Nack (message rejected)
    • + *
    • The broker returns the message via Basic.Return (unroutable with mandatory flag)
    • + *
    • An I/O error occurs during publish
    • + *
    • The channel is closed before confirmation
    • + *
    + *
  • + *
+ *

+ * Thread safety: This method is thread-safe and can be called concurrently. + *

+ * Rate limiting: If a {@link RateLimiter} is configured, this method will + * block until a permit is available before publishing. + * + * @param exchange the exchange to publish to + * @param routingKey the routing key + * @param props message properties (null for default) + * @param body message body + * @param context user-provided context object for correlation (can be null) + * @param the type of the context parameter + * @return a CompletableFuture that completes with the context value on success, + * or exceptionally with PublishException on failure + * @throws IllegalStateException if the channel is closed + */ + CompletableFuture basicPublishAsync(String exchange, String routingKey, + AMQP.BasicProperties props, byte[] body, T context); + + /** + * Asynchronously publish a message with mandatory flag and publisher confirmation tracking. + *

+ * This is equivalent to {@link #basicPublishAsync(String, String, AMQP.BasicProperties, byte[], Object)} + * but allows specifying the mandatory flag. When mandatory is true, the broker will return + * the message via Basic.Return if it cannot be routed to any queue, causing the future + * to complete exceptionally with {@link PublishException}. + * + * @param exchange the exchange to publish to + * @param routingKey the routing key + * @param mandatory true if the message must be routable to at least one queue + * @param props message properties (null for default) + * @param body message body + * @param context user-provided context object for correlation (can be null) + * @param the type of the context parameter + * @return a CompletableFuture that completes with the context value on success, + * or exceptionally with PublishException on failure + * @throws IllegalStateException if the channel is closed + */ + CompletableFuture basicPublishAsync(String exchange, String routingKey, + boolean mandatory, + AMQP.BasicProperties props, byte[] body, T context); +} diff --git a/src/main/java/com/rabbitmq/client/Connection.java b/src/main/java/com/rabbitmq/client/Connection.java index 634d0ae7df..949bb71f92 100644 --- a/src/main/java/com/rabbitmq/client/Connection.java +++ b/src/main/java/com/rabbitmq/client/Connection.java @@ -201,23 +201,23 @@ default Optional openChannel(int channelNumber) throws IOException { * Close this connection and all its channels * with the {@link com.rabbitmq.client.AMQP#REPLY_SUCCESS} close code * and message 'OK'. - * + * * This method behaves in a similar way as {@link #close()}, with the only difference * that it waits with a provided timeout for all the close operations to * complete. When timeout is reached the socket is forced to close. - * + * * @param timeout timeout (in milliseconds) for completing all the close-related * operations, use -1 for infinity * @throws IOException if an I/O problem is encountered */ void close(int timeout) throws IOException; - + /** * Close this connection and all its channels. * * Waits with the given timeout for all the close operations to complete. * When timeout is reached the socket is forced to close. - * + * * @param closeCode the close code (See under "Reply Codes" in the AMQP specification) * @param closeMessage a message indicating the reason for closing the connection * @param timeout timeout (in milliseconds) for completing all the close-related @@ -235,18 +235,18 @@ default Optional openChannel(int channelNumber) throws IOException { * Any encountered exceptions in the close operations are silently discarded. */ void abort(); - + /** * Abort this connection and all its channels. * * Forces the connection to close and waits for all the close operations to complete. * Any encountered exceptions in the close operations are silently discarded. - * + * * @param closeCode the close code (See under "Reply Codes" in the AMQP specification) * @param closeMessage a message indicating the reason for closing the connection */ void abort(int closeCode, String closeMessage); - + /** * Abort this connection and all its channels * with the {@link com.rabbitmq.client.AMQP#REPLY_SUCCESS} close code diff --git a/src/main/java/com/rabbitmq/client/PublishException.java b/src/main/java/com/rabbitmq/client/PublishException.java new file mode 100644 index 0000000000..aab9e40ef6 --- /dev/null +++ b/src/main/java/com/rabbitmq/client/PublishException.java @@ -0,0 +1,153 @@ +// Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. +// +// This software, the RabbitMQ Java client library, is triple-licensed under the +// Mozilla Public License 2.0 ("MPL"), the GNU General Public License version 2 +// ("GPL") and the Apache License version 2 ("ASL"). For the MPL, please see +// LICENSE-MPL-RabbitMQ. For the GPL, please see LICENSE-GPL2. For the ASL, +// please see LICENSE-APACHE2. +// +// This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, +// either express or implied. See the LICENSE file for specific language governing +// rights and limitations of this software. +// +// If you have any questions regarding licensing, please contact us at +// info@rabbitmq.com. + +package com.rabbitmq.client; + +import java.io.IOException; + +/** + * Exception thrown when a published message is nack'd or returned by the broker. + *

+ * This exception is thrown when publisher confirmation tracking is enabled and: + *

    + *
  • The broker sends Basic.Nack for a message (negative acknowledgment)
  • + *
  • The broker returns a message via Basic.Return (unroutable with mandatory flag)
  • + *
+ *

+ * Use {@link #isReturn()} to distinguish between these two cases: + *

    + *
  • Nack ({@code isReturn() == false}): The broker rejected the message. + * Additional fields (exchange, routingKey, replyCode, replyText) will be null.
  • + *
  • Return ({@code isReturn() == true}): The broker could not route the message. + * Additional fields contain routing information and the reason for the return.
  • + *
+ *

+ * Example handling: + *

{@code
+ * ConfirmationChannel channel = ConfirmationChannel.create(regularChannel, rateLimiter);
+ * channel.basicPublishAsync(exchange, routingKey, true, props, body, "msg-123")
+ *     .exceptionally(ex -> {
+ *         if (ex.getCause() instanceof PublishException) {
+ *             PublishException pe = (PublishException) ex.getCause();
+ *             String msgId = (String) pe.getContext();
+ *             if (pe.isReturn()) {
+ *                 System.err.println("Message " + msgId + " returned: " + pe.getReplyText());
+ *             } else {
+ *                 System.err.println("Message " + msgId + " nack'd");
+ *             }
+ *         }
+ *         return null;
+ *     });
+ * }
+ * + * @see ConfirmationChannel#basicPublishAsync(String, String, com.rabbitmq.client.AMQP.BasicProperties, byte[], Object) + * @see ConfirmationChannel + */ +public class PublishException extends IOException { + private final long sequenceNumber; + private final boolean isReturn; + private final String exchange; + private final String routingKey; + private final Integer replyCode; + private final String replyText; + private final Object context; + + /** + * Constructor for nack scenarios where routing details are not available. + *

+ * When the broker sends Basic.Nack, it only provides the sequence number. + * The exchange, routingKey, replyCode, and replyText fields will be null. + * + * @param sequenceNumber the publish sequence number + * @param context the user-provided context object + */ + public PublishException(long sequenceNumber, Object context) + { + this(sequenceNumber, false, null, null, null, null, context); + } + + public PublishException(long sequenceNumber, boolean isReturn, String exchange, String routingKey, + Integer replyCode, String replyText, Object context) { + super(buildMessage(sequenceNumber, isReturn, replyCode, replyText)); + this.sequenceNumber = sequenceNumber; + this.isReturn = isReturn; + this.exchange = exchange; + this.routingKey = routingKey; + this.replyCode = replyCode; + this.replyText = replyText; + this.context = context; + } + + private static String buildMessage(long sequenceNumber, boolean isReturn, Integer replyCode, String replyText) { + if (isReturn) { + return String.format("Message %d returned: %s (%d)", sequenceNumber, replyText, replyCode); + } else { + return String.format("Message %d nack'd", sequenceNumber); + } + } + + /** + * @return the publish sequence number of the failed message + */ + public long getSequenceNumber() { + return sequenceNumber; + } + + /** + * @return true if this exception was caused by Basic.Return (unroutable message), + * false if caused by Basic.Nack (broker rejection) + */ + public boolean isReturn() { + return isReturn; + } + + /** + * @return the exchange the message was published to (only available for returns, null for nacks) + */ + public String getExchange() { + return exchange; + } + + /** + * @return the routing key used (only available for returns, null for nacks) + */ + public String getRoutingKey() { + return routingKey; + } + + /** + * @return the reply code from the broker (only available for returns, null for nacks) + * @see com.rabbitmq.client.AMQP#NO_ROUTE + * @see com.rabbitmq.client.AMQP#NO_CONSUMERS + */ + public Integer getReplyCode() { + return replyCode; + } + + /** + * @return the reply text from the broker explaining why the message was returned + * (only available for returns, null for nacks) + */ + public String getReplyText() { + return replyText; + } + + /** + * @return the user-provided context object that was passed to basicPublishAsync, or null if none was provided + */ + public Object getContext() { + return context; + } +} diff --git a/src/main/java/com/rabbitmq/client/RateLimiter.java b/src/main/java/com/rabbitmq/client/RateLimiter.java new file mode 100644 index 0000000000..d751cc1699 --- /dev/null +++ b/src/main/java/com/rabbitmq/client/RateLimiter.java @@ -0,0 +1,67 @@ +// Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. +// +// This software, the RabbitMQ Java client library, is triple-licensed under the +// Mozilla Public License 2.0 ("MPL"), the GNU General Public License version 2 +// ("GPL") and the Apache License version 2 ("ASL"). For the MPL, please see +// LICENSE-MPL-RabbitMQ. For the GPL, please see LICENSE-GPL2. For the ASL, +// please see LICENSE-APACHE2. +// +// This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, +// either express or implied. See the LICENSE file for specific language governing +// rights and limitations of this software. +// +// If you have any questions regarding licensing, please contact us at +// info@rabbitmq.com. + +package com.rabbitmq.client; + +/** + * Interface for rate limiting publisher confirmations. + *

+ * Implementations control the rate of publish operations by acquiring permits + * before publishing and releasing them when confirmations are received. + *

+ * The library provides {@link ThrottlingRateLimiter} as a default implementation + * with progressive throttling behavior. Users can implement custom rate limiting + * strategies by implementing this interface. + * + * @see ThrottlingRateLimiter + */ +public interface RateLimiter +{ + /** + * Acquires a permit, blocking if necessary until one is available. + *

+ * Implementations may apply delays or other backpressure mechanisms + * before returning the permit. + * + * @return A permit that must be released when the operation completes + * @throws InterruptedException if the thread is interrupted while waiting + */ + Permit acquire() throws InterruptedException; + + /** + * Gets the maximum concurrency supported by this rate limiter. + *

+ * This is a hint for sizing internal data structures. Implementations + * that don't have a fixed capacity should return 0. + * + * @return the maximum number of concurrent operations, or 0 if unknown/unlimited + */ + default int getMaxConcurrency() + { + return 0; + } + + /** + * A permit that represents acquired access to a rate-limited resource. + * Must be released when the operation completes. + */ + interface Permit + { + /** + * Releases this permit, returning it to the rate limiter pool. + */ + void release(); + } +} diff --git a/src/main/java/com/rabbitmq/client/ThrottlingRateLimiter.java b/src/main/java/com/rabbitmq/client/ThrottlingRateLimiter.java new file mode 100644 index 0000000000..4773300d95 --- /dev/null +++ b/src/main/java/com/rabbitmq/client/ThrottlingRateLimiter.java @@ -0,0 +1,156 @@ +// Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. +// +// This software, the RabbitMQ Java client library, is triple-licensed under the +// Mozilla Public License 2.0 ("MPL"), the GNU General Public License version 2 +// ("GPL") and the Apache License version 2 ("ASL"). For the MPL, please see +// LICENSE-MPL-RabbitMQ. For the GPL, please see LICENSE-GPL2. For the ASL, +// please see LICENSE-APACHE2. +// +// This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, +// either express or implied. See the LICENSE file for specific language governing +// rights and limitations of this software. +// +// If you have any questions regarding licensing, please contact us at +// info@rabbitmq.com. + +package com.rabbitmq.client; + +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * A rate limiter that controls the rate of operations by limiting concurrency and applying delays + * when a specified threshold of concurrency usage is reached. + *

+ * The delay algorithm checks the current available permits. If the available permits are greater than or equal + * to the throttling threshold, no delay is applied. Otherwise, it calculates a delay based on the percentage + * of permits used, scaling it up to a maximum of 1000 milliseconds. + *

+ * This implementation matches the behavior of the .NET client's {@code ThrottlingRateLimiter}. + * + * @see RateLimiter + */ +public class ThrottlingRateLimiter implements RateLimiter +{ + + /** + * The default throttling percentage, which defines the threshold for applying throttling, set to 50%. + */ + public static final int DEFAULT_THROTTLING_PERCENTAGE = 50; + + private final int maxConcurrency; + private final int throttlingThreshold; + private final Semaphore semaphore; + private final AtomicInteger currentPermits; + + /** + * Initializes a new instance with the specified maximum number of concurrent calls + * and the default throttling percentage (50%). + * + * @param maxConcurrentCalls The maximum number of concurrent operations allowed + */ + public ThrottlingRateLimiter(int maxConcurrentCalls) { + this(maxConcurrentCalls, DEFAULT_THROTTLING_PERCENTAGE); + } + + /** + * Initializes a new instance with the specified maximum number of concurrent calls + * and throttling percentage. + * + * @param maxConcurrentCalls The maximum number of concurrent operations allowed + * @param throttlingPercentage The percentage of maxConcurrentCalls at which throttling is triggered + */ + public ThrottlingRateLimiter(int maxConcurrentCalls, int throttlingPercentage) { + if (maxConcurrentCalls <= 0) { + throw new IllegalArgumentException("maxConcurrentCalls must be positive"); + } + if (throttlingPercentage < 0 || throttlingPercentage > 100) { + throw new IllegalArgumentException("throttlingPercentage must be between 0 and 100"); + } + + this.maxConcurrency = maxConcurrentCalls; + this.throttlingThreshold = maxConcurrency * throttlingPercentage / 100; + this.semaphore = new Semaphore(maxConcurrentCalls, true); + this.currentPermits = new AtomicInteger(maxConcurrentCalls); + } + + /** + * Acquires a permit, blocking if necessary until one is available. + * Applies throttling delay if the number of available permits falls below the threshold. + * + * @return A permit that must be released when the operation completes + * @throws InterruptedException if the thread is interrupted while waiting + */ + public Permit acquire() throws InterruptedException { + int delay = calculateDelay(); + if (delay > 0) { + Thread.sleep(delay); + } + + semaphore.acquire(); + currentPermits.decrementAndGet(); + return new Permit(this); + } + + /** + * Releases a permit, returning it to the pool. + */ + private void release() { + currentPermits.incrementAndGet(); + semaphore.release(); + } + + /** + * Gets the current number of available permits. + * + * @return The number of permits currently available + */ + public int getAvailablePermits() { + return currentPermits.get(); + } + + /** + * Gets the maximum concurrency supported by this rate limiter. + * + * @return The maximum number of concurrent operations allowed + */ + @Override + public int getMaxConcurrency() { + return maxConcurrency; + } + + private int calculateDelay() { + int availablePermits = currentPermits.get(); + + if (availablePermits >= throttlingThreshold) { + // No delay - available permits exceed the threshold + return 0; + } + + double percentageUsed = 1.0 - (availablePermits / (double) maxConcurrency); + return (int)(percentageUsed * 1000); + } + + /** + * A permit that represents acquired access to a rate-limited resource. + * Must be released when the operation completes. + */ + public static class Permit implements RateLimiter.Permit { + private final ThrottlingRateLimiter limiter; + private boolean released = false; + + private Permit(ThrottlingRateLimiter limiter) { + this.limiter = limiter; + } + + /** + * Releases this permit, returning it to the rate limiter pool. + */ + public void release() { + if (!released) { + released = true; + limiter.release(); + } + } + } +} diff --git a/src/main/java/com/rabbitmq/client/impl/ChannelN.java b/src/main/java/com/rabbitmq/client/impl/ChannelN.java index ea2df2d717..ddf3c27ed3 100644 --- a/src/main/java/com/rabbitmq/client/impl/ChannelN.java +++ b/src/main/java/com/rabbitmq/client/impl/ChannelN.java @@ -118,6 +118,7 @@ public ChannelN(AMQConnection connection, int channelNumber, * @param channelNumber The channel number to be associated with this channel * @param workService service for managing this channel's consumer callbacks * @param metricsCollector service for managing metrics + * @param observationCollector service for managing observations */ public ChannelN(AMQConnection connection, int channelNumber, ConsumerWorkService workService, diff --git a/src/main/java/com/rabbitmq/client/impl/ConfirmationChannelN.java b/src/main/java/com/rabbitmq/client/impl/ConfirmationChannelN.java new file mode 100644 index 0000000000..c7f1b977cd --- /dev/null +++ b/src/main/java/com/rabbitmq/client/impl/ConfirmationChannelN.java @@ -0,0 +1,934 @@ +// Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. +// +// This software, the RabbitMQ Java client library, is triple-licensed under the +// Mozilla Public License 2.0 ("MPL"), the GNU General Public License version 2 +// ("GPL") and the Apache License version 2 ("ASL"). For the MPL, please see +// LICENSE-MPL-RabbitMQ. For the GPL, please see LICENSE-GPL2. For the ASL, +// please see LICENSE-APACHE2. +// +// This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, +// either express or implied. See the LICENSE file for specific language governing +// rights and limitations of this software. +// +// If you have any questions regarding licensing, please contact us at +// info@rabbitmq.com. + +package com.rabbitmq.client.impl; + +import com.rabbitmq.client.*; +import com.rabbitmq.client.AMQP.BasicProperties; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Implementation of {@link ConfirmationChannel} that wraps an existing {@link Channel} + * and provides asynchronous publisher confirmation tracking. + *

+ * This class maintains its own sequence number space independent of the wrapped channel. + * The {@link #basicPublish} methods are not supported and will throw + * {@link UnsupportedOperationException}. Use {@link #basicPublishAsync} instead. + */ +public class ConfirmationChannelN implements ConfirmationChannel +{ + private static final String SEQUENCE_NUMBER_HEADER = "x-seq-no"; + + private final Channel delegate; + private final RateLimiter rateLimiter; + private final AtomicLong nextSeqNo = new AtomicLong(1); + private final Map> confirmations; + + private final ShutdownListener shutdownListener; + private final ReturnListener returnListener; + private final ConfirmListener confirmListener; + + /** + * Creates a new ConfirmationChannelN wrapping the given channel. + * + * @param delegate the channel to wrap + * @param rateLimiter optional rate limiter for controlling publish concurrency (can be null) + * @throws IOException if enabling publisher confirmations fails + */ + public ConfirmationChannelN(Channel delegate, RateLimiter rateLimiter) throws IOException + { + if (delegate == null) + { + throw new IllegalArgumentException("delegate must be non-null"); + } + + this.delegate = delegate; + this.rateLimiter = rateLimiter; + + int initialCapacity = (rateLimiter != null) ? rateLimiter.getMaxConcurrency() : 16; + this.confirmations = new ConcurrentHashMap<>(initialCapacity > 0 ? initialCapacity : 16); + + // Enable publisher confirmations on the delegate channel + delegate.confirmSelect(); + + // Register listeners for confirmations and returns + // Store listener instances so we can remove them later + this.shutdownListener = this::handleShutdown; + this.returnListener = this::handleReturn; + this.confirmListener = delegate.addConfirmListener(this::handleAck, this::handleNack); + + delegate.addReturnListener(returnListener); + delegate.addShutdownListener(shutdownListener); + } + + @Override + public CompletableFuture basicPublishAsync(String exchange, String routingKey, + BasicProperties props, byte[] body, T context) + { + return basicPublishAsync(exchange, routingKey, false, props, body, context); + } + + @Override + public CompletableFuture basicPublishAsync(String exchange, String routingKey, + boolean mandatory, + BasicProperties props, byte[] body, T context) + { + CompletableFuture future = new CompletableFuture<>(); + RateLimiter.Permit permit = null; + long seqNo = 0; + + try + { + // Acquire rate limiter permit if configured + if (rateLimiter != null) + { + permit = rateLimiter.acquire(); + } + + // Get next sequence number + seqNo = nextSeqNo.getAndIncrement(); + + // Store confirmation entry + confirmations.put(seqNo, new ConfirmationEntry<>(future, permit, context)); + + // Add sequence number to message headers + if (props == null) + { + props = MessageProperties.MINIMAL_BASIC; + } + props = addSequenceNumberHeader(props, seqNo); + + // Publish to delegate channel + // Note: Metrics are collected by the delegate's MetricsCollector + delegate.basicPublish(exchange, routingKey, mandatory, props, body); + } + catch (IOException | AlreadyClosedException | InterruptedException e) + { + // Clean up on error + confirmations.remove(seqNo); + if (permit != null) + { + permit.release(); + } + future.completeExceptionally(e); + } + + return future; + } + + // Unsupported basicPublish methods - throw UnsupportedOperationException + + @Override + public void basicPublish(String exchange, String routingKey, BasicProperties props, byte[] body) + throws IOException + { + throw new UnsupportedOperationException( + "basicPublish() is not supported on ConfirmationChannel. Use basicPublishAsync() instead."); + } + + @Override + public void basicPublish(String exchange, String routingKey, boolean mandatory, BasicProperties props, byte[] body) + throws IOException + { + throw new UnsupportedOperationException( + "basicPublish() is not supported on ConfirmationChannel. Use basicPublishAsync() instead."); + } + + @Override + public void basicPublish(String exchange, String routingKey, boolean mandatory, boolean immediate, + BasicProperties props, byte[] body) + throws IOException + { + throw new UnsupportedOperationException( + "basicPublish() is not supported on ConfirmationChannel. Use basicPublishAsync() instead."); + } + + // Delegated Channel methods + + @Override + public int getChannelNumber() + { + return delegate.getChannelNumber(); + } + + @Override + public Connection getConnection() + { + return delegate.getConnection(); + } + + @Override + public void close() throws IOException, TimeoutException + { + delegate.close(); + removeListeners(); + } + + @Override + public void close(int closeCode, String closeMessage) throws IOException, TimeoutException + { + delegate.close(closeCode, closeMessage); + removeListeners(); + } + + @Override + public void abort() + { + delegate.abort(); + removeListeners(); + } + + @Override + public void abort(int closeCode, String closeMessage) + { + delegate.abort(closeCode, closeMessage); + removeListeners(); + } + + @Override + public void addReturnListener(ReturnListener listener) + { + delegate.addReturnListener(listener); + } + + @Override + public ReturnListener addReturnListener(ReturnCallback returnCallback) + { + return delegate.addReturnListener(returnCallback); + } + + @Override + public boolean removeReturnListener(ReturnListener listener) + { + return delegate.removeReturnListener(listener); + } + + @Override + public void clearReturnListeners() + { + delegate.clearReturnListeners(); + } + + @Override + public void addConfirmListener(ConfirmListener listener) + { + delegate.addConfirmListener(listener); + } + + @Override + public ConfirmListener addConfirmListener(ConfirmCallback ackCallback, ConfirmCallback nackCallback) + { + return delegate.addConfirmListener(ackCallback, nackCallback); + } + + @Override + public boolean removeConfirmListener(ConfirmListener listener) + { + return delegate.removeConfirmListener(listener); + } + + @Override + public void clearConfirmListeners() + { + delegate.clearConfirmListeners(); + } + + @Override + public Consumer getDefaultConsumer() + { + return delegate.getDefaultConsumer(); + } + + @Override + public void setDefaultConsumer(Consumer consumer) + { + delegate.setDefaultConsumer(consumer); + } + + @Override + public void basicQos(int prefetchSize, int prefetchCount, boolean global) throws IOException + { + delegate.basicQos(prefetchSize, prefetchCount, global); + } + + @Override + public void basicQos(int prefetchCount, boolean global) throws IOException + { + delegate.basicQos(prefetchCount, global); + } + + @Override + public void basicQos(int prefetchCount) throws IOException + { + delegate.basicQos(prefetchCount); + } + + @Override + public String basicConsume(String queue, Consumer callback) throws IOException + { + return delegate.basicConsume(queue, callback); + } + + @Override + public String basicConsume(String queue, DeliverCallback deliverCallback, CancelCallback cancelCallback) throws IOException + { + return delegate.basicConsume(queue, deliverCallback, cancelCallback); + } + + @Override + public String basicConsume(String queue, DeliverCallback deliverCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException + { + return delegate.basicConsume(queue, deliverCallback, shutdownSignalCallback); + } + + @Override + public String basicConsume(String queue, DeliverCallback deliverCallback, CancelCallback cancelCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException + { + return delegate.basicConsume(queue, deliverCallback, cancelCallback, shutdownSignalCallback); + } + + @Override + public String basicConsume(String queue, boolean autoAck, Consumer callback) throws IOException + { + return delegate.basicConsume(queue, autoAck, callback); + } + + @Override + public String basicConsume(String queue, boolean autoAck, DeliverCallback deliverCallback, CancelCallback cancelCallback) throws IOException + { + return delegate.basicConsume(queue, autoAck, deliverCallback, cancelCallback); + } + + @Override + public String basicConsume(String queue, boolean autoAck, DeliverCallback deliverCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException + { + return delegate.basicConsume(queue, autoAck, deliverCallback, shutdownSignalCallback); + } + + @Override + public String basicConsume(String queue, boolean autoAck, DeliverCallback deliverCallback, CancelCallback cancelCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException + { + return delegate.basicConsume(queue, autoAck, deliverCallback, cancelCallback, shutdownSignalCallback); + } + + @Override + public String basicConsume(String queue, boolean autoAck, Map arguments, Consumer callback) throws IOException + { + return delegate.basicConsume(queue, autoAck, arguments, callback); + } + + @Override + public String basicConsume(String queue, boolean autoAck, Map arguments, DeliverCallback deliverCallback, CancelCallback cancelCallback) throws IOException + { + return delegate.basicConsume(queue, autoAck, arguments, deliverCallback, cancelCallback); + } + + @Override + public String basicConsume(String queue, boolean autoAck, Map arguments, DeliverCallback deliverCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException + { + return delegate.basicConsume(queue, autoAck, arguments, deliverCallback, shutdownSignalCallback); + } + + @Override + public String basicConsume(String queue, boolean autoAck, Map arguments, DeliverCallback deliverCallback, CancelCallback cancelCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException + { + return delegate.basicConsume(queue, autoAck, arguments, deliverCallback, cancelCallback, shutdownSignalCallback); + } + + @Override + public String basicConsume(String queue, boolean autoAck, String consumerTag, Consumer callback) throws IOException + { + return delegate.basicConsume(queue, autoAck, consumerTag, callback); + } + + @Override + public String basicConsume(String queue, boolean autoAck, String consumerTag, DeliverCallback deliverCallback, CancelCallback cancelCallback) throws IOException + { + return delegate.basicConsume(queue, autoAck, consumerTag, deliverCallback, cancelCallback); + } + + @Override + public String basicConsume(String queue, boolean autoAck, String consumerTag, DeliverCallback deliverCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException + { + return delegate.basicConsume(queue, autoAck, consumerTag, deliverCallback, shutdownSignalCallback); + } + + @Override + public String basicConsume(String queue, boolean autoAck, String consumerTag, DeliverCallback deliverCallback, CancelCallback cancelCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException + { + return delegate.basicConsume(queue, autoAck, consumerTag, deliverCallback, cancelCallback, shutdownSignalCallback); + } + + @Override + public String basicConsume(String queue, boolean autoAck, String consumerTag, boolean noLocal, boolean exclusive, Map arguments, Consumer callback) throws IOException + { + return delegate.basicConsume(queue, autoAck, consumerTag, noLocal, exclusive, arguments, callback); + } + + @Override + public String basicConsume(String queue, boolean autoAck, String consumerTag, boolean noLocal, boolean exclusive, Map arguments, DeliverCallback deliverCallback, CancelCallback cancelCallback) throws IOException + { + return delegate.basicConsume(queue, autoAck, consumerTag, noLocal, exclusive, arguments, deliverCallback, cancelCallback); + } + + @Override + public String basicConsume(String queue, boolean autoAck, String consumerTag, boolean noLocal, boolean exclusive, Map arguments, DeliverCallback deliverCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException + { + return delegate.basicConsume(queue, autoAck, consumerTag, noLocal, exclusive, arguments, deliverCallback, shutdownSignalCallback); + } + + @Override + public String basicConsume(String queue, boolean autoAck, String consumerTag, boolean noLocal, boolean exclusive, Map arguments, DeliverCallback deliverCallback, CancelCallback cancelCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException + { + return delegate.basicConsume(queue, autoAck, consumerTag, noLocal, exclusive, arguments, deliverCallback, cancelCallback, shutdownSignalCallback); + } + + @Override + public void basicCancel(String consumerTag) throws IOException + { + delegate.basicCancel(consumerTag); + } + + @Override + public AMQP.Basic.RecoverOk basicRecover() throws IOException + { + return delegate.basicRecover(); + } + + @Override + public AMQP.Basic.RecoverOk basicRecover(boolean requeue) throws IOException + { + return delegate.basicRecover(requeue); + } + + @Override + public AMQP.Tx.SelectOk txSelect() throws IOException + { + return delegate.txSelect(); + } + + @Override + public AMQP.Tx.CommitOk txCommit() throws IOException + { + return delegate.txCommit(); + } + + @Override + public AMQP.Tx.RollbackOk txRollback() throws IOException + { + return delegate.txRollback(); + } + + @Override + public AMQP.Confirm.SelectOk confirmSelect() throws IOException + { + return delegate.confirmSelect(); + } + + @Override + public long getNextPublishSeqNo() + { + return delegate.getNextPublishSeqNo(); + } + + @Override + public boolean waitForConfirms() throws InterruptedException + { + throw new UnsupportedOperationException( + "waitForConfirms() is not supported on ConfirmationChannel. Use basicPublishAsync() instead."); + } + + @Override + public boolean waitForConfirms(long timeout) throws InterruptedException, TimeoutException + { + throw new UnsupportedOperationException( + "waitForConfirms() is not supported on ConfirmationChannel. Use basicPublishAsync() instead."); + } + + @Override + public void waitForConfirmsOrDie() throws IOException, InterruptedException + { + throw new UnsupportedOperationException( + "waitForConfirmsOrDie() is not supported on ConfirmationChannel. Use basicPublishAsync() instead."); + } + + @Override + public void waitForConfirmsOrDie(long timeout) throws IOException, InterruptedException, TimeoutException + { + throw new UnsupportedOperationException( + "waitForConfirmsOrDie() is not supported on ConfirmationChannel. Use basicPublishAsync() instead."); + } + + @Override + public void asyncRpc(com.rabbitmq.client.Method method) throws IOException + { + delegate.asyncRpc(method); + } + + @Override + public Command rpc(com.rabbitmq.client.Method method) throws IOException + { + return delegate.rpc(method); + } + + @Override + public long messageCount(String queue) throws IOException + { + return delegate.messageCount(queue); + } + + @Override + public long consumerCount(String queue) throws IOException + { + return delegate.consumerCount(queue); + } + + @Override + public CompletableFuture asyncCompletableRpc(com.rabbitmq.client.Method method) throws IOException + { + return delegate.asyncCompletableRpc(method); + } + + @Override + public void addShutdownListener(ShutdownListener listener) + { + delegate.addShutdownListener(listener); + } + + @Override + public void removeShutdownListener(ShutdownListener listener) + { + delegate.removeShutdownListener(listener); + } + + @Override + public ShutdownSignalException getCloseReason() + { + return delegate.getCloseReason(); + } + + @Override + public void notifyListeners() + { + delegate.notifyListeners(); + } + + @Override + public boolean isOpen() + { + return delegate.isOpen(); + } + + @Override + public AMQP.Exchange.DeclareOk exchangeDeclare(String exchange, String type) throws IOException + { + return delegate.exchangeDeclare(exchange, type); + } + + @Override + public AMQP.Exchange.DeclareOk exchangeDeclare(String exchange, BuiltinExchangeType type) throws IOException + { + return delegate.exchangeDeclare(exchange, type); + } + + @Override + public AMQP.Exchange.DeclareOk exchangeDeclare(String exchange, String type, boolean durable) throws IOException + { + return delegate.exchangeDeclare(exchange, type, durable); + } + + @Override + public AMQP.Exchange.DeclareOk exchangeDeclare(String exchange, BuiltinExchangeType type, boolean durable) throws IOException + { + return delegate.exchangeDeclare(exchange, type, durable); + } + + @Override + public AMQP.Exchange.DeclareOk exchangeDeclare(String exchange, String type, boolean durable, boolean autoDelete, Map arguments) throws IOException + { + return delegate.exchangeDeclare(exchange, type, durable, autoDelete, arguments); + } + + @Override + public AMQP.Exchange.DeclareOk exchangeDeclare(String exchange, BuiltinExchangeType type, boolean durable, boolean autoDelete, Map arguments) throws IOException + { + return delegate.exchangeDeclare(exchange, type, durable, autoDelete, arguments); + } + + @Override + public AMQP.Exchange.DeclareOk exchangeDeclare(String exchange, String type, boolean durable, boolean autoDelete, boolean internal, Map arguments) throws IOException + { + return delegate.exchangeDeclare(exchange, type, durable, autoDelete, internal, arguments); + } + + @Override + public AMQP.Exchange.DeclareOk exchangeDeclare(String exchange, BuiltinExchangeType type, boolean durable, boolean autoDelete, boolean internal, Map arguments) throws IOException + { + return delegate.exchangeDeclare(exchange, type, durable, autoDelete, internal, arguments); + } + + @Override + public void exchangeDeclareNoWait(String exchange, String type, boolean durable, boolean autoDelete, boolean internal, Map arguments) throws IOException + { + delegate.exchangeDeclareNoWait(exchange, type, durable, autoDelete, internal, arguments); + } + + @Override + public void exchangeDeclareNoWait(String exchange, BuiltinExchangeType type, boolean durable, boolean autoDelete, boolean internal, Map arguments) throws IOException + { + delegate.exchangeDeclareNoWait(exchange, type, durable, autoDelete, internal, arguments); + } + + @Override + public AMQP.Exchange.DeclareOk exchangeDeclarePassive(String exchange) throws IOException + { + return delegate.exchangeDeclarePassive(exchange); + } + + @Override + public AMQP.Exchange.DeleteOk exchangeDelete(String exchange, boolean ifUnused) throws IOException + { + return delegate.exchangeDelete(exchange, ifUnused); + } + + @Override + public void exchangeDeleteNoWait(String exchange, boolean ifUnused) throws IOException + { + delegate.exchangeDeleteNoWait(exchange, ifUnused); + } + + @Override + public AMQP.Exchange.DeleteOk exchangeDelete(String exchange) throws IOException + { + return delegate.exchangeDelete(exchange); + } + + @Override + public AMQP.Exchange.BindOk exchangeBind(String destination, String source, String routingKey) throws IOException + { + return delegate.exchangeBind(destination, source, routingKey); + } + + @Override + public AMQP.Exchange.BindOk exchangeBind(String destination, String source, String routingKey, Map arguments) throws IOException + { + return delegate.exchangeBind(destination, source, routingKey, arguments); + } + + @Override + public void exchangeBindNoWait(String destination, String source, String routingKey, Map arguments) throws IOException + { + delegate.exchangeBindNoWait(destination, source, routingKey, arguments); + } + + @Override + public AMQP.Exchange.UnbindOk exchangeUnbind(String destination, String source, String routingKey) throws IOException + { + return delegate.exchangeUnbind(destination, source, routingKey); + } + + @Override + public AMQP.Exchange.UnbindOk exchangeUnbind(String destination, String source, String routingKey, Map arguments) throws IOException + { + return delegate.exchangeUnbind(destination, source, routingKey, arguments); + } + + @Override + public void exchangeUnbindNoWait(String destination, String source, String routingKey, Map arguments) throws IOException + { + delegate.exchangeUnbindNoWait(destination, source, routingKey, arguments); + } + + @Override + public AMQP.Queue.DeclareOk queueDeclare() throws IOException + { + return delegate.queueDeclare(); + } + + @Override + public AMQP.Queue.DeclareOk queueDeclare(String queue, boolean durable, boolean exclusive, boolean autoDelete, Map arguments) throws IOException + { + return delegate.queueDeclare(queue, durable, exclusive, autoDelete, arguments); + } + + @Override + public void queueDeclareNoWait(String queue, boolean durable, boolean exclusive, boolean autoDelete, Map arguments) throws IOException + { + delegate.queueDeclareNoWait(queue, durable, exclusive, autoDelete, arguments); + } + + @Override + public AMQP.Queue.DeclareOk queueDeclarePassive(String queue) throws IOException + { + return delegate.queueDeclarePassive(queue); + } + + @Override + public AMQP.Queue.DeleteOk queueDelete(String queue) throws IOException + { + return delegate.queueDelete(queue); + } + + @Override + public AMQP.Queue.DeleteOk queueDelete(String queue, boolean ifUnused, boolean ifEmpty) throws IOException + { + return delegate.queueDelete(queue, ifUnused, ifEmpty); + } + + @Override + public void queueDeleteNoWait(String queue, boolean ifUnused, boolean ifEmpty) throws IOException + { + delegate.queueDeleteNoWait(queue, ifUnused, ifEmpty); + } + + @Override + public AMQP.Queue.BindOk queueBind(String queue, String exchange, String routingKey) throws IOException + { + return delegate.queueBind(queue, exchange, routingKey); + } + + @Override + public AMQP.Queue.BindOk queueBind(String queue, String exchange, String routingKey, Map arguments) throws IOException + { + return delegate.queueBind(queue, exchange, routingKey, arguments); + } + + @Override + public void queueBindNoWait(String queue, String exchange, String routingKey, Map arguments) throws IOException + { + delegate.queueBindNoWait(queue, exchange, routingKey, arguments); + } + + @Override + public AMQP.Queue.UnbindOk queueUnbind(String queue, String exchange, String routingKey) throws IOException + { + return delegate.queueUnbind(queue, exchange, routingKey); + } + + @Override + public AMQP.Queue.UnbindOk queueUnbind(String queue, String exchange, String routingKey, Map arguments) throws IOException + { + return delegate.queueUnbind(queue, exchange, routingKey, arguments); + } + + @Override + public AMQP.Queue.PurgeOk queuePurge(String queue) throws IOException + { + return delegate.queuePurge(queue); + } + + @Override + public GetResponse basicGet(String queue, boolean autoAck) throws IOException + { + return delegate.basicGet(queue, autoAck); + } + + @Override + public void basicAck(long deliveryTag, boolean multiple) throws IOException + { + delegate.basicAck(deliveryTag, multiple); + } + + @Override + public void basicNack(long deliveryTag, boolean multiple, boolean requeue) throws IOException + { + delegate.basicNack(deliveryTag, multiple, requeue); + } + + @Override + public void basicReject(long deliveryTag, boolean requeue) throws IOException + { + delegate.basicReject(deliveryTag, requeue); + } + + private void removeListeners() + { + delegate.removeShutdownListener(shutdownListener); + delegate.removeReturnListener(returnListener); + delegate.removeConfirmListener(confirmListener); + } + + private BasicProperties addSequenceNumberHeader(BasicProperties props, long seqNo) + { + Map headers = props.getHeaders(); + if (headers == null) + { + headers = new HashMap<>(); + } + else + { + headers = new HashMap<>(headers); + } + headers.put(SEQUENCE_NUMBER_HEADER, seqNo); + return props.builder().headers(headers).build(); + } + + private void handleAck(long seqNo, boolean multiple) + { + handleAckNack(seqNo, multiple, false); + } + + private void handleNack(long seqNo, boolean multiple) + { + handleAckNack(seqNo, multiple, true); + } + + private void handleAckNack(long seqNo, boolean multiple, boolean nack) + { + if (multiple) + { + for (Long seq : new ArrayList<>(confirmations.keySet())) + { + if (seq <= seqNo) + { + ConfirmationEntry entry = confirmations.remove(seq); + if (entry != null) + { + if (nack) + { + entry.completeExceptionally(seq); + } + else + { + entry.complete(); + } + entry.releasePermit(); + } + } + } + } + else + { + ConfirmationEntry entry = confirmations.remove(seqNo); + if (entry != null) + { + if (nack) + { + entry.completeExceptionally(seqNo); + } + else + { + entry.complete(); + } + entry.releasePermit(); + } + } + } + + private void handleReturn(int replyCode, String replyText, String exchange, + String routingKey, BasicProperties props, byte[] body) + { + Object seqNumObj = props.getHeaders().get(SEQUENCE_NUMBER_HEADER); + long seqNo = extractSequenceNumber(seqNumObj); + + ConfirmationEntry entry = confirmations.remove(seqNo); + if (entry != null) + { + entry.completeExceptionally(new PublishException(seqNo, true, + exchange, routingKey, replyCode, replyText, entry.context)); + entry.releasePermit(); + } + } + + private void handleShutdown(ShutdownSignalException cause) + { + AlreadyClosedException ex = new AlreadyClosedException(cause); + for (ConfirmationEntry entry : confirmations.values()) + { + entry.completeExceptionally(ex); + entry.releasePermit(); + } + confirmations.clear(); + } + + /** + * Extract sequence number from message header. + * NOTE: Since this library always writes the sequence number as a Long, + * the header value should always be a Long when read back from the broker. + * The additional type checks (Integer, String, byte[]) are defensive programming + * and should never be needed in practice. + */ + private long extractSequenceNumber(Object seqNumObj) + { + if (seqNumObj instanceof Long) + { + return (Long) seqNumObj; + } + else if (seqNumObj instanceof Integer) + { + return ((Integer) seqNumObj).longValue(); + } + else if (seqNumObj instanceof String) + { + return Long.parseLong((String) seqNumObj); + } + else if (seqNumObj instanceof byte[]) + { + return Long.parseLong(new String((byte[]) seqNumObj)); + } + return 0; + } + + private static class ConfirmationEntry + { + final CompletableFuture future; + final RateLimiter.Permit permit; + final T context; + + ConfirmationEntry(CompletableFuture future, RateLimiter.Permit permit, T context) + { + if (future == null) + { + throw new IllegalArgumentException("future must be non-null"); + } + this.future = future; + this.permit = permit; + this.context = context; + } + + void complete() + { + future.complete(context); + } + + void completeExceptionally(long seq) + { + PublishException ex = new PublishException(seq, this.context); + future.completeExceptionally(ex); + } + + void completeExceptionally(Exception e) + { + future.completeExceptionally(e); + } + + void releasePermit() + { + if (permit != null) + { + permit.release(); + } + } + } + +} diff --git a/src/main/java/com/rabbitmq/client/impl/recovery/RecoveryAwareChannelN.java b/src/main/java/com/rabbitmq/client/impl/recovery/RecoveryAwareChannelN.java index 87e90f5f1b..c238e804a6 100644 --- a/src/main/java/com/rabbitmq/client/impl/recovery/RecoveryAwareChannelN.java +++ b/src/main/java/com/rabbitmq/client/impl/recovery/RecoveryAwareChannelN.java @@ -70,6 +70,7 @@ public RecoveryAwareChannelN(AMQConnection connection, int channelNumber, Consum * @param channelNumber The channel number to be associated with this channel * @param workService service for managing this channel's consumer callbacks * @param metricsCollector service for managing metrics + * @param observationCollector service for managing observations */ public RecoveryAwareChannelN(AMQConnection connection, int channelNumber, ConsumerWorkService workService, MetricsCollector metricsCollector, ObservationCollector observationCollector) { diff --git a/src/test/java/com/rabbitmq/client/test/ThrottlingRateLimiterTest.java b/src/test/java/com/rabbitmq/client/test/ThrottlingRateLimiterTest.java new file mode 100644 index 0000000000..2433738a30 --- /dev/null +++ b/src/test/java/com/rabbitmq/client/test/ThrottlingRateLimiterTest.java @@ -0,0 +1,236 @@ +// Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. +// +// This software, the RabbitMQ Java client library, is triple-licensed under the +// Mozilla Public License 2.0 ("MPL"), the GNU General Public License version 2 +// ("GPL") and the Apache License version 2 ("ASL"). For the MPL, please see +// LICENSE-MPL-RabbitMQ. For the GPL, please see LICENSE-GPL2. For the ASL, +// please see LICENSE-APACHE2. +// +// This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, +// either express or implied. See the LICENSE file for specific language governing +// rights and limitations of this software. +// +// If you have any questions regarding licensing, please contact us at +// info@rabbitmq.com. + +package com.rabbitmq.client.test; + +import com.rabbitmq.client.ThrottlingRateLimiter; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +public class ThrottlingRateLimiterTest +{ + + @Test public void testPermitAcquireAndRelease() throws InterruptedException + { + ThrottlingRateLimiter limiter = new ThrottlingRateLimiter(10, 50); + + assertEquals(10, limiter.getAvailablePermits()); + + ThrottlingRateLimiter.Permit permit = limiter.acquire(); + assertEquals(9, limiter.getAvailablePermits()); + + permit.release(); + assertEquals(10, limiter.getAvailablePermits()); + } + + @Test public void testMultipleAcquireAndRelease() throws InterruptedException + { + ThrottlingRateLimiter limiter = new ThrottlingRateLimiter(10, 50); + + List permits = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + permits.add(limiter.acquire()); + } + + assertEquals(5, limiter.getAvailablePermits()); + + for (ThrottlingRateLimiter.Permit p : permits) { + p.release(); + } + + assertEquals(10, limiter.getAvailablePermits()); + } + + @Test public void testThrottlingOccursAboveThreshold() throws InterruptedException + { + ThrottlingRateLimiter limiter = new ThrottlingRateLimiter(10, 50); + + // Hold 6 permits (60% used, above 50% threshold) + List permits = new ArrayList<>(); + for (int i = 0; i < 6; i++) { + permits.add(limiter.acquire()); + } + + assertEquals(4, limiter.getAvailablePermits()); + + // Next acquire should throttle - verify with lenient timing + long start = System.currentTimeMillis(); + ThrottlingRateLimiter.Permit permit = limiter.acquire(); + long elapsed = System.currentTimeMillis() - start; + + assertTrue(elapsed > 10, "Should apply throttling delay, got " + elapsed + "ms"); + assertEquals(3, limiter.getAvailablePermits()); + + permit.release(); + for (ThrottlingRateLimiter.Permit p : permits) { + p.release(); + } + } + + @Test public void testHighThrottlingNearCapacity() throws InterruptedException + { + ThrottlingRateLimiter limiter = new ThrottlingRateLimiter(10, 50); + + // Hold 9 permits (90% used) + List permits = new ArrayList<>(); + for (int i = 0; i < 9; i++) { + permits.add(limiter.acquire()); + } + + assertEquals(1, limiter.getAvailablePermits()); + + // Should have significant delay + long start = System.currentTimeMillis(); + ThrottlingRateLimiter.Permit permit = limiter.acquire(); + long elapsed = System.currentTimeMillis() - start; + + assertTrue(elapsed > 500, "Should have significant delay near capacity, got " + elapsed + "ms"); + assertEquals(0, limiter.getAvailablePermits()); + + permit.release(); + for (ThrottlingRateLimiter.Permit p : permits) { + p.release(); + } + } + + @Test public void testThrottlingThresholds() throws InterruptedException + { + // 80% threshold - more permissive + ThrottlingRateLimiter limiter80 = new ThrottlingRateLimiter(10, 80); + + ThrottlingRateLimiter.Permit permit1 = limiter80.acquire(); + assertEquals(9, limiter80.getAvailablePermits()); + + ThrottlingRateLimiter.Permit permit2 = limiter80.acquire(); + assertEquals(8, limiter80.getAvailablePermits()); + + permit1.release(); + permit2.release(); + + // 20% threshold - more restrictive + ThrottlingRateLimiter limiter20 = new ThrottlingRateLimiter(10, 20); + + // Hold 9 permits (90% used, well above 20% threshold) + List permits = new ArrayList<>(); + for (int i = 0; i < 9; i++) { + permits.add(limiter20.acquire()); + } + + assertEquals(1, limiter20.getAvailablePermits()); + + // Should throttle + long start = System.currentTimeMillis(); + ThrottlingRateLimiter.Permit permit = limiter20.acquire(); + long elapsed = System.currentTimeMillis() - start; + + assertTrue(elapsed > 10, "Should throttle above 20% threshold, got " + elapsed + "ms"); + + permit.release(); + for (ThrottlingRateLimiter.Permit p : permits) { + p.release(); + } + } + + @Test public void testConcurrentAcquireAndRelease() throws InterruptedException, ExecutionException + { + ThrottlingRateLimiter limiter = new ThrottlingRateLimiter(50, 50); + ExecutorService executor = Executors.newFixedThreadPool(10); + AtomicInteger successCount = new AtomicInteger(0); + + List> futures = new ArrayList<>(); + for (int i = 0; i < 100; i++) { + futures.add(executor.submit(() -> { + try { + ThrottlingRateLimiter.Permit permit = limiter.acquire(); + successCount.incrementAndGet(); + Thread.sleep(10); + permit.release(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + })); + } + + for (Future future : futures) { + future.get(); + } + + executor.shutdown(); + assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)); + assertEquals(100, successCount.get()); + assertEquals(50, limiter.getAvailablePermits()); + } + + @Test public void testZeroThresholdBehavior() throws InterruptedException + { + ThrottlingRateLimiter limiter = new ThrottlingRateLimiter(10, 0); + + // 0% threshold means threshold = 0 + // Available = 10, threshold = 0, so 10 >= 0 = no throttle + ThrottlingRateLimiter.Permit permit = limiter.acquire(); + assertEquals(9, limiter.getAvailablePermits()); + permit.release(); + } + + @Test public void testHundredPercentThreshold() throws InterruptedException + { + ThrottlingRateLimiter limiter = new ThrottlingRateLimiter(10, 100); + + // 100% threshold means threshold = 10 + // Hold 9 permits: available = 1, threshold = 10, so 1 < 10 = throttle + List permits = new ArrayList<>(); + for (int i = 0; i < 9; i++) { + permits.add(limiter.acquire()); + } + + assertEquals(1, limiter.getAvailablePermits()); + + long start = System.currentTimeMillis(); + ThrottlingRateLimiter.Permit permit = limiter.acquire(); + long elapsed = System.currentTimeMillis() - start; + + assertTrue(elapsed > 500, "Should throttle with 100% threshold near capacity, got " + elapsed + "ms"); + + permit.release(); + for (ThrottlingRateLimiter.Permit p : permits) { + p.release(); + } + } + + @Test public void testGetStatistics() throws InterruptedException + { + ThrottlingRateLimiter limiter = new ThrottlingRateLimiter(10, 50); + + assertEquals(10, limiter.getAvailablePermits()); + + ThrottlingRateLimiter.Permit p1 = limiter.acquire(); + assertEquals(9, limiter.getAvailablePermits()); + + ThrottlingRateLimiter.Permit p2 = limiter.acquire(); + assertEquals(8, limiter.getAvailablePermits()); + + p1.release(); + assertEquals(9, limiter.getAvailablePermits()); + + p2.release(); + assertEquals(10, limiter.getAvailablePermits()); + } +} diff --git a/src/test/java/com/rabbitmq/client/test/functional/Confirm.java b/src/test/java/com/rabbitmq/client/test/functional/Confirm.java index 62392d6da9..9d956a234d 100644 --- a/src/test/java/com/rabbitmq/client/test/functional/Confirm.java +++ b/src/test/java/com/rabbitmq/client/test/functional/Confirm.java @@ -23,11 +23,14 @@ import com.rabbitmq.client.AMQP; +import com.rabbitmq.client.AlreadyClosedException; import com.rabbitmq.client.Channel; +import com.rabbitmq.client.ConfirmationChannel; import com.rabbitmq.client.ConfirmListener; import com.rabbitmq.client.DefaultConsumer; import com.rabbitmq.client.GetResponse; import com.rabbitmq.client.MessageProperties; +import com.rabbitmq.client.PublishException; import com.rabbitmq.client.ShutdownSignalException; import com.rabbitmq.client.test.BrokerTestCase; @@ -316,4 +319,269 @@ protected void publish(String exchangeName, String queueName, : MessageProperties.BASIC, "nop".getBytes()); } + + /** + * Tests basic publisher confirmation tracking with context parameter. + * Verifies that futures complete successfully with their context values when messages are confirmed. + */ + @Test public void testBasicPublishAsync() throws Exception { + Channel ch = connection.createChannel(); + ConfirmationChannel confirmCh = ConfirmationChannel.create(ch, null); + String queue = confirmCh.queueDeclare().getQueue(); + + int messageCount = 100; + java.util.List> futures = new java.util.ArrayList<>(); + + for (int i = 0; i < messageCount; i++) { + futures.add(confirmCh.basicPublishAsync("", queue, null, ("msg" + i).getBytes(), i)); + } + + // Verify all futures complete with their context values + for (int i = 0; i < messageCount; i++) { + assertEquals(Integer.valueOf(i), futures.get(i).join()); + } + + assertEquals(messageCount, confirmCh.messageCount(queue)); + confirmCh.close(); + } + + /** + * Tests that unroutable messages with mandatory flag cause futures to complete exceptionally. + * Verifies PublishException contains correct return information (isReturn=true, replyCode=NO_ROUTE). + */ + @Test public void testBasicPublishAsyncWithReturn() throws Exception { + Channel ch = connection.createChannel(); + ConfirmationChannel confirmCh = ConfirmationChannel.create(ch, null); + + java.util.concurrent.CompletableFuture future = confirmCh.basicPublishAsync( + "", "nonexistent-queue", true, null, "test".getBytes(), null + ); + + try { + future.join(); + fail("Expected PublishException"); + } catch (java.util.concurrent.CompletionException e) { + assertTrue(e.getCause() instanceof PublishException); + PublishException pe = (PublishException) e.getCause(); + assertTrue(pe.isReturn()); + assertEquals(AMQP.NO_ROUTE, pe.getReplyCode().intValue()); + } + + confirmCh.close(); + } + + /** + * Tests rate limiting with ThrottlingRateLimiter. + * Verifies that messages are throttled to max 10 concurrent in-flight messages + * and all futures complete with correct context values. + */ + @Test public void testMaxOutstandingConfirms() throws Exception { + com.rabbitmq.client.ThrottlingRateLimiter limiter = + new com.rabbitmq.client.ThrottlingRateLimiter(10, 50); + Channel ch = connection.createChannel(); + ConfirmationChannel confirmCh = ConfirmationChannel.create(ch, limiter); + String queue = confirmCh.queueDeclare().getQueue(); + + java.util.concurrent.atomic.AtomicInteger completed = new java.util.concurrent.atomic.AtomicInteger(0); + java.util.List> futures = new java.util.ArrayList<>(); + + for (int i = 0; i < 50; i++) { + final int msgNum = i; + java.util.concurrent.CompletableFuture future = confirmCh.basicPublishAsync( + "", queue, null, ("msg" + i).getBytes(), i + ); + future.thenAccept(ctx -> { + assertEquals(Integer.valueOf(msgNum), ctx); + completed.incrementAndGet(); + }); + futures.add(future); + } + + java.util.concurrent.CompletableFuture.allOf(futures.toArray(new java.util.concurrent.CompletableFuture[0])).join(); + assertEquals(50, completed.get()); + confirmCh.close(); + } + + /** + * Tests that closing a channel with pending confirmations causes all futures to complete exceptionally. + * Verifies that AlreadyClosedException is thrown for in-flight messages when channel closes. + */ + @Test public void testBasicPublishAsyncChannelClose() throws Exception { + Channel ch = connection.createChannel(); + ConfirmationChannel confirmCh = ConfirmationChannel.create(ch, null); + String queue = confirmCh.queueDeclare().getQueue(); + + java.util.List> futures = new java.util.ArrayList<>(); + for (int i = 0; i < 10; i++) { + futures.add(confirmCh.basicPublishAsync("", queue, null, ("msg" + i).getBytes(), null)); + } + + confirmCh.close(); + + for (java.util.concurrent.CompletableFuture future : futures) { + try { + future.join(); + } catch (java.util.concurrent.CompletionException e) { + assertTrue(e.getCause() instanceof AlreadyClosedException); + } + } + } + + /** + * Tests that rate limiting introduces delay and all messages complete with correct context. + * Verifies ThrottlingRateLimiter limits concurrent in-flight messages and elapsed time is non-zero. + */ + @Test public void testBasicPublishAsyncWithThrottling() throws Exception { + com.rabbitmq.client.ThrottlingRateLimiter limiter = + new com.rabbitmq.client.ThrottlingRateLimiter(10, 50); + Channel ch = connection.createChannel(); + ConfirmationChannel confirmCh = ConfirmationChannel.create(ch, limiter); + String queue = confirmCh.queueDeclare().getQueue(); + + int messageCount = 50; + java.util.List> futures = new java.util.ArrayList<>(); + + long start = System.currentTimeMillis(); + for (int i = 0; i < messageCount; i++) { + String msgId = "msg-" + i; + futures.add(confirmCh.basicPublishAsync("", queue, null, ("message" + i).getBytes(), msgId)); + } + + // Verify all complete with correct context + for (int i = 0; i < messageCount; i++) { + assertEquals("msg-" + i, futures.get(i).join()); + } + + long elapsed = System.currentTimeMillis() - start; + + assertEquals(messageCount, confirmCh.messageCount(queue)); + assertTrue(elapsed > 0, "Throttling should introduce some delay"); + confirmCh.close(); + } + + /** + * Tests performance comparison between throttled and unlimited channels. + * Verifies both complete successfully and throttling introduces measurable delay. + */ + @Test public void testBasicPublishAsyncThrottlingVsUnlimited() throws Exception { + // Test with throttling (10 permits, 50% threshold) + com.rabbitmq.client.ThrottlingRateLimiter limiter = + new com.rabbitmq.client.ThrottlingRateLimiter(10, 50); + Channel throttlingCh = connection.createChannel(); + ConfirmationChannel throttlingConfirmCh = ConfirmationChannel.create(throttlingCh, limiter); + String queue1 = throttlingConfirmCh.queueDeclare().getQueue(); + + int messageCount = 4096; + java.util.List> futures = new java.util.ArrayList<>(); + + long start = System.currentTimeMillis(); + for (int i = 0; i < messageCount; i++) { + futures.add(throttlingConfirmCh.basicPublishAsync("", queue1, null, ("msg" + i).getBytes(), null)); + } + java.util.concurrent.CompletableFuture.allOf(futures.toArray(new java.util.concurrent.CompletableFuture[0])).join(); + long throttlingElapsed = System.currentTimeMillis() - start; + + assertEquals(messageCount, throttlingConfirmCh.messageCount(queue1)); + throttlingConfirmCh.close(); + + // Test with unlimited (no rate limiter) + Channel unlimitedCh = connection.createChannel(); + ConfirmationChannel unlimitedConfirmCh = ConfirmationChannel.create(unlimitedCh, null); + String queue2 = unlimitedConfirmCh.queueDeclare().getQueue(); + + futures.clear(); + start = System.currentTimeMillis(); + for (int i = 0; i < messageCount; i++) { + futures.add(unlimitedConfirmCh.basicPublishAsync("", queue2, null, ("msg" + i).getBytes(), null)); + } + java.util.concurrent.CompletableFuture.allOf(futures.toArray(new java.util.concurrent.CompletableFuture[0])).join(); + long unlimitedElapsed = System.currentTimeMillis() - start; + + assertEquals(messageCount, unlimitedConfirmCh.messageCount(queue2)); + unlimitedConfirmCh.close(); + + // Both should complete successfully + assertTrue(throttlingElapsed > 0); + assertTrue(unlimitedElapsed > 0); + } + + /** + * Tests that ConfirmationChannel works correctly without a rate limiter. + * Verifies all messages are confirmed when rateLimiter is null (unlimited concurrency). + */ + @Test public void testBasicPublishAsyncWithNullRateLimiter() throws Exception { + Channel ch = connection.createChannel(); + ConfirmationChannel confirmCh = ConfirmationChannel.create(ch, null); + String queue = confirmCh.queueDeclare().getQueue(); + + int messageCount = 100; + java.util.List> futures = new java.util.ArrayList<>(); + + for (int i = 0; i < messageCount; i++) { + futures.add(confirmCh.basicPublishAsync("", queue, null, ("msg" + i).getBytes(), null)); + } + + java.util.concurrent.CompletableFuture.allOf(futures.toArray(new java.util.concurrent.CompletableFuture[0])).join(); + + assertEquals(messageCount, confirmCh.messageCount(queue)); + confirmCh.close(); + } + + /** + * Tests context parameter correlation with String correlation IDs. + * Verifies that each future completes with its exact correlation ID for message tracking. + */ + @Test public void testBasicPublishAsyncWithContext() throws Exception { + Channel ch = connection.createChannel(); + ConfirmationChannel confirmCh = ConfirmationChannel.create(ch, null); + String queue = confirmCh.queueDeclare().getQueue(); + + int messageCount = 10; + java.util.Map> futuresByCorrelationId = new java.util.HashMap<>(); + + for (int i = 0; i < messageCount; i++) { + String correlationId = "msg-" + i; + java.util.concurrent.CompletableFuture future = confirmCh.basicPublishAsync( + "", queue, null, ("message" + i).getBytes(), correlationId + ); + futuresByCorrelationId.put(correlationId, future); + } + + // Verify all futures complete with their correlation IDs + for (java.util.Map.Entry> entry : futuresByCorrelationId.entrySet()) { + String expectedId = entry.getKey(); + String actualId = entry.getValue().join(); + assertEquals(expectedId, actualId); + } + + assertEquals(messageCount, confirmCh.messageCount(queue)); + confirmCh.close(); + } + + /** + * Tests that context parameter is available in PublishException for failed publishes. + * Verifies context is preserved when message is returned as unroutable. + */ + @Test public void testBasicPublishAsyncWithContextInException() throws Exception { + Channel ch = connection.createChannel(); + ConfirmationChannel confirmCh = ConfirmationChannel.create(ch, null); + + String messageId = "unroutable-msg-456"; + java.util.concurrent.CompletableFuture future = confirmCh.basicPublishAsync( + "", "nonexistent-queue", true, null, "test".getBytes(), messageId + ); + + try { + future.join(); + fail("Expected PublishException"); + } catch (java.util.concurrent.CompletionException e) { + assertTrue(e.getCause() instanceof PublishException); + PublishException pe = (PublishException) e.getCause(); + assertTrue(pe.isReturn()); + assertEquals(AMQP.NO_ROUTE, pe.getReplyCode().intValue()); + assertEquals(messageId, pe.getContext()); + } + + confirmCh.close(); + } } From 31352877e1b895db846488f8e22894410fcdfe78 Mon Sep 17 00:00:00 2001 From: Luke Bakken Date: Sun, 12 Jul 2026 13:25:37 -0700 Subject: [PATCH 2/3] Reimplement async publisher confirmations as ConfirmationPublisher Replace the ConfirmationChannel design, which extended Channel and kept a shadow AtomicLong sequence counter parallel to the broker's delivery-tag space, with a standalone ConfirmationPublisher that owns a private channel and keys its outstanding map by the channel's own getNextPublishSeqNo() read under a lock. This removes the counter-desync bug class at its root without modifying any Channel or ChannelN class. Correctness properties: - A connection-level publish failure (IOException) is left to the natural shutdown and recovery path rather than aborting the channel, so a single transient failure no longer unregisters an autorecovering channel from recovery. Only a channel left genuinely stuck open with an advanced counter is aborted. - Publishing while an autorecovering channel is mid-recovery (sequence number 0, confirm mode not yet re-established) fails the future instead of sending an untracked message that would never be confirmed. - A RecoveryListener fails all outstanding futures at the start of recovery, before confirm.select resets the delivery-tag sequence, so a reused tag cannot mis-correlate against a stale entry. - Completions fall back to the calling thread if the configured executor rejects them, so a saturated or shut-down user executor never leaves a future hanging. PublishException now carries a nullable Return for the basic.return case instead of duplicating the exchange, routing key, reply code and reply text fields. --- RUNNING_TESTS.md | 2 +- doc/publisher-confirmations/README.md | 410 +++----- .../rabbitmq/client/ConfirmationChannel.java | 149 --- .../client/ConfirmationPublisher.java | 459 +++++++++ .../com/rabbitmq/client/PublishException.java | 130 +-- .../java/com/rabbitmq/client/RateLimiter.java | 67 -- .../client/ThrottlingRateLimiter.java | 156 --- .../client/impl/ConfirmationChannelN.java | 934 ------------------ .../test/ThrottlingRateLimiterTest.java | 236 ----- .../client/test/functional/Confirm.java | 291 ++---- 10 files changed, 722 insertions(+), 2112 deletions(-) delete mode 100644 src/main/java/com/rabbitmq/client/ConfirmationChannel.java create mode 100644 src/main/java/com/rabbitmq/client/ConfirmationPublisher.java delete mode 100644 src/main/java/com/rabbitmq/client/RateLimiter.java delete mode 100644 src/main/java/com/rabbitmq/client/ThrottlingRateLimiter.java delete mode 100644 src/main/java/com/rabbitmq/client/impl/ConfirmationChannelN.java delete mode 100644 src/test/java/com/rabbitmq/client/test/ThrottlingRateLimiterTest.java diff --git a/RUNNING_TESTS.md b/RUNNING_TESTS.md index 21a45046db..c57c8f702a 100644 --- a/RUNNING_TESTS.md +++ b/RUNNING_TESTS.md @@ -97,7 +97,7 @@ top-level directory of the source tree: * To run a specific test method within a test class: ``` -./mvnw verify -Dit.test=Confirm#testBasicPublishAsync +./mvnw verify -Dit.test=Confirm#publisherBasicConfirmation ``` Test reports can be found in `target/failsafe-reports`. diff --git a/doc/publisher-confirmations/README.md b/doc/publisher-confirmations/README.md index 837ffb5453..6acb08c905 100644 --- a/doc/publisher-confirmations/README.md +++ b/doc/publisher-confirmations/README.md @@ -1,358 +1,212 @@ -# ConfirmationChannel - Asynchronous Publisher Confirmations - -**Status:** Complete -**Date:** 2025-12-10 +# ConfirmationPublisher - Asynchronous Publisher Confirmations ## Overview -`ConfirmationChannel` provides asynchronous publisher confirmation tracking with a `CompletableFuture`-based API, optional rate limiting, and generic context parameter for message correlation. The implementation wraps existing `Channel` instances using listener-based integration, requiring no modifications to the core `ChannelN` class. +`ConfirmationPublisher` provides asynchronous publisher confirmation tracking +with a `CompletableFuture`-based API, optional bounded-outstanding backpressure, +and a generic context parameter for message correlation. It owns a dedicated +channel created from the supplied `Connection`, requiring no modifications to +the existing `Channel` or `ChannelN` classes. ## Motivation -Traditional publisher confirms in the Java client require manual tracking of sequence numbers and correlation of Basic.Return messages. This makes per-message error handling complex and provides no built-in async pattern, backpressure mechanism, or message correlation support. +Traditional publisher confirms in the Java client require manual tracking of +sequence numbers and correlation of `basic.return` messages. This makes +per-message error handling complex and provides no built-in async pattern, +backpressure mechanism, or message correlation support. + +`ConfirmationPublisher` addresses these limitations: -`ConfirmationChannel` addresses these limitations by providing: - Automatic confirmation tracking via `CompletableFuture` API - Generic context parameter for message correlation -- Optional rate limiting for backpressure control -- Clean separation from core `Channel` implementation +- Optional bounded-outstanding-confirms backpressure (simple `Semaphore`) +- Futures completed on a configurable executor, never on the I/O thread +- Clean recovery semantics: outstanding futures fail, then publishing resumes ## Architecture -### Interface Hierarchy - ``` -Channel (existing interface) - ↑ - | -ConfirmationChannel (new interface) - ↑ +Connection | -ConfirmationChannelN (new implementation) + +-- createChannel() (private to the publisher) + | + ConfirmationPublisher + - ConcurrentSkipListMap + - publishLock (serializes seqNo read + publish) + - Semaphore (optional backpressure) + - Confirm/Return/Shutdown listeners ``` -### Key Components - -**ConfirmationChannel Interface** -- Extends `Channel` interface -- Adds `basicPublishAsync()` methods (with and without mandatory flag) -- Generic `` context parameter for correlation -- Returns `CompletableFuture` - -**ConfirmationChannelN Implementation** -- Wraps an existing `Channel` instance (composition, not inheritance) -- Maintains its own sequence number counter (`AtomicLong`) -- Registers return and confirm listeners on the wrapped channel -- Delegates all other `Channel` methods to the wrapped instance -- Throws `UnsupportedOperationException` for `basicPublish()` methods - -### Sequence Number Management - -**Independent Sequence Space:** -- `ConfirmationChannelN` maintains its own `AtomicLong nextSeqNo` -- No coordination with `ChannelN`'s sequence numbers -- Sequence numbers start at 1 and increment for each `basicPublishAsync()` call -- Sequence number added to message headers as `x-seq-no` - -**Why Independent?** -- `basicPublish()` is disallowed on `ConfirmationChannel` -- No risk of sequence number conflicts -- Simpler implementation - no need to access `ChannelN` internals -- Clean separation of concerns +The publisher keys its outstanding map by `channel.getNextPublishSeqNo()`, +read atomically with the publish under a `ReentrantLock`. Because the channel +is private, no external publish can desynchronize the counter. This guarantees +that the map key is the broker's delivery tag and correlation is correct. ### Confirmation Tracking -**State Management:** -```java -private final ConcurrentHashMap> confirmations; - -private static class ConfirmationEntry { - final CompletableFuture future; - final RateLimiter.Permit permit; - final T context; -} -``` - **Lifecycle:** -1. `basicPublishAsync()` called -2. Acquire rate limiter permit (if configured) -3. Get next sequence number -4. Create `CompletableFuture` and `ConfirmationEntry` -5. Add `x-seq-no` header to message -6. Store entry in `confirmations` map -7. Call `delegate.basicPublish()` -8. Return future to caller - -**Completion Paths:** -- **Basic.Ack** → Complete future with context value, release permit -- **Basic.Nack** → Complete exceptionally with `PublishException`, release permit -- **Basic.Return** → Complete exceptionally with `PublishException`, release permit -- **Channel close** → Complete all pending futures exceptionally, release all permits - -### Listener Integration - -**Return Listener:** -```java -delegate.addReturnListener((replyCode, replyText, exchange, routingKey, props, body) -> { - long seqNo = extractSequenceNumber(props.getHeaders()); - ConfirmationEntry entry = confirmations.remove(seqNo); - if (entry != null) { - entry.future.completeExceptionally( - new PublishException(seqNo, true, exchange, routingKey, replyCode, replyText, entry.context) - ); - entry.releasePermit(); - } -}); -``` -**Confirm Listeners:** -```java -delegate.addConfirmListener( - (seqNo, multiple) -> handleAck(seqNo, multiple), - (seqNo, multiple) -> handleNack(seqNo, multiple) -); -``` +1. Acquire a semaphore permit (if bounded) +2. Lock, read `channel.getNextPublishSeqNo()`, insert entry, publish, unlock +3. If publish fails, remove entry, release permit, abort channel if tag-space + skewed +4. Broker sends `basic.ack`/`basic.nack`/`basic.return` +5. Entry claimed via `ConcurrentSkipListMap.remove()`, permit released via + `AtomicBoolean.compareAndSet`, future completed on the configured executor + +**Multiple-ack handling:** -**Multiple Acknowledgments:** -When `multiple=true`, all sequence numbers ≤ `seqNo` are processed: ```java -for (Long seq : new ArrayList<>(confirmations.keySet())) { - if (seq <= seqNo) { - ConfirmationEntry entry = confirmations.remove(seq); - // Complete future and release permit - } +NavigableMap confirmed = + outstanding.headMap(deliveryTag, true); +for (Long seqNo : confirmed.keySet()) { + completeOne(seqNo, nack); } ``` -## API Design - -### Constructor +This is O(k log n) on the confirmed entries, matching `ChannelN`'s own +`unconfirmedSet.headSet(seqNo + 1).clear()` pattern. -```java -public ConfirmationChannelN(Channel delegate, RateLimiter rateLimiter) -``` +### Return Correlation -**Parameters:** -- `delegate` - The underlying `Channel` instance (typically `ChannelN`) -- `rateLimiter` - Optional rate limiter for controlling publish concurrency (can be null) +A `basic.return` does not carry a delivery tag, so the publisher injects an +`x-seq-no` header **only when `mandatory = true`** (returns are impossible +otherwise). On the return path, the header is read back to find the +corresponding entry. Non-mandatory publishes avoid the header and the +`BasicProperties` copy entirely. -**Initialization:** -- Calls `delegate.confirmSelect()` to enable publisher confirmations -- Registers return and confirm listeners -- Initializes confirmation tracking map +The broker echoes this header on normal delivery as well as on return, so when +a mandatory message is routable its consumers receive it with the `x-seq-no` +header present; it cannot be stripped on the routable path. Applications that +must not expose the header should publish such messages through their own +channel rather than this publisher. -### basicPublishAsync Methods +The publisher rejects publishes where the caller already set an `x-seq-no` +header to prevent silent clobbering. -```java - CompletableFuture basicPublishAsync(String exchange, String routingKey, - AMQP.BasicProperties props, byte[] body, T context) +### Connection Recovery - CompletableFuture basicPublishAsync(String exchange, String routingKey, - boolean mandatory, - AMQP.BasicProperties props, byte[] body, T context) -``` +When the wrapped channel is an `AutorecoveringChannel`, connection loss +triggers the shutdown listener, which fails all outstanding futures +exceptionally (their outcome is unknown). After recovery the channel's +`getNextPublishSeqNo()` returns the reset counter (verified experimentally), +so new publishes key correctly with no reset hook needed. -**Context Parameter:** -- Generic type `` allows any user-defined correlation object -- Returned in the completed future on success -- Available in `PublishException.getContext()` on failure -- Can be null if correlation not needed +## API -**Return Value:** -- `CompletableFuture` that completes when broker confirms/rejects -- Completes successfully with context value on Basic.Ack -- Completes exceptionally with `PublishException` on Basic.Nack or Basic.Return +### Creating a Publisher -### basicPublish Methods (Disallowed) +```java +// Unlimited outstanding +ConfirmationPublisher publisher = ConfirmationPublisher.create(connection); -All `basicPublish()` method overloads throw `UnsupportedOperationException`: +// At most 100 unconfirmed messages; basicPublishAsync blocks at the limit +ConfirmationPublisher publisher = ConfirmationPublisher.create(connection, 100); -```java -@Override -public void basicPublish(String exchange, String routingKey, - AMQP.BasicProperties props, byte[] body) { - throw new UnsupportedOperationException( - "basicPublish() is not supported on ConfirmationChannel. Use basicPublishAsync() instead." - ); -} +// Custom executor for future completion +ConfirmationPublisher publisher = ConfirmationPublisher.create(connection, 100, myExecutor); ``` -**Rationale:** -- Prevents mixing synchronous and asynchronous publish patterns -- Eliminates sequence number coordination complexity -- Clear API contract - this channel is for async confirmations only +### Publishing -### Delegated Methods +```java + CompletableFuture basicPublishAsync(String exchange, String routingKey, + AMQP.BasicProperties props, byte[] body, T context) -All other `Channel` methods are delegated to the wrapped instance: -- `basicConsume()`, `basicGet()`, `basicAck()`, etc. -- `exchangeDeclare()`, `queueDeclare()`, etc. -- `addReturnListener()`, `addConfirmListener()`, etc. -- `close()`, `abort()`, etc. + CompletableFuture basicPublishAsync(String exchange, String routingKey, + boolean mandatory, AMQP.BasicProperties props, byte[] body, T context) +``` -## Rate Limiting +The context parameter is returned in the completed future on success and +available via `PublishException.getContext()` on failure. -**Optional Feature:** -- Pass `RateLimiter` to constructor to enable -- Limits concurrent in-flight messages -- Blocks in `basicPublishAsync()` until permit available -- Permits released when confirmation received (ack/nack/return) +### Closing -**Integration:** ```java -RateLimiter.Permit permit = null; -if (rateLimiter != null) { - permit = rateLimiter.acquire(); // May block -} -// ... publish message ... -// Store permit in ConfirmationEntry for later release +publisher.close(); ``` +Outstanding futures complete exceptionally with `ShutdownSignalException`. + ## Error Handling ### PublishException -Enhanced with context parameter: ```java public class PublishException extends IOException { - private final Object context; // User-provided correlation object - - // Constructor for nacks (no routing details available) - public PublishException(long sequenceNumber, Object context) - - // Constructor for returns (full routing details) - public PublishException(long sequenceNumber, boolean isReturn, - String exchange, String routingKey, - Integer replyCode, String replyText, Object context) + public long getSequenceNumber(); + public boolean isReturn(); + public Return getReturned(); // null for nacks + public Object getContext(); } ``` -### Exception Scenarios - -**Basic.Nack:** -- Broker rejected the message -- `isReturn() == false` -- Exchange, routingKey, replyCode, replyText are null -- Only sequence number and context available - -**Basic.Return:** -- Message unroutable (mandatory flag set) -- `isReturn() == true` -- Full routing details available -- Reply code indicates reason (NO_ROUTE, NO_CONSUMERS, etc.) - -**Channel Closed:** -- All pending futures completed with `AlreadyClosedException` -- All rate limiter permits released -- Confirmations map cleared - -**I/O Error:** -- Future completed with the I/O exception -- Rate limiter permit released -- Entry removed from confirmations map +- **basic.nack**: `isReturn() == false`, `getReturned() == null` +- **basic.return**: `isReturn() == true`, `getReturned()` carries the full + `Return` (reply code, text, exchange, routing key, properties, body) +- **Shutdown/close**: future completes with the `ShutdownSignalException` ## Usage Examples -### Basic Usage +### Basic ```java Connection connection = factory.newConnection(); -Channel channel = connection.createChannel(); -ConfirmationChannel confirmChannel = ConfirmationChannel.create(channel, null); +ConfirmationPublisher publisher = ConfirmationPublisher.create(connection, 100); -confirmChannel.basicPublishAsync("exchange", "routing.key", props, body, "msg-123") +publisher.basicPublishAsync("exchange", "routing.key", props, body, "msg-123") .thenAccept(msgId -> System.out.println("Confirmed: " + msgId)) .exceptionally(ex -> { - System.err.println("Failed: " + ex.getMessage()); + System.err.println("Failed: " + ex.getCause().getMessage()); return null; }); ``` -### With Rate Limiting - -```java -RateLimiter rateLimiter = new ThrottlingRateLimiter(1000); // Max 1000 in-flight -ConfirmationChannel confirmChannel = ConfirmationChannel.create(channel, rateLimiter); - -for (int i = 0; i < 10000; i++) { - String msgId = "msg-" + i; - confirmChannel.basicPublishAsync("exchange", "key", props, body, msgId) - .thenAccept(id -> System.out.println("Confirmed: " + id)) - .exceptionally(ex -> { - if (ex.getCause() instanceof PublishException) { - PublishException pe = (PublishException) ex.getCause(); - System.err.println("Failed: " + pe.getContext()); - } - return null; - }); -} -``` - ### With Context Objects ```java -class MessageContext { +class OrderContext { final String orderId; - final Instant timestamp; - - MessageContext(String orderId) { + final Instant sent; + OrderContext(String orderId) { this.orderId = orderId; - this.timestamp = Instant.now(); + this.sent = Instant.now(); } } -MessageContext ctx = new MessageContext("order-12345"); -confirmChannel.basicPublishAsync("orders", "new", props, body, ctx) - .thenAccept(context -> { - Duration latency = Duration.between(context.timestamp, Instant.now()); - System.out.println("Order " + context.orderId + " confirmed in " + latency.toMillis() + "ms"); +OrderContext ctx = new OrderContext("order-12345"); +publisher.basicPublishAsync("orders", "new", props, body, ctx) + .thenAccept(c -> { + Duration latency = Duration.between(c.sent, Instant.now()); + System.out.println(c.orderId + " confirmed in " + latency.toMillis() + "ms"); + }); +``` + +### Handling Returns + +```java +publisher.basicPublishAsync("exchange", "key", true, props, body, "msg-1") + .exceptionally(ex -> { + if (ex.getCause() instanceof PublishException) { + PublishException pe = (PublishException) ex.getCause(); + if (pe.isReturn()) { + Return r = pe.getReturned(); + System.err.println("Unroutable: " + r.getReplyText()); + } + } + return null; }); ``` -## Test Results - -- **Confirm tests:** 24/24 passing -- **ThrottlingRateLimiterTest:** 9/9 passing -- **Total:** 33/33 tests passing - -## Testing Strategy - -### Unit Tests -- Sequence number generation and tracking -- Confirmation entry lifecycle -- Rate limiter integration -- Exception handling - -### Integration Tests (Existing) -- All 25 tests in `Confirm.java` adapted to use `ConfirmationChannel` -- Basic.Ack handling (single and multiple) -- Basic.Nack handling (single and multiple) -- Basic.Return handling -- Context parameter correlation -- Channel close cleanup - -### Rate Limiter Tests (Existing) -- 9 tests in `ThrottlingRateLimiterTest.java` -- No changes needed (rate limiter is independent) - -## Trade-offs - -**Pros:** -- Clean architecture with clear boundaries -- No risk of breaking existing functionality -- Easy to understand and maintain -- Can evolve independently of `ChannelN` - -**Cons:** -- Requires wrapping a channel (extra object) -- Two ways to do publisher confirmations (`waitForConfirms()` vs `basicPublishAsync()`) -- Cannot mix `basicPublish()` and `basicPublishAsync()` on same channel -- Slightly more verbose setup code - -## Future Enhancements - -1. **Factory method on Connection** - `connection.createConfirmationChannel(rateLimiter)` -2. **Batch operations** - `basicPublishAsyncBatch()` for multiple messages -3. **Metrics integration** - Add metrics for `basicPublishAsync()` -4. **Observability** - Integration with observation collectors -5. **Alternative rate limiters** - Token bucket, sliding window, etc. +## Design Decisions + +| Decision | Rationale | +|----------|-----------| +| Owns the channel | Prevents external publishes from skewing the tag space | +| Keys by `getNextPublishSeqNo()` | Uses the channel's authoritative counter; no shadow counter to diverge | +| `ReentrantLock` around seqNo + publish | Makes them atomic; contention negligible since `ChannelN` already serializes `transmit` | +| `ConcurrentSkipListMap` | O(k log n) `headMap` for multiple-acks vs O(n) keyset scan | +| `AtomicBoolean` on slot release | Prevents double-release race between ack and shutdown paths | +| Completes futures on an executor | Never blocks the connection's frame-reader thread | +| Header only on `mandatory=true` | Non-mandatory publishes avoid the wire overhead entirely | +| Aborts channel on publish failure after tag consumed | Safe because the channel is private; guarantees tag-space consistency | diff --git a/src/main/java/com/rabbitmq/client/ConfirmationChannel.java b/src/main/java/com/rabbitmq/client/ConfirmationChannel.java deleted file mode 100644 index 0736062317..0000000000 --- a/src/main/java/com/rabbitmq/client/ConfirmationChannel.java +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. -// -// This software, the RabbitMQ Java client library, is triple-licensed under the -// Mozilla Public License 2.0 ("MPL"), the GNU General Public License version 2 -// ("GPL") and the Apache License version 2 ("ASL"). For the MPL, please see -// LICENSE-MPL-RabbitMQ. For the GPL, please see LICENSE-GPL2. For the ASL, -// please see LICENSE-APACHE2. -// -// This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, -// either express or implied. See the LICENSE file for specific language governing -// rights and limitations of this software. -// -// If you have any questions regarding licensing, please contact us at -// info@rabbitmq.com. - -package com.rabbitmq.client; - -import com.rabbitmq.client.impl.ConfirmationChannelN; - -import java.io.IOException; -import java.util.concurrent.CompletableFuture; - -/** - * A channel that supports asynchronous publisher confirmations. - *

- * This interface extends {@link Channel} to add the {@link #basicPublishAsync} method, - * which returns a {@link CompletableFuture} that completes when the broker confirms - * or rejects the published message. - *

- * Publisher confirmations are automatically enabled when using this channel type. - * Messages are tracked using sequence numbers, and the future completes when: - *

    - *
  • The broker sends Basic.Ack (successful confirmation)
  • - *
  • The broker sends Basic.Nack (rejection) - future completes exceptionally with {@link PublishException}
  • - *
  • The broker returns the message via Basic.Return (unroutable) - future completes exceptionally with {@link PublishException}
  • - *
- *

- * Example usage: - *

{@code
- * Channel channel = connection.createChannel();
- * ConfirmationChannel confirmChannel = ConfirmationChannel.create(channel, rateLimiter);
- *
- * confirmChannel.basicPublishAsync("exchange", "routing.key", props, body, "msg-123")
- *     .thenAccept(msgId -> System.out.println("Confirmed: " + msgId))
- *     .exceptionally(ex -> {
- *         if (ex.getCause() instanceof PublishException) {
- *             PublishException pe = (PublishException) ex.getCause();
- *             System.err.println("Failed: " + pe.getContext());
- *         }
- *         return null;
- *     });
- * }
- * - * @see Channel - * @see PublishException - * @see com.rabbitmq.client.impl.ConfirmationChannelN - */ -public interface ConfirmationChannel extends Channel -{ - /** - * Creates a new ConfirmationChannel by wrapping an existing channel. - *

- * This factory method enables asynchronous publisher confirmation tracking - * on any existing channel. The wrapped channel will have publisher confirmations - * automatically enabled via {@link Channel#confirmSelect()}. - *

- * Example usage: - *

{@code
-     * Channel channel = connection.createChannel();
-     * RateLimiter limiter = new ThrottlingRateLimiter(100, 50);
-     * ConfirmationChannel confirmChannel = ConfirmationChannel.create(channel, limiter);
-     *
-     * confirmChannel.basicPublishAsync("exchange", "key", props, body, "msg-123")
-     *     .thenAccept(msgId -> System.out.println("Confirmed: " + msgId));
-     * }
- * - * @param channel the channel to wrap - * @param rateLimiter optional rate limiter for controlling publish concurrency (null for unlimited) - * @return a new ConfirmationChannel instance - * @throws IOException if enabling publisher confirmations fails - * @see RateLimiter - * @see ThrottlingRateLimiter - */ - static ConfirmationChannel create(Channel channel, RateLimiter rateLimiter) throws IOException - { - return new ConfirmationChannelN(channel, rateLimiter); - } - - /** - * Asynchronously publish a message with publisher confirmation tracking. - *

- * This method publishes a message and returns a {@link CompletableFuture} that completes - * when the broker confirms or rejects the message. The future's value is the context - * parameter provided, allowing correlation between publish requests and confirmations. - *

- * The future completes: - *

    - *
  • Successfully with the context value when the broker sends Basic.Ack
  • - *
  • Exceptionally with {@link PublishException} when: - *
      - *
    • The broker sends Basic.Nack (message rejected)
    • - *
    • The broker returns the message via Basic.Return (unroutable with mandatory flag)
    • - *
    • An I/O error occurs during publish
    • - *
    • The channel is closed before confirmation
    • - *
    - *
  • - *
- *

- * Thread safety: This method is thread-safe and can be called concurrently. - *

- * Rate limiting: If a {@link RateLimiter} is configured, this method will - * block until a permit is available before publishing. - * - * @param exchange the exchange to publish to - * @param routingKey the routing key - * @param props message properties (null for default) - * @param body message body - * @param context user-provided context object for correlation (can be null) - * @param the type of the context parameter - * @return a CompletableFuture that completes with the context value on success, - * or exceptionally with PublishException on failure - * @throws IllegalStateException if the channel is closed - */ - CompletableFuture basicPublishAsync(String exchange, String routingKey, - AMQP.BasicProperties props, byte[] body, T context); - - /** - * Asynchronously publish a message with mandatory flag and publisher confirmation tracking. - *

- * This is equivalent to {@link #basicPublishAsync(String, String, AMQP.BasicProperties, byte[], Object)} - * but allows specifying the mandatory flag. When mandatory is true, the broker will return - * the message via Basic.Return if it cannot be routed to any queue, causing the future - * to complete exceptionally with {@link PublishException}. - * - * @param exchange the exchange to publish to - * @param routingKey the routing key - * @param mandatory true if the message must be routable to at least one queue - * @param props message properties (null for default) - * @param body message body - * @param context user-provided context object for correlation (can be null) - * @param the type of the context parameter - * @return a CompletableFuture that completes with the context value on success, - * or exceptionally with PublishException on failure - * @throws IllegalStateException if the channel is closed - */ - CompletableFuture basicPublishAsync(String exchange, String routingKey, - boolean mandatory, - AMQP.BasicProperties props, byte[] body, T context); -} diff --git a/src/main/java/com/rabbitmq/client/ConfirmationPublisher.java b/src/main/java/com/rabbitmq/client/ConfirmationPublisher.java new file mode 100644 index 0000000000..30b883e76d --- /dev/null +++ b/src/main/java/com/rabbitmq/client/ConfirmationPublisher.java @@ -0,0 +1,459 @@ +// Copyright (c) 2007-2026 Broadcom. All Rights Reserved. The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. +// +// This software, the RabbitMQ Java client library, is triple-licensed under the +// Mozilla Public License 2.0 ("MPL"), the GNU General Public License version 2 +// ("GPL") and the Apache License version 2 ("ASL"). For the MPL, please see +// LICENSE-MPL-RabbitMQ. For the GPL, please see LICENSE-GPL2. For the ASL, +// please see LICENSE-APACHE2. +// +// This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, +// either express or implied. See the LICENSE file for specific language governing +// rights and limitations of this software. +// +// If you have any questions regarding licensing, please contact us at +// info@rabbitmq.com. + +package com.rabbitmq.client; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentSkipListMap; +import java.util.concurrent.Executor; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantLock; +import java.util.NavigableMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A publisher with asynchronous publisher confirmation tracking. + *

+ * A {@code ConfirmationPublisher} owns a dedicated channel created from the + * supplied {@link Connection}. The channel is put in confirm mode and every + * message published through {@link #basicPublishAsync} is tracked: the + * returned {@link CompletableFuture} completes with the caller-supplied + * context when the broker confirms the message, or exceptionally with + * {@link PublishException} when the broker rejects ({@code basic.nack}) or + * returns ({@code basic.return}) it. + *

+ * The publisher's channel is private to it: topology operations and ordinary + * publishing should use the application's own channels. + *

+ * When the connection supports automatic recovery, the publisher keeps + * working across recoveries: futures outstanding at the time of a connection + * failure complete exceptionally (their outcome is unknown; callers decide + * whether to republish) and publishing can resume once recovery completes. + *

+ * Example usage: + *

{@code
+ * ConfirmationPublisher publisher = ConfirmationPublisher.create(connection, 100);
+ * publisher.basicPublishAsync("exchange", "routing.key", props, body, "msg-123")
+ *     .thenAccept(msgId -> System.out.println("Confirmed: " + msgId))
+ *     .exceptionally(ex -> {
+ *         System.err.println("Failed: " + ex.getCause());
+ *         return null;
+ *     });
+ * }
+ * + * @see PublishException + */ +public class ConfirmationPublisher implements AutoCloseable { + + private static final Logger LOGGER = LoggerFactory.getLogger(ConfirmationPublisher.class); + + /** + * Header used to correlate {@code basic.return} with a publish. Only added + * to messages published with {@code mandatory = true}, since a + * {@code basic.return} frame carries no delivery tag and this header is the + * only way to match it back to a publish. + *

+ * The broker echoes this header both in the return frame and on normal + * delivery, so when a mandatory message is routable its consumers + * receive it with this header present. It cannot be stripped on the routable + * path. Applications that must not expose it should publish such messages + * through their own channel rather than this publisher. + */ + public static final String SEQUENCE_NUMBER_HEADER = "x-seq-no"; + + private final Channel channel; + private final Semaphore outstandingLimit; + private final Executor confirmationExecutor; + private final ReentrantLock publishLock = new ReentrantLock(); + private final ConcurrentSkipListMap outstanding = + new ConcurrentSkipListMap(); + + /** + * Creates a publisher with no bound on outstanding confirmations. + * + * @param connection the connection to create the publisher's channel from + * @return a new publisher + * @throws IOException if channel creation or {@code confirm.select} fails + */ + public static ConfirmationPublisher create(Connection connection) throws IOException { + return new ConfirmationPublisher(connection, 0, ForkJoinPool.commonPool()); + } + + /** + * Creates a publisher that allows at most {@code maxOutstandingConfirms} + * unconfirmed messages; {@link #basicPublishAsync} blocks when the limit + * is reached. + * + * @param connection the connection to create the publisher's channel from + * @param maxOutstandingConfirms maximum unconfirmed messages, must be positive + * @return a new publisher + * @throws IOException if channel creation or {@code confirm.select} fails + */ + public static ConfirmationPublisher create(Connection connection, int maxOutstandingConfirms) + throws IOException { + if (maxOutstandingConfirms <= 0) { + throw new IllegalArgumentException("maxOutstandingConfirms must be positive: " + maxOutstandingConfirms); + } + return new ConfirmationPublisher(connection, maxOutstandingConfirms, ForkJoinPool.commonPool()); + } + + /** + * Creates a publisher with a bound on outstanding confirmations and a + * custom executor for completing futures. + * + * @param connection the connection to create the publisher's channel from + * @param maxOutstandingConfirms maximum unconfirmed messages, must be positive + * @param confirmationExecutor executor used to complete the returned futures + * (and thus run non-async continuations); never the connection's I/O thread + * @return a new publisher + * @throws IOException if channel creation or {@code confirm.select} fails + */ + public static ConfirmationPublisher create(Connection connection, int maxOutstandingConfirms, + Executor confirmationExecutor) throws IOException { + if (maxOutstandingConfirms <= 0) { + throw new IllegalArgumentException("maxOutstandingConfirms must be positive: " + maxOutstandingConfirms); + } + if (confirmationExecutor == null) { + throw new IllegalArgumentException("confirmationExecutor must not be null"); + } + return new ConfirmationPublisher(connection, maxOutstandingConfirms, confirmationExecutor); + } + + private ConfirmationPublisher(Connection connection, int maxOutstandingConfirms, + Executor confirmationExecutor) throws IOException { + this.channel = connection.createChannel(); + this.outstandingLimit = maxOutstandingConfirms > 0 ? new Semaphore(maxOutstandingConfirms) : null; + this.confirmationExecutor = confirmationExecutor; + this.channel.confirmSelect(); + this.channel.addConfirmListener(this::handleAck, this::handleNack); + this.channel.addReturnListener(this::handleReturn); + this.channel.addShutdownListener(this::handleShutdown); + if (this.channel instanceof Recoverable) { + ((Recoverable) this.channel).addRecoveryListener(new RecoveryListener() { + @Override + public void handleRecoveryStarted(Recoverable recoverable) { + onRecoveryStarted(); + } + + @Override + public void handleRecovery(Recoverable recoverable) { + } + }); + } + } + + /** + * Asynchronously publish a message with publisher confirmation tracking. + *

+ * Thread-safe. If the publisher was created with a bound on outstanding + * confirmations, this method blocks while the bound is reached. + * + * @param exchange the exchange to publish to + * @param routingKey the routing key + * @param props message properties (null for default) + * @param body message body + * @param context user-provided context object for correlation (can be null) + * @param the type of the context parameter + * @return a future completing with the context on {@code basic.ack}, or + * exceptionally with {@link PublishException} on {@code basic.nack}, + * or with the underlying exception if the publish itself fails + */ + public CompletableFuture basicPublishAsync(String exchange, String routingKey, + AMQP.BasicProperties props, byte[] body, T context) { + return basicPublishAsync(exchange, routingKey, false, props, body, context); + } + + /** + * Asynchronously publish a message with the mandatory flag and publisher + * confirmation tracking. + *

+ * When {@code mandatory} is true a {@value #SEQUENCE_NUMBER_HEADER} header + * is added to the message so that a {@code basic.return} can be correlated + * with this publish; the future then completes exceptionally with a + * {@link PublishException} carrying the {@link Return}. + * + * @param exchange the exchange to publish to + * @param routingKey the routing key + * @param mandatory true if the message must be routable to at least one queue + * @param props message properties (null for default); must not already + * contain a {@value #SEQUENCE_NUMBER_HEADER} header when mandatory + * @param body message body + * @param context user-provided context object for correlation (can be null) + * @param the type of the context parameter + * @return a future completing with the context on {@code basic.ack}, or + * exceptionally with {@link PublishException} on {@code basic.nack} + * or {@code basic.return}, or with the underlying exception if the + * publish itself fails + * @throws IllegalArgumentException if {@code mandatory} is true and + * {@code props} already contains a {@value #SEQUENCE_NUMBER_HEADER} + * header; this is a caller programming error and is reported + * synchronously rather than through the returned future + */ + public CompletableFuture basicPublishAsync(String exchange, String routingKey, + boolean mandatory, AMQP.BasicProperties props, byte[] body, T context) { + if (mandatory && props != null && props.getHeaders() != null + && props.getHeaders().containsKey(SEQUENCE_NUMBER_HEADER)) { + throw new IllegalArgumentException( + "message properties must not contain a " + SEQUENCE_NUMBER_HEADER + " header"); + } + CompletableFuture future = new CompletableFuture(); + OutstandingConfirmation confirmation = + new OutstandingConfirmation(future, context, this.outstandingLimit); + try { + if (this.outstandingLimit != null) { + this.outstandingLimit.acquire(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + future.completeExceptionally(e); + return future; + } + this.publishLock.lock(); + try { + long seqNo = this.channel.getNextPublishSeqNo(); + if (seqNo == 0) { + // The channel is transiently not in confirm mode: an + // autorecovering channel has swapped in a fresh delegate whose + // confirm.select has not been re-issued yet. Publishing now + // would send an untracked message the broker never confirms. + confirmation.releaseSlot(); + future.completeExceptionally(new IOException( + "channel is not in confirm mode (recovery in progress)")); + return future; + } + AMQP.BasicProperties publishProps = + mandatory ? withSequenceNumberHeader(props, seqNo) : props; + this.outstanding.put(seqNo, confirmation); + try { + this.channel.basicPublish(exchange, routingKey, mandatory, publishProps, body); + } catch (Exception e) { + this.outstanding.remove(seqNo); + confirmation.releaseSlot(); + abortChannelIfTagSpaceStuck(e); + future.completeExceptionally(e); + } + } finally { + this.publishLock.unlock(); + } + return future; + } + + /** + * @return the number of publishes awaiting a broker confirmation; this is + * not a constant-time operation and is intended for monitoring, not + * for use on a hot path + */ + public int getOutstandingCount() { + return this.outstanding.size(); + } + + /** + * Closes the publisher and its channel with a graceful handshake. + * Confirmations that arrive before the channel finishes closing complete + * their futures normally; any futures still outstanding once the channel is + * closed complete exceptionally with a {@link ShutdownSignalException} + * (their messages are unconfirmed and their outcome unknown). + */ + @Override + public void close() throws IOException { + try { + this.channel.close(); + } catch (TimeoutException e) { + throw new IOException(e); + } catch (AlreadyClosedException e) { + // already closed by shutdown or a previous call + } + } + + private static AMQP.BasicProperties withSequenceNumberHeader(AMQP.BasicProperties props, long seqNo) { + Map headers = new HashMap(); + if (props != null && props.getHeaders() != null) { + headers.putAll(props.getHeaders()); + } + headers.put(SEQUENCE_NUMBER_HEADER, seqNo); + AMQP.BasicProperties.Builder builder = + props == null ? new AMQP.BasicProperties.Builder() : props.builder(); + return builder.headers(headers).build(); + } + + /** + * The delegate channel increments its publish sequence number before the + * frame reaches the wire, so a failed publish leaves the channel's counter + * ahead of the broker's delivery-tag sequence and every later confirmation + * would be correlated incorrectly. + *

+ * An {@link IOException} means the connection is failing: the imminent + * shutdown fails all outstanding futures and, if the connection recovers, + * the counter resets to 1 (the broker restarts delivery tags), so the skew + * self-heals and the channel must be left alone to recover. Any other + * exception (for example from a misbehaving {@code ObservationCollector}) + * leaves the channel open with a genuinely skewed counter and no shutdown + * pending; the channel is private to this publisher, so the safe reaction + * is to abort it, which fails all outstanding futures via the shutdown + * listener. + */ + private void abortChannelIfTagSpaceStuck(Exception failure) { + if (failure instanceof IOException) { + return; + } + if (this.channel.isOpen()) { + LOGGER.warn("Aborting confirmation publisher channel: publish failed " + + "after a sequence number was consumed", failure); + this.channel.abort(); + } + } + + private void handleAck(long deliveryTag, boolean multiple) { + completeConfirmations(deliveryTag, multiple, false); + } + + private void handleNack(long deliveryTag, boolean multiple) { + completeConfirmations(deliveryTag, multiple, true); + } + + private void completeConfirmations(long deliveryTag, boolean multiple, boolean nack) { + if (multiple) { + // Confirm/return handlers all run on the single connection thread and + // concurrent publishes only insert higher sequence numbers, so this + // head view is stable: complete each entry, then clear it in one pass. + NavigableMap confirmed = + this.outstanding.headMap(deliveryTag, true); + for (Map.Entry entry : confirmed.entrySet()) { + complete(entry.getKey(), entry.getValue(), nack); + } + confirmed.clear(); + } else { + OutstandingConfirmation confirmation = this.outstanding.remove(deliveryTag); + if (confirmation != null) { + complete(deliveryTag, confirmation, nack); + } + } + } + + private void complete(long seqNo, OutstandingConfirmation confirmation, boolean nack) { + confirmation.releaseSlot(); + if (nack) { + final PublishException e = new PublishException(seqNo, confirmation.context); + dispatch(() -> confirmation.completeExceptionally(e)); + } else { + dispatch(confirmation::complete); + } + } + + private void handleReturn(Return returned) { + Long seqNo = extractSequenceNumber(returned.getProperties()); + if (seqNo == null) { + // not published through this publisher, or header lost: the + // subsequent basic.ack still completes the future successfully + LOGGER.debug("Ignoring basic.return without a usable {} header", SEQUENCE_NUMBER_HEADER); + return; + } + final OutstandingConfirmation confirmation = this.outstanding.remove(seqNo); + if (confirmation == null) { + return; + } + confirmation.releaseSlot(); + final PublishException e = new PublishException(seqNo, returned, confirmation.context); + dispatch(() -> confirmation.completeExceptionally(e)); + } + + private static Long extractSequenceNumber(AMQP.BasicProperties props) { + if (props == null || props.getHeaders() == null) { + return null; + } + Object value = props.getHeaders().get(SEQUENCE_NUMBER_HEADER); + return value instanceof Long ? (Long) value : null; + } + + private void handleShutdown(ShutdownSignalException cause) { + failAllOutstanding(cause); + } + + /** + * Fails every outstanding future exceptionally and drains the map. Called + * both on channel shutdown and, for an autorecovering channel, at the start + * of recovery: recovery re-issues {@code confirm.select}, which resets the + * delivery-tag sequence to 1, so any entry that outlived the shutdown drain + * must be failed before the reused tag space could mis-correlate it. + */ + private void failAllOutstanding(Throwable cause) { + Map.Entry entry; + while ((entry = this.outstanding.pollFirstEntry()) != null) { + final OutstandingConfirmation confirmation = entry.getValue(); + confirmation.releaseSlot(); + dispatch(() -> confirmation.completeExceptionally(cause)); + } + } + + private void onRecoveryStarted() { + // The new delegate is already installed and open by this point, so the + // outcome of any still-outstanding publish is unknown; fail it before + // recovery resets the delivery-tag sequence. + failAllOutstanding(new IOException( + "connection recovery started; outcome of outstanding publishes is unknown")); + } + + /** + * Runs a completion task on the configured executor, falling back to the + * calling thread if the executor rejects it (for example a user-supplied + * executor that is saturated or shut down). Completing inline still runs off + * the future's own continuations but guarantees the future never hangs. + */ + private void dispatch(Runnable completion) { + try { + this.confirmationExecutor.execute(completion); + } catch (RejectedExecutionException e) { + LOGGER.warn("Confirmation executor rejected a completion task; " + + "completing on the calling thread", e); + completion.run(); + } + } + + private static final class OutstandingConfirmation { + private final CompletableFuture future; + private final Object context; + private final Semaphore limit; + private final AtomicBoolean slotReleased = new AtomicBoolean(false); + + private OutstandingConfirmation(CompletableFuture future, Object context, Semaphore limit) { + this.future = future; + this.context = context; + this.limit = limit; + } + + @SuppressWarnings("unchecked") + private void complete() { + ((CompletableFuture) this.future).complete(this.context); + } + + private void completeExceptionally(Throwable t) { + this.future.completeExceptionally(t); + } + + private void releaseSlot() { + if (this.limit != null && this.slotReleased.compareAndSet(false, true)) { + this.limit.release(); + } + } + } +} diff --git a/src/main/java/com/rabbitmq/client/PublishException.java b/src/main/java/com/rabbitmq/client/PublishException.java index aab9e40ef6..59c6ec56c3 100644 --- a/src/main/java/com/rabbitmq/client/PublishException.java +++ b/src/main/java/com/rabbitmq/client/PublishException.java @@ -1,4 +1,4 @@ -// Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. +// Copyright (c) 2007-2026 Broadcom. All Rights Reserved. The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. // // This software, the RabbitMQ Java client library, is triple-licensed under the // Mozilla Public License 2.0 ("MPL"), the GNU General Public License version 2 @@ -18,84 +18,48 @@ import java.io.IOException; /** - * Exception thrown when a published message is nack'd or returned by the broker. + * Exception a {@link ConfirmationPublisher} future completes with when the + * broker rejects a message ({@code basic.nack}) or returns it as unroutable + * ({@code basic.return}). *

- * This exception is thrown when publisher confirmation tracking is enabled and: - *

    - *
  • The broker sends Basic.Nack for a message (negative acknowledgment)
  • - *
  • The broker returns a message via Basic.Return (unroutable with mandatory flag)
  • - *
- *

- * Use {@link #isReturn()} to distinguish between these two cases: - *

    - *
  • Nack ({@code isReturn() == false}): The broker rejected the message. - * Additional fields (exchange, routingKey, replyCode, replyText) will be null.
  • - *
  • Return ({@code isReturn() == true}): The broker could not route the message. - * Additional fields contain routing information and the reason for the return.
  • - *
- *

- * Example handling: - *

{@code
- * ConfirmationChannel channel = ConfirmationChannel.create(regularChannel, rateLimiter);
- * channel.basicPublishAsync(exchange, routingKey, true, props, body, "msg-123")
- *     .exceptionally(ex -> {
- *         if (ex.getCause() instanceof PublishException) {
- *             PublishException pe = (PublishException) ex.getCause();
- *             String msgId = (String) pe.getContext();
- *             if (pe.isReturn()) {
- *                 System.err.println("Message " + msgId + " returned: " + pe.getReplyText());
- *             } else {
- *                 System.err.println("Message " + msgId + " nack'd");
- *             }
- *         }
- *         return null;
- *     });
- * }
+ * Use {@link #isReturn()} to distinguish the two cases. For a return, + * {@link #getReturned()} carries the full {@link Return} (reply code and text, + * exchange, routing key, properties, body); for a nack it is null. * - * @see ConfirmationChannel#basicPublishAsync(String, String, com.rabbitmq.client.AMQP.BasicProperties, byte[], Object) - * @see ConfirmationChannel + * @see ConfirmationPublisher#basicPublishAsync(String, String, boolean, com.rabbitmq.client.AMQP.BasicProperties, byte[], Object) */ public class PublishException extends IOException { + private final long sequenceNumber; - private final boolean isReturn; - private final String exchange; - private final String routingKey; - private final Integer replyCode; - private final String replyText; + private final Return returned; private final Object context; /** - * Constructor for nack scenarios where routing details are not available. - *

- * When the broker sends Basic.Nack, it only provides the sequence number. - * The exchange, routingKey, replyCode, and replyText fields will be null. + * Constructor for the {@code basic.nack} case. * * @param sequenceNumber the publish sequence number * @param context the user-provided context object */ - public PublishException(long sequenceNumber, Object context) - { - this(sequenceNumber, false, null, null, null, null, context); - } - - public PublishException(long sequenceNumber, boolean isReturn, String exchange, String routingKey, - Integer replyCode, String replyText, Object context) { - super(buildMessage(sequenceNumber, isReturn, replyCode, replyText)); + public PublishException(long sequenceNumber, Object context) { + super(String.format("Message %d nack'd", sequenceNumber)); this.sequenceNumber = sequenceNumber; - this.isReturn = isReturn; - this.exchange = exchange; - this.routingKey = routingKey; - this.replyCode = replyCode; - this.replyText = replyText; + this.returned = null; this.context = context; } - private static String buildMessage(long sequenceNumber, boolean isReturn, Integer replyCode, String replyText) { - if (isReturn) { - return String.format("Message %d returned: %s (%d)", sequenceNumber, replyText, replyCode); - } else { - return String.format("Message %d nack'd", sequenceNumber); - } + /** + * Constructor for the {@code basic.return} case. + * + * @param sequenceNumber the publish sequence number + * @param returned the broker's return + * @param context the user-provided context object + */ + public PublishException(long sequenceNumber, Return returned, Object context) { + super(String.format("Message %d returned: %s (%d)", sequenceNumber, + returned.getReplyText(), returned.getReplyCode())); + this.sequenceNumber = sequenceNumber; + this.returned = returned; + this.context = context; } /** @@ -106,46 +70,24 @@ public long getSequenceNumber() { } /** - * @return true if this exception was caused by Basic.Return (unroutable message), - * false if caused by Basic.Nack (broker rejection) + * @return true if caused by {@code basic.return} (unroutable message), + * false if caused by {@code basic.nack} (broker rejection) */ public boolean isReturn() { - return isReturn; - } - - /** - * @return the exchange the message was published to (only available for returns, null for nacks) - */ - public String getExchange() { - return exchange; - } - - /** - * @return the routing key used (only available for returns, null for nacks) - */ - public String getRoutingKey() { - return routingKey; - } - - /** - * @return the reply code from the broker (only available for returns, null for nacks) - * @see com.rabbitmq.client.AMQP#NO_ROUTE - * @see com.rabbitmq.client.AMQP#NO_CONSUMERS - */ - public Integer getReplyCode() { - return replyCode; + return returned != null; } /** - * @return the reply text from the broker explaining why the message was returned - * (only available for returns, null for nacks) + * @return the broker's {@link Return} for the unroutable message, or null + * for a nack */ - public String getReplyText() { - return replyText; + public Return getReturned() { + return returned; } /** - * @return the user-provided context object that was passed to basicPublishAsync, or null if none was provided + * @return the user-provided context object passed to {@code basicPublishAsync}, + * or null if none was provided */ public Object getContext() { return context; diff --git a/src/main/java/com/rabbitmq/client/RateLimiter.java b/src/main/java/com/rabbitmq/client/RateLimiter.java deleted file mode 100644 index d751cc1699..0000000000 --- a/src/main/java/com/rabbitmq/client/RateLimiter.java +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. -// -// This software, the RabbitMQ Java client library, is triple-licensed under the -// Mozilla Public License 2.0 ("MPL"), the GNU General Public License version 2 -// ("GPL") and the Apache License version 2 ("ASL"). For the MPL, please see -// LICENSE-MPL-RabbitMQ. For the GPL, please see LICENSE-GPL2. For the ASL, -// please see LICENSE-APACHE2. -// -// This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, -// either express or implied. See the LICENSE file for specific language governing -// rights and limitations of this software. -// -// If you have any questions regarding licensing, please contact us at -// info@rabbitmq.com. - -package com.rabbitmq.client; - -/** - * Interface for rate limiting publisher confirmations. - *

- * Implementations control the rate of publish operations by acquiring permits - * before publishing and releasing them when confirmations are received. - *

- * The library provides {@link ThrottlingRateLimiter} as a default implementation - * with progressive throttling behavior. Users can implement custom rate limiting - * strategies by implementing this interface. - * - * @see ThrottlingRateLimiter - */ -public interface RateLimiter -{ - /** - * Acquires a permit, blocking if necessary until one is available. - *

- * Implementations may apply delays or other backpressure mechanisms - * before returning the permit. - * - * @return A permit that must be released when the operation completes - * @throws InterruptedException if the thread is interrupted while waiting - */ - Permit acquire() throws InterruptedException; - - /** - * Gets the maximum concurrency supported by this rate limiter. - *

- * This is a hint for sizing internal data structures. Implementations - * that don't have a fixed capacity should return 0. - * - * @return the maximum number of concurrent operations, or 0 if unknown/unlimited - */ - default int getMaxConcurrency() - { - return 0; - } - - /** - * A permit that represents acquired access to a rate-limited resource. - * Must be released when the operation completes. - */ - interface Permit - { - /** - * Releases this permit, returning it to the rate limiter pool. - */ - void release(); - } -} diff --git a/src/main/java/com/rabbitmq/client/ThrottlingRateLimiter.java b/src/main/java/com/rabbitmq/client/ThrottlingRateLimiter.java deleted file mode 100644 index 4773300d95..0000000000 --- a/src/main/java/com/rabbitmq/client/ThrottlingRateLimiter.java +++ /dev/null @@ -1,156 +0,0 @@ -// Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. -// -// This software, the RabbitMQ Java client library, is triple-licensed under the -// Mozilla Public License 2.0 ("MPL"), the GNU General Public License version 2 -// ("GPL") and the Apache License version 2 ("ASL"). For the MPL, please see -// LICENSE-MPL-RabbitMQ. For the GPL, please see LICENSE-GPL2. For the ASL, -// please see LICENSE-APACHE2. -// -// This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, -// either express or implied. See the LICENSE file for specific language governing -// rights and limitations of this software. -// -// If you have any questions regarding licensing, please contact us at -// info@rabbitmq.com. - -package com.rabbitmq.client; - -import java.util.concurrent.Semaphore; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * A rate limiter that controls the rate of operations by limiting concurrency and applying delays - * when a specified threshold of concurrency usage is reached. - *

- * The delay algorithm checks the current available permits. If the available permits are greater than or equal - * to the throttling threshold, no delay is applied. Otherwise, it calculates a delay based on the percentage - * of permits used, scaling it up to a maximum of 1000 milliseconds. - *

- * This implementation matches the behavior of the .NET client's {@code ThrottlingRateLimiter}. - * - * @see RateLimiter - */ -public class ThrottlingRateLimiter implements RateLimiter -{ - - /** - * The default throttling percentage, which defines the threshold for applying throttling, set to 50%. - */ - public static final int DEFAULT_THROTTLING_PERCENTAGE = 50; - - private final int maxConcurrency; - private final int throttlingThreshold; - private final Semaphore semaphore; - private final AtomicInteger currentPermits; - - /** - * Initializes a new instance with the specified maximum number of concurrent calls - * and the default throttling percentage (50%). - * - * @param maxConcurrentCalls The maximum number of concurrent operations allowed - */ - public ThrottlingRateLimiter(int maxConcurrentCalls) { - this(maxConcurrentCalls, DEFAULT_THROTTLING_PERCENTAGE); - } - - /** - * Initializes a new instance with the specified maximum number of concurrent calls - * and throttling percentage. - * - * @param maxConcurrentCalls The maximum number of concurrent operations allowed - * @param throttlingPercentage The percentage of maxConcurrentCalls at which throttling is triggered - */ - public ThrottlingRateLimiter(int maxConcurrentCalls, int throttlingPercentage) { - if (maxConcurrentCalls <= 0) { - throw new IllegalArgumentException("maxConcurrentCalls must be positive"); - } - if (throttlingPercentage < 0 || throttlingPercentage > 100) { - throw new IllegalArgumentException("throttlingPercentage must be between 0 and 100"); - } - - this.maxConcurrency = maxConcurrentCalls; - this.throttlingThreshold = maxConcurrency * throttlingPercentage / 100; - this.semaphore = new Semaphore(maxConcurrentCalls, true); - this.currentPermits = new AtomicInteger(maxConcurrentCalls); - } - - /** - * Acquires a permit, blocking if necessary until one is available. - * Applies throttling delay if the number of available permits falls below the threshold. - * - * @return A permit that must be released when the operation completes - * @throws InterruptedException if the thread is interrupted while waiting - */ - public Permit acquire() throws InterruptedException { - int delay = calculateDelay(); - if (delay > 0) { - Thread.sleep(delay); - } - - semaphore.acquire(); - currentPermits.decrementAndGet(); - return new Permit(this); - } - - /** - * Releases a permit, returning it to the pool. - */ - private void release() { - currentPermits.incrementAndGet(); - semaphore.release(); - } - - /** - * Gets the current number of available permits. - * - * @return The number of permits currently available - */ - public int getAvailablePermits() { - return currentPermits.get(); - } - - /** - * Gets the maximum concurrency supported by this rate limiter. - * - * @return The maximum number of concurrent operations allowed - */ - @Override - public int getMaxConcurrency() { - return maxConcurrency; - } - - private int calculateDelay() { - int availablePermits = currentPermits.get(); - - if (availablePermits >= throttlingThreshold) { - // No delay - available permits exceed the threshold - return 0; - } - - double percentageUsed = 1.0 - (availablePermits / (double) maxConcurrency); - return (int)(percentageUsed * 1000); - } - - /** - * A permit that represents acquired access to a rate-limited resource. - * Must be released when the operation completes. - */ - public static class Permit implements RateLimiter.Permit { - private final ThrottlingRateLimiter limiter; - private boolean released = false; - - private Permit(ThrottlingRateLimiter limiter) { - this.limiter = limiter; - } - - /** - * Releases this permit, returning it to the rate limiter pool. - */ - public void release() { - if (!released) { - released = true; - limiter.release(); - } - } - } -} diff --git a/src/main/java/com/rabbitmq/client/impl/ConfirmationChannelN.java b/src/main/java/com/rabbitmq/client/impl/ConfirmationChannelN.java deleted file mode 100644 index c7f1b977cd..0000000000 --- a/src/main/java/com/rabbitmq/client/impl/ConfirmationChannelN.java +++ /dev/null @@ -1,934 +0,0 @@ -// Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. -// -// This software, the RabbitMQ Java client library, is triple-licensed under the -// Mozilla Public License 2.0 ("MPL"), the GNU General Public License version 2 -// ("GPL") and the Apache License version 2 ("ASL"). For the MPL, please see -// LICENSE-MPL-RabbitMQ. For the GPL, please see LICENSE-GPL2. For the ASL, -// please see LICENSE-APACHE2. -// -// This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, -// either express or implied. See the LICENSE file for specific language governing -// rights and limitations of this software. -// -// If you have any questions regarding licensing, please contact us at -// info@rabbitmq.com. - -package com.rabbitmq.client.impl; - -import com.rabbitmq.client.*; -import com.rabbitmq.client.AMQP.BasicProperties; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeoutException; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Implementation of {@link ConfirmationChannel} that wraps an existing {@link Channel} - * and provides asynchronous publisher confirmation tracking. - *

- * This class maintains its own sequence number space independent of the wrapped channel. - * The {@link #basicPublish} methods are not supported and will throw - * {@link UnsupportedOperationException}. Use {@link #basicPublishAsync} instead. - */ -public class ConfirmationChannelN implements ConfirmationChannel -{ - private static final String SEQUENCE_NUMBER_HEADER = "x-seq-no"; - - private final Channel delegate; - private final RateLimiter rateLimiter; - private final AtomicLong nextSeqNo = new AtomicLong(1); - private final Map> confirmations; - - private final ShutdownListener shutdownListener; - private final ReturnListener returnListener; - private final ConfirmListener confirmListener; - - /** - * Creates a new ConfirmationChannelN wrapping the given channel. - * - * @param delegate the channel to wrap - * @param rateLimiter optional rate limiter for controlling publish concurrency (can be null) - * @throws IOException if enabling publisher confirmations fails - */ - public ConfirmationChannelN(Channel delegate, RateLimiter rateLimiter) throws IOException - { - if (delegate == null) - { - throw new IllegalArgumentException("delegate must be non-null"); - } - - this.delegate = delegate; - this.rateLimiter = rateLimiter; - - int initialCapacity = (rateLimiter != null) ? rateLimiter.getMaxConcurrency() : 16; - this.confirmations = new ConcurrentHashMap<>(initialCapacity > 0 ? initialCapacity : 16); - - // Enable publisher confirmations on the delegate channel - delegate.confirmSelect(); - - // Register listeners for confirmations and returns - // Store listener instances so we can remove them later - this.shutdownListener = this::handleShutdown; - this.returnListener = this::handleReturn; - this.confirmListener = delegate.addConfirmListener(this::handleAck, this::handleNack); - - delegate.addReturnListener(returnListener); - delegate.addShutdownListener(shutdownListener); - } - - @Override - public CompletableFuture basicPublishAsync(String exchange, String routingKey, - BasicProperties props, byte[] body, T context) - { - return basicPublishAsync(exchange, routingKey, false, props, body, context); - } - - @Override - public CompletableFuture basicPublishAsync(String exchange, String routingKey, - boolean mandatory, - BasicProperties props, byte[] body, T context) - { - CompletableFuture future = new CompletableFuture<>(); - RateLimiter.Permit permit = null; - long seqNo = 0; - - try - { - // Acquire rate limiter permit if configured - if (rateLimiter != null) - { - permit = rateLimiter.acquire(); - } - - // Get next sequence number - seqNo = nextSeqNo.getAndIncrement(); - - // Store confirmation entry - confirmations.put(seqNo, new ConfirmationEntry<>(future, permit, context)); - - // Add sequence number to message headers - if (props == null) - { - props = MessageProperties.MINIMAL_BASIC; - } - props = addSequenceNumberHeader(props, seqNo); - - // Publish to delegate channel - // Note: Metrics are collected by the delegate's MetricsCollector - delegate.basicPublish(exchange, routingKey, mandatory, props, body); - } - catch (IOException | AlreadyClosedException | InterruptedException e) - { - // Clean up on error - confirmations.remove(seqNo); - if (permit != null) - { - permit.release(); - } - future.completeExceptionally(e); - } - - return future; - } - - // Unsupported basicPublish methods - throw UnsupportedOperationException - - @Override - public void basicPublish(String exchange, String routingKey, BasicProperties props, byte[] body) - throws IOException - { - throw new UnsupportedOperationException( - "basicPublish() is not supported on ConfirmationChannel. Use basicPublishAsync() instead."); - } - - @Override - public void basicPublish(String exchange, String routingKey, boolean mandatory, BasicProperties props, byte[] body) - throws IOException - { - throw new UnsupportedOperationException( - "basicPublish() is not supported on ConfirmationChannel. Use basicPublishAsync() instead."); - } - - @Override - public void basicPublish(String exchange, String routingKey, boolean mandatory, boolean immediate, - BasicProperties props, byte[] body) - throws IOException - { - throw new UnsupportedOperationException( - "basicPublish() is not supported on ConfirmationChannel. Use basicPublishAsync() instead."); - } - - // Delegated Channel methods - - @Override - public int getChannelNumber() - { - return delegate.getChannelNumber(); - } - - @Override - public Connection getConnection() - { - return delegate.getConnection(); - } - - @Override - public void close() throws IOException, TimeoutException - { - delegate.close(); - removeListeners(); - } - - @Override - public void close(int closeCode, String closeMessage) throws IOException, TimeoutException - { - delegate.close(closeCode, closeMessage); - removeListeners(); - } - - @Override - public void abort() - { - delegate.abort(); - removeListeners(); - } - - @Override - public void abort(int closeCode, String closeMessage) - { - delegate.abort(closeCode, closeMessage); - removeListeners(); - } - - @Override - public void addReturnListener(ReturnListener listener) - { - delegate.addReturnListener(listener); - } - - @Override - public ReturnListener addReturnListener(ReturnCallback returnCallback) - { - return delegate.addReturnListener(returnCallback); - } - - @Override - public boolean removeReturnListener(ReturnListener listener) - { - return delegate.removeReturnListener(listener); - } - - @Override - public void clearReturnListeners() - { - delegate.clearReturnListeners(); - } - - @Override - public void addConfirmListener(ConfirmListener listener) - { - delegate.addConfirmListener(listener); - } - - @Override - public ConfirmListener addConfirmListener(ConfirmCallback ackCallback, ConfirmCallback nackCallback) - { - return delegate.addConfirmListener(ackCallback, nackCallback); - } - - @Override - public boolean removeConfirmListener(ConfirmListener listener) - { - return delegate.removeConfirmListener(listener); - } - - @Override - public void clearConfirmListeners() - { - delegate.clearConfirmListeners(); - } - - @Override - public Consumer getDefaultConsumer() - { - return delegate.getDefaultConsumer(); - } - - @Override - public void setDefaultConsumer(Consumer consumer) - { - delegate.setDefaultConsumer(consumer); - } - - @Override - public void basicQos(int prefetchSize, int prefetchCount, boolean global) throws IOException - { - delegate.basicQos(prefetchSize, prefetchCount, global); - } - - @Override - public void basicQos(int prefetchCount, boolean global) throws IOException - { - delegate.basicQos(prefetchCount, global); - } - - @Override - public void basicQos(int prefetchCount) throws IOException - { - delegate.basicQos(prefetchCount); - } - - @Override - public String basicConsume(String queue, Consumer callback) throws IOException - { - return delegate.basicConsume(queue, callback); - } - - @Override - public String basicConsume(String queue, DeliverCallback deliverCallback, CancelCallback cancelCallback) throws IOException - { - return delegate.basicConsume(queue, deliverCallback, cancelCallback); - } - - @Override - public String basicConsume(String queue, DeliverCallback deliverCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException - { - return delegate.basicConsume(queue, deliverCallback, shutdownSignalCallback); - } - - @Override - public String basicConsume(String queue, DeliverCallback deliverCallback, CancelCallback cancelCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException - { - return delegate.basicConsume(queue, deliverCallback, cancelCallback, shutdownSignalCallback); - } - - @Override - public String basicConsume(String queue, boolean autoAck, Consumer callback) throws IOException - { - return delegate.basicConsume(queue, autoAck, callback); - } - - @Override - public String basicConsume(String queue, boolean autoAck, DeliverCallback deliverCallback, CancelCallback cancelCallback) throws IOException - { - return delegate.basicConsume(queue, autoAck, deliverCallback, cancelCallback); - } - - @Override - public String basicConsume(String queue, boolean autoAck, DeliverCallback deliverCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException - { - return delegate.basicConsume(queue, autoAck, deliverCallback, shutdownSignalCallback); - } - - @Override - public String basicConsume(String queue, boolean autoAck, DeliverCallback deliverCallback, CancelCallback cancelCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException - { - return delegate.basicConsume(queue, autoAck, deliverCallback, cancelCallback, shutdownSignalCallback); - } - - @Override - public String basicConsume(String queue, boolean autoAck, Map arguments, Consumer callback) throws IOException - { - return delegate.basicConsume(queue, autoAck, arguments, callback); - } - - @Override - public String basicConsume(String queue, boolean autoAck, Map arguments, DeliverCallback deliverCallback, CancelCallback cancelCallback) throws IOException - { - return delegate.basicConsume(queue, autoAck, arguments, deliverCallback, cancelCallback); - } - - @Override - public String basicConsume(String queue, boolean autoAck, Map arguments, DeliverCallback deliverCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException - { - return delegate.basicConsume(queue, autoAck, arguments, deliverCallback, shutdownSignalCallback); - } - - @Override - public String basicConsume(String queue, boolean autoAck, Map arguments, DeliverCallback deliverCallback, CancelCallback cancelCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException - { - return delegate.basicConsume(queue, autoAck, arguments, deliverCallback, cancelCallback, shutdownSignalCallback); - } - - @Override - public String basicConsume(String queue, boolean autoAck, String consumerTag, Consumer callback) throws IOException - { - return delegate.basicConsume(queue, autoAck, consumerTag, callback); - } - - @Override - public String basicConsume(String queue, boolean autoAck, String consumerTag, DeliverCallback deliverCallback, CancelCallback cancelCallback) throws IOException - { - return delegate.basicConsume(queue, autoAck, consumerTag, deliverCallback, cancelCallback); - } - - @Override - public String basicConsume(String queue, boolean autoAck, String consumerTag, DeliverCallback deliverCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException - { - return delegate.basicConsume(queue, autoAck, consumerTag, deliverCallback, shutdownSignalCallback); - } - - @Override - public String basicConsume(String queue, boolean autoAck, String consumerTag, DeliverCallback deliverCallback, CancelCallback cancelCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException - { - return delegate.basicConsume(queue, autoAck, consumerTag, deliverCallback, cancelCallback, shutdownSignalCallback); - } - - @Override - public String basicConsume(String queue, boolean autoAck, String consumerTag, boolean noLocal, boolean exclusive, Map arguments, Consumer callback) throws IOException - { - return delegate.basicConsume(queue, autoAck, consumerTag, noLocal, exclusive, arguments, callback); - } - - @Override - public String basicConsume(String queue, boolean autoAck, String consumerTag, boolean noLocal, boolean exclusive, Map arguments, DeliverCallback deliverCallback, CancelCallback cancelCallback) throws IOException - { - return delegate.basicConsume(queue, autoAck, consumerTag, noLocal, exclusive, arguments, deliverCallback, cancelCallback); - } - - @Override - public String basicConsume(String queue, boolean autoAck, String consumerTag, boolean noLocal, boolean exclusive, Map arguments, DeliverCallback deliverCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException - { - return delegate.basicConsume(queue, autoAck, consumerTag, noLocal, exclusive, arguments, deliverCallback, shutdownSignalCallback); - } - - @Override - public String basicConsume(String queue, boolean autoAck, String consumerTag, boolean noLocal, boolean exclusive, Map arguments, DeliverCallback deliverCallback, CancelCallback cancelCallback, ConsumerShutdownSignalCallback shutdownSignalCallback) throws IOException - { - return delegate.basicConsume(queue, autoAck, consumerTag, noLocal, exclusive, arguments, deliverCallback, cancelCallback, shutdownSignalCallback); - } - - @Override - public void basicCancel(String consumerTag) throws IOException - { - delegate.basicCancel(consumerTag); - } - - @Override - public AMQP.Basic.RecoverOk basicRecover() throws IOException - { - return delegate.basicRecover(); - } - - @Override - public AMQP.Basic.RecoverOk basicRecover(boolean requeue) throws IOException - { - return delegate.basicRecover(requeue); - } - - @Override - public AMQP.Tx.SelectOk txSelect() throws IOException - { - return delegate.txSelect(); - } - - @Override - public AMQP.Tx.CommitOk txCommit() throws IOException - { - return delegate.txCommit(); - } - - @Override - public AMQP.Tx.RollbackOk txRollback() throws IOException - { - return delegate.txRollback(); - } - - @Override - public AMQP.Confirm.SelectOk confirmSelect() throws IOException - { - return delegate.confirmSelect(); - } - - @Override - public long getNextPublishSeqNo() - { - return delegate.getNextPublishSeqNo(); - } - - @Override - public boolean waitForConfirms() throws InterruptedException - { - throw new UnsupportedOperationException( - "waitForConfirms() is not supported on ConfirmationChannel. Use basicPublishAsync() instead."); - } - - @Override - public boolean waitForConfirms(long timeout) throws InterruptedException, TimeoutException - { - throw new UnsupportedOperationException( - "waitForConfirms() is not supported on ConfirmationChannel. Use basicPublishAsync() instead."); - } - - @Override - public void waitForConfirmsOrDie() throws IOException, InterruptedException - { - throw new UnsupportedOperationException( - "waitForConfirmsOrDie() is not supported on ConfirmationChannel. Use basicPublishAsync() instead."); - } - - @Override - public void waitForConfirmsOrDie(long timeout) throws IOException, InterruptedException, TimeoutException - { - throw new UnsupportedOperationException( - "waitForConfirmsOrDie() is not supported on ConfirmationChannel. Use basicPublishAsync() instead."); - } - - @Override - public void asyncRpc(com.rabbitmq.client.Method method) throws IOException - { - delegate.asyncRpc(method); - } - - @Override - public Command rpc(com.rabbitmq.client.Method method) throws IOException - { - return delegate.rpc(method); - } - - @Override - public long messageCount(String queue) throws IOException - { - return delegate.messageCount(queue); - } - - @Override - public long consumerCount(String queue) throws IOException - { - return delegate.consumerCount(queue); - } - - @Override - public CompletableFuture asyncCompletableRpc(com.rabbitmq.client.Method method) throws IOException - { - return delegate.asyncCompletableRpc(method); - } - - @Override - public void addShutdownListener(ShutdownListener listener) - { - delegate.addShutdownListener(listener); - } - - @Override - public void removeShutdownListener(ShutdownListener listener) - { - delegate.removeShutdownListener(listener); - } - - @Override - public ShutdownSignalException getCloseReason() - { - return delegate.getCloseReason(); - } - - @Override - public void notifyListeners() - { - delegate.notifyListeners(); - } - - @Override - public boolean isOpen() - { - return delegate.isOpen(); - } - - @Override - public AMQP.Exchange.DeclareOk exchangeDeclare(String exchange, String type) throws IOException - { - return delegate.exchangeDeclare(exchange, type); - } - - @Override - public AMQP.Exchange.DeclareOk exchangeDeclare(String exchange, BuiltinExchangeType type) throws IOException - { - return delegate.exchangeDeclare(exchange, type); - } - - @Override - public AMQP.Exchange.DeclareOk exchangeDeclare(String exchange, String type, boolean durable) throws IOException - { - return delegate.exchangeDeclare(exchange, type, durable); - } - - @Override - public AMQP.Exchange.DeclareOk exchangeDeclare(String exchange, BuiltinExchangeType type, boolean durable) throws IOException - { - return delegate.exchangeDeclare(exchange, type, durable); - } - - @Override - public AMQP.Exchange.DeclareOk exchangeDeclare(String exchange, String type, boolean durable, boolean autoDelete, Map arguments) throws IOException - { - return delegate.exchangeDeclare(exchange, type, durable, autoDelete, arguments); - } - - @Override - public AMQP.Exchange.DeclareOk exchangeDeclare(String exchange, BuiltinExchangeType type, boolean durable, boolean autoDelete, Map arguments) throws IOException - { - return delegate.exchangeDeclare(exchange, type, durable, autoDelete, arguments); - } - - @Override - public AMQP.Exchange.DeclareOk exchangeDeclare(String exchange, String type, boolean durable, boolean autoDelete, boolean internal, Map arguments) throws IOException - { - return delegate.exchangeDeclare(exchange, type, durable, autoDelete, internal, arguments); - } - - @Override - public AMQP.Exchange.DeclareOk exchangeDeclare(String exchange, BuiltinExchangeType type, boolean durable, boolean autoDelete, boolean internal, Map arguments) throws IOException - { - return delegate.exchangeDeclare(exchange, type, durable, autoDelete, internal, arguments); - } - - @Override - public void exchangeDeclareNoWait(String exchange, String type, boolean durable, boolean autoDelete, boolean internal, Map arguments) throws IOException - { - delegate.exchangeDeclareNoWait(exchange, type, durable, autoDelete, internal, arguments); - } - - @Override - public void exchangeDeclareNoWait(String exchange, BuiltinExchangeType type, boolean durable, boolean autoDelete, boolean internal, Map arguments) throws IOException - { - delegate.exchangeDeclareNoWait(exchange, type, durable, autoDelete, internal, arguments); - } - - @Override - public AMQP.Exchange.DeclareOk exchangeDeclarePassive(String exchange) throws IOException - { - return delegate.exchangeDeclarePassive(exchange); - } - - @Override - public AMQP.Exchange.DeleteOk exchangeDelete(String exchange, boolean ifUnused) throws IOException - { - return delegate.exchangeDelete(exchange, ifUnused); - } - - @Override - public void exchangeDeleteNoWait(String exchange, boolean ifUnused) throws IOException - { - delegate.exchangeDeleteNoWait(exchange, ifUnused); - } - - @Override - public AMQP.Exchange.DeleteOk exchangeDelete(String exchange) throws IOException - { - return delegate.exchangeDelete(exchange); - } - - @Override - public AMQP.Exchange.BindOk exchangeBind(String destination, String source, String routingKey) throws IOException - { - return delegate.exchangeBind(destination, source, routingKey); - } - - @Override - public AMQP.Exchange.BindOk exchangeBind(String destination, String source, String routingKey, Map arguments) throws IOException - { - return delegate.exchangeBind(destination, source, routingKey, arguments); - } - - @Override - public void exchangeBindNoWait(String destination, String source, String routingKey, Map arguments) throws IOException - { - delegate.exchangeBindNoWait(destination, source, routingKey, arguments); - } - - @Override - public AMQP.Exchange.UnbindOk exchangeUnbind(String destination, String source, String routingKey) throws IOException - { - return delegate.exchangeUnbind(destination, source, routingKey); - } - - @Override - public AMQP.Exchange.UnbindOk exchangeUnbind(String destination, String source, String routingKey, Map arguments) throws IOException - { - return delegate.exchangeUnbind(destination, source, routingKey, arguments); - } - - @Override - public void exchangeUnbindNoWait(String destination, String source, String routingKey, Map arguments) throws IOException - { - delegate.exchangeUnbindNoWait(destination, source, routingKey, arguments); - } - - @Override - public AMQP.Queue.DeclareOk queueDeclare() throws IOException - { - return delegate.queueDeclare(); - } - - @Override - public AMQP.Queue.DeclareOk queueDeclare(String queue, boolean durable, boolean exclusive, boolean autoDelete, Map arguments) throws IOException - { - return delegate.queueDeclare(queue, durable, exclusive, autoDelete, arguments); - } - - @Override - public void queueDeclareNoWait(String queue, boolean durable, boolean exclusive, boolean autoDelete, Map arguments) throws IOException - { - delegate.queueDeclareNoWait(queue, durable, exclusive, autoDelete, arguments); - } - - @Override - public AMQP.Queue.DeclareOk queueDeclarePassive(String queue) throws IOException - { - return delegate.queueDeclarePassive(queue); - } - - @Override - public AMQP.Queue.DeleteOk queueDelete(String queue) throws IOException - { - return delegate.queueDelete(queue); - } - - @Override - public AMQP.Queue.DeleteOk queueDelete(String queue, boolean ifUnused, boolean ifEmpty) throws IOException - { - return delegate.queueDelete(queue, ifUnused, ifEmpty); - } - - @Override - public void queueDeleteNoWait(String queue, boolean ifUnused, boolean ifEmpty) throws IOException - { - delegate.queueDeleteNoWait(queue, ifUnused, ifEmpty); - } - - @Override - public AMQP.Queue.BindOk queueBind(String queue, String exchange, String routingKey) throws IOException - { - return delegate.queueBind(queue, exchange, routingKey); - } - - @Override - public AMQP.Queue.BindOk queueBind(String queue, String exchange, String routingKey, Map arguments) throws IOException - { - return delegate.queueBind(queue, exchange, routingKey, arguments); - } - - @Override - public void queueBindNoWait(String queue, String exchange, String routingKey, Map arguments) throws IOException - { - delegate.queueBindNoWait(queue, exchange, routingKey, arguments); - } - - @Override - public AMQP.Queue.UnbindOk queueUnbind(String queue, String exchange, String routingKey) throws IOException - { - return delegate.queueUnbind(queue, exchange, routingKey); - } - - @Override - public AMQP.Queue.UnbindOk queueUnbind(String queue, String exchange, String routingKey, Map arguments) throws IOException - { - return delegate.queueUnbind(queue, exchange, routingKey, arguments); - } - - @Override - public AMQP.Queue.PurgeOk queuePurge(String queue) throws IOException - { - return delegate.queuePurge(queue); - } - - @Override - public GetResponse basicGet(String queue, boolean autoAck) throws IOException - { - return delegate.basicGet(queue, autoAck); - } - - @Override - public void basicAck(long deliveryTag, boolean multiple) throws IOException - { - delegate.basicAck(deliveryTag, multiple); - } - - @Override - public void basicNack(long deliveryTag, boolean multiple, boolean requeue) throws IOException - { - delegate.basicNack(deliveryTag, multiple, requeue); - } - - @Override - public void basicReject(long deliveryTag, boolean requeue) throws IOException - { - delegate.basicReject(deliveryTag, requeue); - } - - private void removeListeners() - { - delegate.removeShutdownListener(shutdownListener); - delegate.removeReturnListener(returnListener); - delegate.removeConfirmListener(confirmListener); - } - - private BasicProperties addSequenceNumberHeader(BasicProperties props, long seqNo) - { - Map headers = props.getHeaders(); - if (headers == null) - { - headers = new HashMap<>(); - } - else - { - headers = new HashMap<>(headers); - } - headers.put(SEQUENCE_NUMBER_HEADER, seqNo); - return props.builder().headers(headers).build(); - } - - private void handleAck(long seqNo, boolean multiple) - { - handleAckNack(seqNo, multiple, false); - } - - private void handleNack(long seqNo, boolean multiple) - { - handleAckNack(seqNo, multiple, true); - } - - private void handleAckNack(long seqNo, boolean multiple, boolean nack) - { - if (multiple) - { - for (Long seq : new ArrayList<>(confirmations.keySet())) - { - if (seq <= seqNo) - { - ConfirmationEntry entry = confirmations.remove(seq); - if (entry != null) - { - if (nack) - { - entry.completeExceptionally(seq); - } - else - { - entry.complete(); - } - entry.releasePermit(); - } - } - } - } - else - { - ConfirmationEntry entry = confirmations.remove(seqNo); - if (entry != null) - { - if (nack) - { - entry.completeExceptionally(seqNo); - } - else - { - entry.complete(); - } - entry.releasePermit(); - } - } - } - - private void handleReturn(int replyCode, String replyText, String exchange, - String routingKey, BasicProperties props, byte[] body) - { - Object seqNumObj = props.getHeaders().get(SEQUENCE_NUMBER_HEADER); - long seqNo = extractSequenceNumber(seqNumObj); - - ConfirmationEntry entry = confirmations.remove(seqNo); - if (entry != null) - { - entry.completeExceptionally(new PublishException(seqNo, true, - exchange, routingKey, replyCode, replyText, entry.context)); - entry.releasePermit(); - } - } - - private void handleShutdown(ShutdownSignalException cause) - { - AlreadyClosedException ex = new AlreadyClosedException(cause); - for (ConfirmationEntry entry : confirmations.values()) - { - entry.completeExceptionally(ex); - entry.releasePermit(); - } - confirmations.clear(); - } - - /** - * Extract sequence number from message header. - * NOTE: Since this library always writes the sequence number as a Long, - * the header value should always be a Long when read back from the broker. - * The additional type checks (Integer, String, byte[]) are defensive programming - * and should never be needed in practice. - */ - private long extractSequenceNumber(Object seqNumObj) - { - if (seqNumObj instanceof Long) - { - return (Long) seqNumObj; - } - else if (seqNumObj instanceof Integer) - { - return ((Integer) seqNumObj).longValue(); - } - else if (seqNumObj instanceof String) - { - return Long.parseLong((String) seqNumObj); - } - else if (seqNumObj instanceof byte[]) - { - return Long.parseLong(new String((byte[]) seqNumObj)); - } - return 0; - } - - private static class ConfirmationEntry - { - final CompletableFuture future; - final RateLimiter.Permit permit; - final T context; - - ConfirmationEntry(CompletableFuture future, RateLimiter.Permit permit, T context) - { - if (future == null) - { - throw new IllegalArgumentException("future must be non-null"); - } - this.future = future; - this.permit = permit; - this.context = context; - } - - void complete() - { - future.complete(context); - } - - void completeExceptionally(long seq) - { - PublishException ex = new PublishException(seq, this.context); - future.completeExceptionally(ex); - } - - void completeExceptionally(Exception e) - { - future.completeExceptionally(e); - } - - void releasePermit() - { - if (permit != null) - { - permit.release(); - } - } - } - -} diff --git a/src/test/java/com/rabbitmq/client/test/ThrottlingRateLimiterTest.java b/src/test/java/com/rabbitmq/client/test/ThrottlingRateLimiterTest.java deleted file mode 100644 index 2433738a30..0000000000 --- a/src/test/java/com/rabbitmq/client/test/ThrottlingRateLimiterTest.java +++ /dev/null @@ -1,236 +0,0 @@ -// Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. -// -// This software, the RabbitMQ Java client library, is triple-licensed under the -// Mozilla Public License 2.0 ("MPL"), the GNU General Public License version 2 -// ("GPL") and the Apache License version 2 ("ASL"). For the MPL, please see -// LICENSE-MPL-RabbitMQ. For the GPL, please see LICENSE-GPL2. For the ASL, -// please see LICENSE-APACHE2. -// -// This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, -// either express or implied. See the LICENSE file for specific language governing -// rights and limitations of this software. -// -// If you have any questions regarding licensing, please contact us at -// info@rabbitmq.com. - -package com.rabbitmq.client.test; - -import com.rabbitmq.client.ThrottlingRateLimiter; -import org.junit.jupiter.api.Test; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.*; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.jupiter.api.Assertions.*; - -public class ThrottlingRateLimiterTest -{ - - @Test public void testPermitAcquireAndRelease() throws InterruptedException - { - ThrottlingRateLimiter limiter = new ThrottlingRateLimiter(10, 50); - - assertEquals(10, limiter.getAvailablePermits()); - - ThrottlingRateLimiter.Permit permit = limiter.acquire(); - assertEquals(9, limiter.getAvailablePermits()); - - permit.release(); - assertEquals(10, limiter.getAvailablePermits()); - } - - @Test public void testMultipleAcquireAndRelease() throws InterruptedException - { - ThrottlingRateLimiter limiter = new ThrottlingRateLimiter(10, 50); - - List permits = new ArrayList<>(); - for (int i = 0; i < 5; i++) { - permits.add(limiter.acquire()); - } - - assertEquals(5, limiter.getAvailablePermits()); - - for (ThrottlingRateLimiter.Permit p : permits) { - p.release(); - } - - assertEquals(10, limiter.getAvailablePermits()); - } - - @Test public void testThrottlingOccursAboveThreshold() throws InterruptedException - { - ThrottlingRateLimiter limiter = new ThrottlingRateLimiter(10, 50); - - // Hold 6 permits (60% used, above 50% threshold) - List permits = new ArrayList<>(); - for (int i = 0; i < 6; i++) { - permits.add(limiter.acquire()); - } - - assertEquals(4, limiter.getAvailablePermits()); - - // Next acquire should throttle - verify with lenient timing - long start = System.currentTimeMillis(); - ThrottlingRateLimiter.Permit permit = limiter.acquire(); - long elapsed = System.currentTimeMillis() - start; - - assertTrue(elapsed > 10, "Should apply throttling delay, got " + elapsed + "ms"); - assertEquals(3, limiter.getAvailablePermits()); - - permit.release(); - for (ThrottlingRateLimiter.Permit p : permits) { - p.release(); - } - } - - @Test public void testHighThrottlingNearCapacity() throws InterruptedException - { - ThrottlingRateLimiter limiter = new ThrottlingRateLimiter(10, 50); - - // Hold 9 permits (90% used) - List permits = new ArrayList<>(); - for (int i = 0; i < 9; i++) { - permits.add(limiter.acquire()); - } - - assertEquals(1, limiter.getAvailablePermits()); - - // Should have significant delay - long start = System.currentTimeMillis(); - ThrottlingRateLimiter.Permit permit = limiter.acquire(); - long elapsed = System.currentTimeMillis() - start; - - assertTrue(elapsed > 500, "Should have significant delay near capacity, got " + elapsed + "ms"); - assertEquals(0, limiter.getAvailablePermits()); - - permit.release(); - for (ThrottlingRateLimiter.Permit p : permits) { - p.release(); - } - } - - @Test public void testThrottlingThresholds() throws InterruptedException - { - // 80% threshold - more permissive - ThrottlingRateLimiter limiter80 = new ThrottlingRateLimiter(10, 80); - - ThrottlingRateLimiter.Permit permit1 = limiter80.acquire(); - assertEquals(9, limiter80.getAvailablePermits()); - - ThrottlingRateLimiter.Permit permit2 = limiter80.acquire(); - assertEquals(8, limiter80.getAvailablePermits()); - - permit1.release(); - permit2.release(); - - // 20% threshold - more restrictive - ThrottlingRateLimiter limiter20 = new ThrottlingRateLimiter(10, 20); - - // Hold 9 permits (90% used, well above 20% threshold) - List permits = new ArrayList<>(); - for (int i = 0; i < 9; i++) { - permits.add(limiter20.acquire()); - } - - assertEquals(1, limiter20.getAvailablePermits()); - - // Should throttle - long start = System.currentTimeMillis(); - ThrottlingRateLimiter.Permit permit = limiter20.acquire(); - long elapsed = System.currentTimeMillis() - start; - - assertTrue(elapsed > 10, "Should throttle above 20% threshold, got " + elapsed + "ms"); - - permit.release(); - for (ThrottlingRateLimiter.Permit p : permits) { - p.release(); - } - } - - @Test public void testConcurrentAcquireAndRelease() throws InterruptedException, ExecutionException - { - ThrottlingRateLimiter limiter = new ThrottlingRateLimiter(50, 50); - ExecutorService executor = Executors.newFixedThreadPool(10); - AtomicInteger successCount = new AtomicInteger(0); - - List> futures = new ArrayList<>(); - for (int i = 0; i < 100; i++) { - futures.add(executor.submit(() -> { - try { - ThrottlingRateLimiter.Permit permit = limiter.acquire(); - successCount.incrementAndGet(); - Thread.sleep(10); - permit.release(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - })); - } - - for (Future future : futures) { - future.get(); - } - - executor.shutdown(); - assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)); - assertEquals(100, successCount.get()); - assertEquals(50, limiter.getAvailablePermits()); - } - - @Test public void testZeroThresholdBehavior() throws InterruptedException - { - ThrottlingRateLimiter limiter = new ThrottlingRateLimiter(10, 0); - - // 0% threshold means threshold = 0 - // Available = 10, threshold = 0, so 10 >= 0 = no throttle - ThrottlingRateLimiter.Permit permit = limiter.acquire(); - assertEquals(9, limiter.getAvailablePermits()); - permit.release(); - } - - @Test public void testHundredPercentThreshold() throws InterruptedException - { - ThrottlingRateLimiter limiter = new ThrottlingRateLimiter(10, 100); - - // 100% threshold means threshold = 10 - // Hold 9 permits: available = 1, threshold = 10, so 1 < 10 = throttle - List permits = new ArrayList<>(); - for (int i = 0; i < 9; i++) { - permits.add(limiter.acquire()); - } - - assertEquals(1, limiter.getAvailablePermits()); - - long start = System.currentTimeMillis(); - ThrottlingRateLimiter.Permit permit = limiter.acquire(); - long elapsed = System.currentTimeMillis() - start; - - assertTrue(elapsed > 500, "Should throttle with 100% threshold near capacity, got " + elapsed + "ms"); - - permit.release(); - for (ThrottlingRateLimiter.Permit p : permits) { - p.release(); - } - } - - @Test public void testGetStatistics() throws InterruptedException - { - ThrottlingRateLimiter limiter = new ThrottlingRateLimiter(10, 50); - - assertEquals(10, limiter.getAvailablePermits()); - - ThrottlingRateLimiter.Permit p1 = limiter.acquire(); - assertEquals(9, limiter.getAvailablePermits()); - - ThrottlingRateLimiter.Permit p2 = limiter.acquire(); - assertEquals(8, limiter.getAvailablePermits()); - - p1.release(); - assertEquals(9, limiter.getAvailablePermits()); - - p2.release(); - assertEquals(10, limiter.getAvailablePermits()); - } -} diff --git a/src/test/java/com/rabbitmq/client/test/functional/Confirm.java b/src/test/java/com/rabbitmq/client/test/functional/Confirm.java index 9d956a234d..1d6a6aade4 100644 --- a/src/test/java/com/rabbitmq/client/test/functional/Confirm.java +++ b/src/test/java/com/rabbitmq/client/test/functional/Confirm.java @@ -25,7 +25,7 @@ import com.rabbitmq.client.AMQP; import com.rabbitmq.client.AlreadyClosedException; import com.rabbitmq.client.Channel; -import com.rabbitmq.client.ConfirmationChannel; +import com.rabbitmq.client.ConfirmationPublisher; import com.rabbitmq.client.ConfirmListener; import com.rabbitmq.client.DefaultConsumer; import com.rabbitmq.client.GetResponse; @@ -35,10 +35,14 @@ import com.rabbitmq.client.test.BrokerTestCase; import java.io.IOException; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.SortedSet; import java.util.TreeSet; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.TimeoutException; import org.junit.jupiter.api.TestInfo; @@ -320,268 +324,161 @@ protected void publish(String exchangeName, String queueName, "nop".getBytes()); } - /** - * Tests basic publisher confirmation tracking with context parameter. - * Verifies that futures complete successfully with their context values when messages are confirmed. - */ - @Test public void testBasicPublishAsync() throws Exception { - Channel ch = connection.createChannel(); - ConfirmationChannel confirmCh = ConfirmationChannel.create(ch, null); - String queue = confirmCh.queueDeclare().getQueue(); + @Test public void publisherBasicConfirmation() throws Exception { + ConfirmationPublisher publisher = ConfirmationPublisher.create(connection); + String queue = channel.queueDeclare().getQueue(); int messageCount = 100; - java.util.List> futures = new java.util.ArrayList<>(); + List> futures = new ArrayList>(); for (int i = 0; i < messageCount; i++) { - futures.add(confirmCh.basicPublishAsync("", queue, null, ("msg" + i).getBytes(), i)); + futures.add(publisher.basicPublishAsync("", queue, null, ("msg" + i).getBytes(), i)); } - // Verify all futures complete with their context values for (int i = 0; i < messageCount; i++) { assertEquals(Integer.valueOf(i), futures.get(i).join()); } - assertEquals(messageCount, confirmCh.messageCount(queue)); - confirmCh.close(); + assertEquals(messageCount, channel.messageCount(queue)); + publisher.close(); } - /** - * Tests that unroutable messages with mandatory flag cause futures to complete exceptionally. - * Verifies PublishException contains correct return information (isReturn=true, replyCode=NO_ROUTE). - */ - @Test public void testBasicPublishAsyncWithReturn() throws Exception { - Channel ch = connection.createChannel(); - ConfirmationChannel confirmCh = ConfirmationChannel.create(ch, null); + @Test public void publisherMandatoryReturn() throws Exception { + ConfirmationPublisher publisher = ConfirmationPublisher.create(connection); - java.util.concurrent.CompletableFuture future = confirmCh.basicPublishAsync( - "", "nonexistent-queue", true, null, "test".getBytes(), null - ); + CompletableFuture future = publisher.basicPublishAsync( + "", "nonexistent-queue", true, null, "test".getBytes(), null); try { future.join(); fail("Expected PublishException"); - } catch (java.util.concurrent.CompletionException e) { + } catch (CompletionException e) { assertTrue(e.getCause() instanceof PublishException); PublishException pe = (PublishException) e.getCause(); assertTrue(pe.isReturn()); - assertEquals(AMQP.NO_ROUTE, pe.getReplyCode().intValue()); + assertEquals(AMQP.NO_ROUTE, pe.getReturned().getReplyCode()); } - confirmCh.close(); + publisher.close(); } - /** - * Tests rate limiting with ThrottlingRateLimiter. - * Verifies that messages are throttled to max 10 concurrent in-flight messages - * and all futures complete with correct context values. - */ - @Test public void testMaxOutstandingConfirms() throws Exception { - com.rabbitmq.client.ThrottlingRateLimiter limiter = - new com.rabbitmq.client.ThrottlingRateLimiter(10, 50); - Channel ch = connection.createChannel(); - ConfirmationChannel confirmCh = ConfirmationChannel.create(ch, limiter); - String queue = confirmCh.queueDeclare().getQueue(); - - java.util.concurrent.atomic.AtomicInteger completed = new java.util.concurrent.atomic.AtomicInteger(0); - java.util.List> futures = new java.util.ArrayList<>(); + @Test public void publisherMaxOutstanding() throws Exception { + ConfirmationPublisher publisher = ConfirmationPublisher.create(connection, 10); + String queue = channel.queueDeclare().getQueue(); + List> futures = new ArrayList>(); for (int i = 0; i < 50; i++) { - final int msgNum = i; - java.util.concurrent.CompletableFuture future = confirmCh.basicPublishAsync( - "", queue, null, ("msg" + i).getBytes(), i - ); - future.thenAccept(ctx -> { - assertEquals(Integer.valueOf(msgNum), ctx); - completed.incrementAndGet(); - }); - futures.add(future); + futures.add(publisher.basicPublishAsync("", queue, null, ("msg" + i).getBytes(), i)); + } + + for (int i = 0; i < 50; i++) { + assertEquals(Integer.valueOf(i), futures.get(i).join()); } - java.util.concurrent.CompletableFuture.allOf(futures.toArray(new java.util.concurrent.CompletableFuture[0])).join(); - assertEquals(50, completed.get()); - confirmCh.close(); + assertEquals(50, channel.messageCount(queue)); + publisher.close(); } - /** - * Tests that closing a channel with pending confirmations causes all futures to complete exceptionally. - * Verifies that AlreadyClosedException is thrown for in-flight messages when channel closes. - */ - @Test public void testBasicPublishAsyncChannelClose() throws Exception { - Channel ch = connection.createChannel(); - ConfirmationChannel confirmCh = ConfirmationChannel.create(ch, null); - String queue = confirmCh.queueDeclare().getQueue(); + @Test public void publisherCloseFailsOutstanding() throws Exception { + ConfirmationPublisher publisher = ConfirmationPublisher.create(connection); + String queue = channel.queueDeclare().getQueue(); - java.util.List> futures = new java.util.ArrayList<>(); + List> futures = new ArrayList>(); for (int i = 0; i < 10; i++) { - futures.add(confirmCh.basicPublishAsync("", queue, null, ("msg" + i).getBytes(), null)); + futures.add(publisher.basicPublishAsync("", queue, null, ("msg" + i).getBytes(), null)); } - confirmCh.close(); + publisher.close(); - for (java.util.concurrent.CompletableFuture future : futures) { + // Every future must settle after close: a graceful close completes + // confirmations that already arrived normally, and fails the rest with + // a ShutdownSignalException. None may hang. + for (CompletableFuture future : futures) { try { - future.join(); - } catch (java.util.concurrent.CompletionException e) { - assertTrue(e.getCause() instanceof AlreadyClosedException); + future.get(60, java.util.concurrent.TimeUnit.SECONDS); + } catch (java.util.concurrent.ExecutionException e) { + assertTrue(e.getCause() instanceof ShutdownSignalException); } } } - /** - * Tests that rate limiting introduces delay and all messages complete with correct context. - * Verifies ThrottlingRateLimiter limits concurrent in-flight messages and elapsed time is non-zero. - */ - @Test public void testBasicPublishAsyncWithThrottling() throws Exception { - com.rabbitmq.client.ThrottlingRateLimiter limiter = - new com.rabbitmq.client.ThrottlingRateLimiter(10, 50); - Channel ch = connection.createChannel(); - ConfirmationChannel confirmCh = ConfirmationChannel.create(ch, limiter); - String queue = confirmCh.queueDeclare().getQueue(); - - int messageCount = 50; - java.util.List> futures = new java.util.ArrayList<>(); - - long start = System.currentTimeMillis(); - for (int i = 0; i < messageCount; i++) { - String msgId = "msg-" + i; - futures.add(confirmCh.basicPublishAsync("", queue, null, ("message" + i).getBytes(), msgId)); - } - - // Verify all complete with correct context - for (int i = 0; i < messageCount; i++) { - assertEquals("msg-" + i, futures.get(i).join()); - } - - long elapsed = System.currentTimeMillis() - start; + @Test public void publisherContextCorrelation() throws Exception { + ConfirmationPublisher publisher = ConfirmationPublisher.create(connection); + String queue = channel.queueDeclare().getQueue(); - assertEquals(messageCount, confirmCh.messageCount(queue)); - assertTrue(elapsed > 0, "Throttling should introduce some delay"); - confirmCh.close(); - } + int messageCount = 10; + List> futures = new ArrayList>(); - /** - * Tests performance comparison between throttled and unlimited channels. - * Verifies both complete successfully and throttling introduces measurable delay. - */ - @Test public void testBasicPublishAsyncThrottlingVsUnlimited() throws Exception { - // Test with throttling (10 permits, 50% threshold) - com.rabbitmq.client.ThrottlingRateLimiter limiter = - new com.rabbitmq.client.ThrottlingRateLimiter(10, 50); - Channel throttlingCh = connection.createChannel(); - ConfirmationChannel throttlingConfirmCh = ConfirmationChannel.create(throttlingCh, limiter); - String queue1 = throttlingConfirmCh.queueDeclare().getQueue(); - - int messageCount = 4096; - java.util.List> futures = new java.util.ArrayList<>(); - - long start = System.currentTimeMillis(); for (int i = 0; i < messageCount; i++) { - futures.add(throttlingConfirmCh.basicPublishAsync("", queue1, null, ("msg" + i).getBytes(), null)); + String correlationId = "msg-" + i; + futures.add(publisher.basicPublishAsync("", queue, null, + ("message" + i).getBytes(), correlationId)); } - java.util.concurrent.CompletableFuture.allOf(futures.toArray(new java.util.concurrent.CompletableFuture[0])).join(); - long throttlingElapsed = System.currentTimeMillis() - start; - assertEquals(messageCount, throttlingConfirmCh.messageCount(queue1)); - throttlingConfirmCh.close(); - - // Test with unlimited (no rate limiter) - Channel unlimitedCh = connection.createChannel(); - ConfirmationChannel unlimitedConfirmCh = ConfirmationChannel.create(unlimitedCh, null); - String queue2 = unlimitedConfirmCh.queueDeclare().getQueue(); - - futures.clear(); - start = System.currentTimeMillis(); for (int i = 0; i < messageCount; i++) { - futures.add(unlimitedConfirmCh.basicPublishAsync("", queue2, null, ("msg" + i).getBytes(), null)); + assertEquals("msg-" + i, futures.get(i).join()); } - java.util.concurrent.CompletableFuture.allOf(futures.toArray(new java.util.concurrent.CompletableFuture[0])).join(); - long unlimitedElapsed = System.currentTimeMillis() - start; - assertEquals(messageCount, unlimitedConfirmCh.messageCount(queue2)); - unlimitedConfirmCh.close(); - - // Both should complete successfully - assertTrue(throttlingElapsed > 0); - assertTrue(unlimitedElapsed > 0); + assertEquals(messageCount, channel.messageCount(queue)); + publisher.close(); } - /** - * Tests that ConfirmationChannel works correctly without a rate limiter. - * Verifies all messages are confirmed when rateLimiter is null (unlimited concurrency). - */ - @Test public void testBasicPublishAsyncWithNullRateLimiter() throws Exception { - Channel ch = connection.createChannel(); - ConfirmationChannel confirmCh = ConfirmationChannel.create(ch, null); - String queue = confirmCh.queueDeclare().getQueue(); + @Test public void publisherContextInException() throws Exception { + ConfirmationPublisher publisher = ConfirmationPublisher.create(connection); - int messageCount = 100; - java.util.List> futures = new java.util.ArrayList<>(); + String messageId = "unroutable-msg-456"; + CompletableFuture future = publisher.basicPublishAsync( + "", "nonexistent-queue", true, null, "test".getBytes(), messageId); - for (int i = 0; i < messageCount; i++) { - futures.add(confirmCh.basicPublishAsync("", queue, null, ("msg" + i).getBytes(), null)); + try { + future.join(); + fail("Expected PublishException"); + } catch (CompletionException e) { + assertTrue(e.getCause() instanceof PublishException); + PublishException pe = (PublishException) e.getCause(); + assertTrue(pe.isReturn()); + assertEquals(AMQP.NO_ROUTE, pe.getReturned().getReplyCode()); + assertEquals(messageId, pe.getContext()); } - java.util.concurrent.CompletableFuture.allOf(futures.toArray(new java.util.concurrent.CompletableFuture[0])).join(); - - assertEquals(messageCount, confirmCh.messageCount(queue)); - confirmCh.close(); + publisher.close(); } - /** - * Tests context parameter correlation with String correlation IDs. - * Verifies that each future completes with its exact correlation ID for message tracking. - */ - @Test public void testBasicPublishAsyncWithContext() throws Exception { - Channel ch = connection.createChannel(); - ConfirmationChannel confirmCh = ConfirmationChannel.create(ch, null); - String queue = confirmCh.queueDeclare().getQueue(); + @Test public void publisherSurvivesConnectionRecovery() throws Exception { + ConfirmationPublisher publisher = ConfirmationPublisher.create(connection); + String queue = channel.queueDeclare().getQueue(); - int messageCount = 10; - java.util.Map> futuresByCorrelationId = new java.util.HashMap<>(); - - for (int i = 0; i < messageCount; i++) { - String correlationId = "msg-" + i; - java.util.concurrent.CompletableFuture future = confirmCh.basicPublishAsync( - "", queue, null, ("message" + i).getBytes(), correlationId - ); - futuresByCorrelationId.put(correlationId, future); + for (int i = 0; i < 10; i++) { + publisher.basicPublishAsync("", queue, null, ("before" + i).getBytes(), i).join(); } - // Verify all futures complete with their correlation IDs - for (java.util.Map.Entry> entry : futuresByCorrelationId.entrySet()) { - String expectedId = entry.getKey(); - String actualId = entry.getValue().join(); - assertEquals(expectedId, actualId); + com.rabbitmq.client.test.TestUtils.closeAndWaitForRecovery( + (com.rabbitmq.client.RecoverableConnection) connection); + + int confirmed = 0; + for (int i = 0; i < 10; i++) { + publisher.basicPublishAsync("", queue, null, ("after" + i).getBytes(), i).join(); + confirmed++; } - assertEquals(messageCount, confirmCh.messageCount(queue)); - confirmCh.close(); + assertEquals(10, confirmed); + publisher.close(); } - /** - * Tests that context parameter is available in PublishException for failed publishes. - * Verifies context is preserved when message is returned as unroutable. - */ - @Test public void testBasicPublishAsyncWithContextInException() throws Exception { - Channel ch = connection.createChannel(); - ConfirmationChannel confirmCh = ConfirmationChannel.create(ch, null); + @Test public void publisherUnlimited() throws Exception { + ConfirmationPublisher publisher = ConfirmationPublisher.create(connection); + String queue = channel.queueDeclare().getQueue(); - String messageId = "unroutable-msg-456"; - java.util.concurrent.CompletableFuture future = confirmCh.basicPublishAsync( - "", "nonexistent-queue", true, null, "test".getBytes(), messageId - ); + int messageCount = 1000; + List> futures = new ArrayList>(); - try { - future.join(); - fail("Expected PublishException"); - } catch (java.util.concurrent.CompletionException e) { - assertTrue(e.getCause() instanceof PublishException); - PublishException pe = (PublishException) e.getCause(); - assertTrue(pe.isReturn()); - assertEquals(AMQP.NO_ROUTE, pe.getReplyCode().intValue()); - assertEquals(messageId, pe.getContext()); + for (int i = 0; i < messageCount; i++) { + futures.add(publisher.basicPublishAsync("", queue, null, ("msg" + i).getBytes(), null)); } - confirmCh.close(); + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + + assertEquals(messageCount, channel.messageCount(queue)); + publisher.close(); } } From 1757ed35dfe85663cf39fbdf6b3c3685a33d01ca Mon Sep 17 00:00:00 2001 From: Luke Bakken Date: Sun, 12 Jul 2026 14:03:33 -0700 Subject: [PATCH 3/3] Test that confirmations complete when the executor rejects A ConfirmationPublisher created with a user-supplied executor that rejects every task must still complete its futures, inline on the connection thread, rather than leaving callers blocked forever. --- .../client/test/functional/Confirm.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/test/java/com/rabbitmq/client/test/functional/Confirm.java b/src/test/java/com/rabbitmq/client/test/functional/Confirm.java index 1d6a6aade4..745f1700e9 100644 --- a/src/test/java/com/rabbitmq/client/test/functional/Confirm.java +++ b/src/test/java/com/rabbitmq/client/test/functional/Confirm.java @@ -465,6 +465,29 @@ protected void publish(String exchangeName, String queueName, publisher.close(); } + @Test public void publisherCompletesWhenExecutorRejects() throws Exception { + // An executor that rejects every task: confirmations must still complete + // (inline, on the connection thread) rather than hang forever. + java.util.concurrent.Executor rejecting = command -> { + throw new java.util.concurrent.RejectedExecutionException("always rejects"); + }; + ConfirmationPublisher publisher = ConfirmationPublisher.create(connection, 100, rejecting); + String queue = channel.queueDeclare().getQueue(); + + int messageCount = 20; + List> futures = new ArrayList>(); + for (int i = 0; i < messageCount; i++) { + futures.add(publisher.basicPublishAsync("", queue, null, ("msg" + i).getBytes(), i)); + } + + for (int i = 0; i < messageCount; i++) { + assertEquals(Integer.valueOf(i), + futures.get(i).get(60, java.util.concurrent.TimeUnit.SECONDS)); + } + + publisher.close(); + } + @Test public void publisherUnlimited() throws Exception { ConfirmationPublisher publisher = ConfirmationPublisher.create(connection); String queue = channel.queueDeclare().getQueue();