This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 8d15f037397 NIFI-15483: Fixed PublishAMQP routing FlowFiles to success 
when broker cannot deliver message
8d15f037397 is described below

commit 8d15f03739761a777b69d743b3c3cb7e80de8f60
Author: Rakesh Kumar Singh <[email protected]>
AuthorDate: Wed May 6 16:01:59 2026 +0530

    NIFI-15483: Fixed PublishAMQP routing FlowFiles to success when broker 
cannot deliver message
    
    PublishAMQP uses mandatory=true on basicPublish() so the broker returns
    messages it cannot route to any queue. However, the return arrives
    asynchronously via ReturnListener.handleReturn() on the AMQP I/O thread
    while the publishing thread had already moved on to 
session.transfer(REL_SUCCESS).
    The UndeliverableMessageLogger only logged a warning — it never signaled
    failure back to publish() or onTrigger(), so every unroutable message was
    silently counted as a success despite never reaching any consumer.
    
    Fix:
    - Enabled Publisher Confirms (channel.confirmSelect()) in the constructor.
      The broker's basic.return frame for an unroutable message is guaranteed
      to arrive before the corresponding confirm frame, so waitForConfirms()
      acts as a synchronization barrier that makes return detection reliable.
    - Added an AtomicReference<String> field (undeliverableReturnReason) that
      UndeliverableMessageLogger.handleReturn() populates with 
exchange/routingKey/
      replyCode/replyText when a message is returned.
    - publish() now: resets the field before each call, calls 
waitForConfirms(5s)
      to synchronize with the broker, then checks the field and throws 
AMQPException
      if the message was returned — causing onTrigger() to route to REL_FAILURE.
    - Broker NACKs (e.g., resource alarm) are also now surfaced as AMQPException
      because waitForConfirms() returns false on NACK.
    
    - Added regression tests to verify that AMQPPublisher and PublishAMQP 
correctly
      route FlowFiles to REL_FAILURE for all broker-side failure modes:
    
    - Added ShutdownSignalException to the catch block in 
AMQPPublisher.publish()
    - Converts the channel-close signal into AMQPException so PublishAMQP routes
       the FlowFile to REL_FAILURE with a descriptive error message
    - Added ShutdownSignalException import
    
    NIFI-15483: Added Delivery Guarantee property to make Publisher Confirms 
opt-in
    
    Added a new Delivery Guarantee property to PublishAMQP with two options:
    
    At most once (default): works like the original - sends the message without
    waiting for a broker reply. If the message cannot be delivered, only a 
warning
    is logged and the FlowFile routes to success. Best for high throughput.
    
    At least once: turns on RabbitMQ Publisher Confirms. The processor waits for
    the broker to confirm the message before routing. If the message is returned
    or the broker sends a NACK, the FlowFile routes to failure instead of 
success.
    This prevents silent data loss but can be much slower, especially with 
remote
    brokers.
    
    This closes #11213.
    
    Signed-off-by: Peter Turcsanyi <[email protected]>
---
 .../apache/nifi/amqp/processors/AMQPPublisher.java | 87 ++++++++++++++++++----
 .../apache/nifi/amqp/processors/PublishAMQP.java   | 53 ++++++++++++-
 .../nifi/amqp/processors/AMQPPublisherTest.java    | 73 ++++++++++++++++--
 .../nifi/amqp/processors/ConsumeAMQPTest.java      | 20 ++---
 .../nifi/amqp/processors/PublishAMQPTest.java      | 36 +++++++++
 .../apache/nifi/amqp/processors/TestChannel.java   | 42 +++++++++--
 .../nifi/amqp/processors/TestConnection.java       |  4 +
 7 files changed, 279 insertions(+), 36 deletions(-)

diff --git 
a/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/main/java/org/apache/nifi/amqp/processors/AMQPPublisher.java
 
b/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/main/java/org/apache/nifi/amqp/processors/AMQPPublisher.java
index 27a64bbb1ff..fdb1b6ff720 100644
--- 
a/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/main/java/org/apache/nifi/amqp/processors/AMQPPublisher.java
+++ 
b/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/main/java/org/apache/nifi/amqp/processors/AMQPPublisher.java
@@ -20,10 +20,13 @@ import com.rabbitmq.client.AMQP.BasicProperties;
 import com.rabbitmq.client.AlreadyClosedException;
 import com.rabbitmq.client.Connection;
 import com.rabbitmq.client.ReturnListener;
+import com.rabbitmq.client.ShutdownSignalException;
 import org.apache.nifi.logging.ComponentLog;
 
 import java.io.IOException;
 import java.net.SocketException;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicReference;
 
 /**
  * Generic publisher of messages to AMQP-based messaging system. It is based on
@@ -32,17 +35,40 @@ import java.net.SocketException;
 final class AMQPPublisher extends AMQPWorker {
 
     private final String connectionString;
+    private final boolean useConfirms;
+
+    /**
+     * Stores the broker's return reason when a message is published with 
mandatory=true
+     * but the broker cannot route it to any queue. Written by the AMQP I/O 
thread via
+     * {@link UndeliverableMessageLogger} and read by the publishing thread 
after
+     * {@link com.rabbitmq.client.Channel#waitForConfirms} synchronizes the 
two.
+     * Only populated when {@link #useConfirms} is true.
+     */
+    private final AtomicReference<String> undeliverableReturnReason = new 
AtomicReference<>(null);
 
     /**
      * Creates an instance of this publisher
      *
      * @param connection instance of AMQP {@link Connection}
+     * @param useConfirms when true, enables RabbitMQ Publisher Confirms so 
that
+     *                    {@link #publish} waits for a broker ack/nack and 
reliably
+     *                    detects undeliverable messages; when false, the 
original
+     *                    fire-and-forget behaviour is used for maximum 
throughput
      */
-    AMQPPublisher(Connection connection, ComponentLog processorLog) {
+    AMQPPublisher(Connection connection, ComponentLog processorLog, boolean 
useConfirms) {
         super(connection, processorLog);
+        this.useConfirms = useConfirms;
         getChannel().addReturnListener(new UndeliverableMessageLogger());
         this.connectionString = connection.toString();
 
+        if (useConfirms) {
+            try {
+                getChannel().confirmSelect();
+            } catch (final IOException e) {
+                throw new AMQPException("Failed to enable Publisher Confirms 
on AMQP channel", e);
+            }
+        }
+
         processorLog.info("Successfully connected AMQPPublisher to {}", 
this.connectionString);
     }
 
@@ -68,6 +94,9 @@ final class AMQPPublisher extends AMQPWorker {
             processorLog.debug("Successfully connected AMQPPublisher to {} and 
'{}' exchange with '{}' as a routing key.", this.connectionString, exchange, 
routingKey);
         }
 
+        // Reset any stale return reason from a previous publish before 
sending.
+        undeliverableReturnReason.set(null);
+
         try {
             getChannel().basicPublish(exchange, routingKey, true, properties, 
bytes);
         } catch (AlreadyClosedException | SocketException e) {
@@ -75,6 +104,33 @@ final class AMQPPublisher extends AMQPWorker {
         } catch (Exception e) {
             throw new AMQPException("Failed to publish message to Exchange '" 
+ exchange + "' with Routing Key '" + routingKey + "'.", e);
         }
+
+        if (useConfirms) {
+            // Wait for the broker's publish confirm (ack/nack). Because the 
broker sends a basic.return
+            // frame BEFORE the corresponding confirm frame for mandatory 
messages it cannot route,
+            // UndeliverableMessageLogger.handleReturn() is guaranteed to have 
run by the time
+            // waitForConfirms() returns. This makes undeliverable-message 
detection reliable.
+            try {
+                if (!getChannel().waitForConfirms(5_000L)) {
+                    throw new AMQPException("Broker negatively acknowledged 
(NACK) message published to Exchange '"
+                            + exchange + "' with Routing Key '" + routingKey + 
"'");
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new AMQPException("Interrupted while waiting for publish 
confirmation from broker", e);
+            } catch (TimeoutException e) {
+                throw new AMQPException("Timed out waiting for publish 
confirmation from broker for Exchange '"
+                        + exchange + "' with Routing Key '" + routingKey + 
"'", e);
+            } catch (ShutdownSignalException e) {
+                throw new AMQPException("Broker closed channel while waiting 
for publish confirmation — "
+                        + "Exchange '" + exchange + "' may not exist: " + 
e.getMessage(), e);
+            }
+
+            final String returnReason = undeliverableReturnReason.get();
+            if (returnReason != null) {
+                throw new AMQPException(returnReason);
+            }
+        }
     }
 
     @Override
@@ -83,23 +139,26 @@ final class AMQPPublisher extends AMQPWorker {
     }
 
     /**
-     * Listener to listen and WARN-log undeliverable messages which are 
returned
-     * back to the sender. Since in the current implementation messages are 
sent
-     * with 'mandatory' bit set, such messages must have final destination
-     * otherwise they are silently dropped which could cause a confusion
-     * especially during early stages of flow development. This implies that
-     * bindings between exchange -> routingKey -> queue must exist and are
-     * typically done by AMQP administrator. This logger simply helps to 
monitor
-     * for such conditions by logging such messages as warning. In the future
-     * this can be extended to provide other type of functionality (e.g., fail
-     * processor etc.)
+     * Listens for messages returned by the broker when they cannot be routed 
to any queue
+     * (mandatory=true publish with no matching binding).
+     *
+     * In {@link PublishAMQP.DeliveryGuarantee#AT_MOST_ONCE} mode (the 
default), this listener
+     * only logs a warning — matching the original behaviour.
+     *
+     * In {@link PublishAMQP.DeliveryGuarantee#AT_LEAST_ONCE} mode, the return 
reason is also
+     * stored in {@link #undeliverableReturnReason} so that {@link #publish} 
can detect it after
+     * {@code waitForConfirms()} synchronizes the two threads and throw an 
{@link AMQPException}
+     * to trigger REL_FAILURE routing.
      */
     private final class UndeliverableMessageLogger implements ReturnListener {
         @Override
         public void handleReturn(int replyCode, String replyText, String 
exchangeName, String routingKey, BasicProperties properties, byte[] message) 
throws IOException {
-            String logMessage = "Message destined for '" + exchangeName + "' 
exchange with '" + routingKey
-                    + "' as routing key came back with replyCode=" + replyCode 
+ " and replyText=" + replyText + ".";
-            processorLog.warn(logMessage);
+            final String reason = "Message returned as undeliverable by 
broker: exchange='" + exchangeName
+                    + "' routingKey='" + routingKey + "' replyCode=" + 
replyCode + " replyText='" + replyText + "'";
+            if (useConfirms) {
+                undeliverableReturnReason.set(reason);
+            }
+            processorLog.warn(reason);
         }
     }
 }
diff --git 
a/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/main/java/org/apache/nifi/amqp/processors/PublishAMQP.java
 
b/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/main/java/org/apache/nifi/amqp/processors/PublishAMQP.java
index 97bc07a6f71..20ffe02f174 100644
--- 
a/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/main/java/org/apache/nifi/amqp/processors/PublishAMQP.java
+++ 
b/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/main/java/org/apache/nifi/amqp/processors/PublishAMQP.java
@@ -95,6 +95,19 @@ public class PublishAMQP extends 
AbstractAMQPProcessor<AMQPPublisher> {
             
.expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES)
             .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
             .build();
+    public static final PropertyDescriptor DELIVERY_GUARANTEE = new 
PropertyDescriptor.Builder()
+            .name("Delivery Guarantee")
+            .description("Controls whether the processor waits for a publish 
confirmation (broker ack/nack) before routing the FlowFile. "
+                    + "\"At least once\" enables RabbitMQ Publisher Confirms: 
the processor blocks until the broker acknowledges the message, "
+                    + "and undeliverable messages (no matching queue binding) 
are reliably routed to 'failure'. "
+                    + "This prevents silent data loss at the cost of 
significantly higher latency, especially with remote brokers. "
+                    + "\"At most once\" uses the original fire-and-forget 
mode: the message is sent without waiting for confirmation. "
+                    + "Undeliverable messages are only logged as a warning and 
the FlowFile is still routed to 'success'. "
+                    + "This mode offers maximum throughput but provides no 
delivery guarantee.")
+            .required(true)
+            .allowableValues(DeliveryGuarantee.class)
+            .defaultValue(DeliveryGuarantee.AT_MOST_ONCE)
+            .build();
     public static final PropertyDescriptor HEADERS_SOURCE = new 
PropertyDescriptor.Builder()
             .name("Headers Source")
             .description("The source of the headers which will be applied to 
the published message.")
@@ -136,6 +149,7 @@ public class PublishAMQP extends 
AbstractAMQPProcessor<AMQPPublisher> {
             Stream.of(
                     EXCHANGE,
                     ROUTING_KEY,
+                    DELIVERY_GUARANTEE,
                     HEADERS_SOURCE,
                     HEADERS_PATTERN,
                     HEADER_SEPARATOR
@@ -207,7 +221,8 @@ public class PublishAMQP extends 
AbstractAMQPProcessor<AMQPPublisher> {
 
     @Override
     protected AMQPPublisher createAMQPWorker(final ProcessContext context, 
final Connection connection) {
-        return new AMQPPublisher(connection, getLogger());
+        final boolean useConfirms = DeliveryGuarantee.AT_LEAST_ONCE == 
context.getProperty(DELIVERY_GUARANTEE).asAllowableValue(DeliveryGuarantee.class);
+        return new AMQPPublisher(connection, getLogger(), useConfirms);
     }
 
     @Override
@@ -346,6 +361,42 @@ public class PublishAMQP extends 
AbstractAMQPProcessor<AMQPPublisher> {
         };
     }
 
+    public enum DeliveryGuarantee implements DescribedValue {
+
+        AT_MOST_ONCE("At most once",
+                "Fire-and-forget: message is sent without waiting for a broker 
acknowledgement. "
+                + "Undeliverable messages (no matching queue binding) are 
logged as a warning and "
+                + "the FlowFile is routed to 'success'. Offers maximum 
throughput."),
+        AT_LEAST_ONCE("At least once",
+                "Publisher Confirms are enabled: the processor blocks until 
the broker acknowledges "
+                + "the message (ack or nack). Undeliverable messages are 
reliably detected and routed "
+                + "to 'failure'. Prevents silent data loss at the cost of 
higher latency, particularly "
+                + "with remote brokers.");
+
+        private final String displayName;
+        private final String description;
+
+        DeliveryGuarantee(final String displayName, final String description) {
+            this.displayName = displayName;
+            this.description = description;
+        }
+
+        @Override
+        public String getValue() {
+            return name();
+        }
+
+        @Override
+        public String getDisplayName() {
+            return displayName;
+        }
+
+        @Override
+        public String getDescription() {
+            return description;
+        }
+    }
+
     public enum InputHeaderSource implements DescribedValue {
 
         FLOWFILE_ATTRIBUTES("FlowFile Attributes", "Select FlowFile Attributes 
based on regular expression pattern for event headers. Key of the matching 
attribute will be used as header key"),
diff --git 
a/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/AMQPPublisherTest.java
 
b/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/AMQPPublisherTest.java
index 84d7c5528f5..24fa039b07a 100644
--- 
a/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/AMQPPublisherTest.java
+++ 
b/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/AMQPPublisherTest.java
@@ -37,17 +37,16 @@ import static org.mockito.Mockito.verify;
 
 public class AMQPPublisherTest {
 
-    @SuppressWarnings("resource")
     @Test
     public void failOnNullConnection() {
-        assertThrows(IllegalArgumentException.class, () -> new 
AMQPPublisher(null, null));
+        assertThrows(IllegalArgumentException.class, () -> new 
AMQPPublisher(null, null, false));
     }
 
     @Test
     public void failPublishIfChannelClosed() {
         assertThrows(AMQPRollbackException.class, () -> {
             Connection conn = new TestConnection(null, null);
-            try (AMQPPublisher sender = new AMQPPublisher(conn, 
mock(ComponentLog.class))) {
+            try (AMQPPublisher sender = new AMQPPublisher(conn, 
mock(ComponentLog.class), false)) {
                 conn.close();
                 sender.publish("oleg".getBytes(), null, "foo", "");
             }
@@ -58,7 +57,7 @@ public class AMQPPublisherTest {
     public void failPublishIfChannelFails() {
         assertThrows(AMQPException.class, () -> {
             TestConnection conn = new TestConnection(null, null);
-            try (AMQPPublisher sender = new AMQPPublisher(conn, 
mock(ComponentLog.class))) {
+            try (AMQPPublisher sender = new AMQPPublisher(conn, 
mock(ComponentLog.class), false)) {
                 ((TestChannel) conn.createChannel()).corruptChannel();
                 sender.publish("oleg".getBytes(), null, "foo", "");
             }
@@ -74,7 +73,7 @@ public class AMQPPublisherTest {
 
         Connection connection = new TestConnection(exchangeToRoutingKeymap, 
routingMap);
 
-        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class))) {
+        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class), false)) {
             sender.publish("hello".getBytes(), null, "key1", "myExchange");
         }
 
@@ -96,7 +95,7 @@ public class AMQPPublisherTest {
         ReturnListener retListener = mock(ReturnListener.class);
         connection.createChannel().addReturnListener(retListener);
 
-        try (AMQPPublisher sender = new AMQPPublisher(connection, new 
MockComponentLog("foo", ""))) {
+        try (AMQPPublisher sender = new AMQPPublisher(connection, new 
MockComponentLog("foo", ""), false)) {
             sender.publish("hello".getBytes(), null, "key1", "myExchange");
         }
 
@@ -105,4 +104,66 @@ public class AMQPPublisherTest {
         connection.close();
     }
 
+    /**
+     * Verifies that a {@link com.rabbitmq.client.ShutdownSignalException} 
thrown by
+     * {@code waitForConfirms()} (e.g., broker closes channel with 404 
NOT_FOUND because the
+     * exchange does not exist) is converted to {@link AMQPException} so the 
FlowFile routes
+     * to REL_FAILURE instead of surfacing as an unhandled processor error.
+     */
+    @Test
+    public void failPublishWhenBrokerClosesChannelDuringConfirmInAtLeastOnce() 
{
+        assertThrows(AMQPException.class, () -> {
+            TestConnection conn = new TestConnection(null, null);
+            conn.getTestChannel().setSimulateShutdownOnConfirm(true);
+            try (AMQPPublisher sender = new AMQPPublisher(conn, 
mock(ComponentLog.class), true)) {
+                sender.publish("hello".getBytes(), null, "foo", "");
+            }
+        });
+    }
+
+    @Test
+    public void failPublishWhenBrokerNacksMessageInAtLeastOnce() {
+        assertThrows(AMQPException.class, () -> {
+            TestConnection conn = new TestConnection(null, null);
+            conn.getTestChannel().setSimulateNackOnConfirm(true);
+            try (AMQPPublisher sender = new AMQPPublisher(conn, 
mock(ComponentLog.class), true)) {
+                sender.publish("hello".getBytes(), null, "foo", "");
+            }
+        });
+    }
+
+    @Test
+    public void failPublishWhenMessageReturnedAsUndeliverableInAtLeastOnce() {
+        assertThrows(AMQPException.class, () -> {
+            Map<String, List<String>> routingMap = new HashMap<>();
+            routingMap.put("key1", Arrays.asList("queue1"));
+            Map<String, String> exchangeToRoutingKeymap = new HashMap<>();
+            exchangeToRoutingKeymap.put("myExchange", "key1");
+
+            TestConnection conn = new TestConnection(exchangeToRoutingKeymap, 
routingMap);
+            conn.getTestChannel().setSimulateSynchronousReturn(true);
+
+            try (AMQPPublisher sender = new AMQPPublisher(conn, new 
MockComponentLog("id", ""), true)) {
+                sender.publish("hello".getBytes(), null, "wrongKey", 
"myExchange");
+            }
+        });
+    }
+
+    @Test
+    public void succeedsPublishWhenMessageUndeliverableInAtMostOnceMode() 
throws Exception {
+        Map<String, List<String>> routingMap = new HashMap<>();
+        routingMap.put("key1", Arrays.asList("queue1"));
+        Map<String, String> exchangeToRoutingKeymap = new HashMap<>();
+        exchangeToRoutingKeymap.put("myExchange", "key1");
+
+        TestConnection conn = new TestConnection(exchangeToRoutingKeymap, 
routingMap);
+        conn.getTestChannel().setSimulateSynchronousReturn(true);
+
+        try (AMQPPublisher sender = new AMQPPublisher(conn, new 
MockComponentLog("id", ""), false)) {
+            // In AT_MOST_ONCE mode, undeliverable messages only produce a 
warning — no exception
+            sender.publish("hello".getBytes(), null, "wrongKey", "myExchange");
+        }
+        conn.close();
+    }
+
 }
diff --git 
a/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/ConsumeAMQPTest.java
 
b/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/ConsumeAMQPTest.java
index 6da28a4dbff..1081c517ccd 100644
--- 
a/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/ConsumeAMQPTest.java
+++ 
b/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/ConsumeAMQPTest.java
@@ -56,7 +56,7 @@ public class ConsumeAMQPTest {
 
         final Connection connection = new 
TestConnection(exchangeToRoutingKeymap, routingMap);
 
-        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class))) {
+        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class), false)) {
             sender.publish("hello".getBytes(), 
MessageProperties.PERSISTENT_TEXT_PLAIN, "key1", "myExchange");
             sender.publish("world".getBytes(), 
MessageProperties.PERSISTENT_TEXT_PLAIN, "key1", "myExchange");
 
@@ -87,7 +87,7 @@ public class ConsumeAMQPTest {
 
         final Connection connection = new 
TestConnection(exchangeToRoutingKeymap, routingMap);
 
-        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class))) {
+        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class), false)) {
             sender.publish("hello".getBytes(), 
MessageProperties.PERSISTENT_TEXT_PLAIN, "key1", "myExchange");
             sender.publish("world".getBytes(), 
MessageProperties.PERSISTENT_TEXT_PLAIN, "key1", "myExchange");
 
@@ -118,7 +118,7 @@ public class ConsumeAMQPTest {
 
         final Connection connection = new 
TestConnection(exchangeToRoutingKeymap, routingMap);
 
-        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class))) {
+        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class), false)) {
             sender.publish("hello".getBytes(), 
MessageProperties.PERSISTENT_TEXT_PLAIN, "key1", "myExchange");
             sender.publish("world".getBytes(), 
MessageProperties.PERSISTENT_TEXT_PLAIN, "key1", "myExchange");
             sender.publish("good-bye".getBytes(), 
MessageProperties.PERSISTENT_TEXT_PLAIN, "key1", "myExchange");
@@ -153,7 +153,7 @@ public class ConsumeAMQPTest {
 
         final Connection connection = new 
TestConnection(exchangeToRoutingKeymap, routingMap);
 
-        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class))) {
+        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class), false)) {
             sender.publish("hello".getBytes(), 
MessageProperties.PERSISTENT_TEXT_PLAIN, "key1", "myExchange");
 
             ConsumeAMQP proc = new LocalConsumeAMQP(connection);
@@ -184,7 +184,7 @@ public class ConsumeAMQPTest {
 
         final Connection connection = new 
TestConnection(exchangeToRoutingKeymap, routingMap);
 
-        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class))) {
+        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class), false)) {
             sender.publish("hello".getBytes(), builderBasicProperties.build(), 
"key1", "myExchange");
 
             ConsumeAMQP proc = new LocalConsumeAMQP(connection);
@@ -219,7 +219,7 @@ public class ConsumeAMQPTest {
 
         final Connection connection = new 
TestConnection(exchangeToRoutingKeymap, routingMap);
 
-        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class))) {
+        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class), false)) {
             sender.publish("hello".getBytes(), builderBasicProperties.build(), 
"key1", "myExchange");
 
             ConsumeAMQP proc = new LocalConsumeAMQP(connection);
@@ -251,7 +251,7 @@ public class ConsumeAMQPTest {
 
         final Connection connection = new 
TestConnection(exchangeToRoutingKeymap, routingMap);
 
-        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class))) {
+        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class), false)) {
             sender.publish("hello".getBytes(), builderBasicProperties.build(), 
"key1", "myExchange");
 
             ConsumeAMQP proc = new LocalConsumeAMQP(connection);
@@ -288,7 +288,7 @@ public class ConsumeAMQPTest {
 
         final Connection connection = new 
TestConnection(exchangeToRoutingKeymap, routingMap);
 
-        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class))) {
+        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class), false)) {
             sender.publish("hello".getBytes(), builderBasicProperties.build(), 
"key1", "myExchange");
 
             ConsumeAMQP proc = new LocalConsumeAMQP(connection);
@@ -318,7 +318,7 @@ public class ConsumeAMQPTest {
 
         final Connection connection = new 
TestConnection(exchangeToRoutingKeymap, routingMap);
 
-        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class))) {
+        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class), false)) {
             sender.publish("hello".getBytes(), builderBasicProperties.build(), 
"key1", "myExchange");
 
             ConsumeAMQP proc = new LocalConsumeAMQP(connection);
@@ -352,7 +352,7 @@ public class ConsumeAMQPTest {
 
         final Connection connection = new 
TestConnection(exchangeToRoutingKeymap, routingMap);
 
-        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class))) {
+        try (AMQPPublisher sender = new AMQPPublisher(connection, 
mock(ComponentLog.class), false)) {
             sender.publish("hello".getBytes(), builderBasicProperties.build(), 
"key1", "myExchange");
 
             ConsumeAMQP proc = new LocalConsumeAMQP(connection);
diff --git 
a/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/PublishAMQPTest.java
 
b/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/PublishAMQPTest.java
index e2a2f0697ae..b77d9f0f29d 100644
--- 
a/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/PublishAMQPTest.java
+++ 
b/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/PublishAMQPTest.java
@@ -252,6 +252,38 @@ public class PublishAMQPTest {
         assertEquals(expectedRemoved, 
propertyMigrationResult.getPropertiesRemoved());
     }
 
+    /**
+     * When the broker closes the channel with a 404 (exchange not found), the 
FlowFile
+     * must route to REL_FAILURE — not cause an unhandled processor exception.
+     */
+    @Test
+    public void validateFlowFileRoutedToFailureWhenBrokerClosesChannel() {
+        final LocalPublishAMQP proc = new LocalPublishAMQP();
+        final TestRunner testRunner = TestRunners.newTestRunner(proc);
+        setConnectionProperties(testRunner);
+        testRunner.setProperty(PublishAMQP.DELIVERY_GUARANTEE, 
PublishAMQP.DeliveryGuarantee.AT_LEAST_ONCE);
+        proc.getTestChannel().setSimulateShutdownOnConfirm(true);
+
+        testRunner.enqueue("Hello Joe".getBytes());
+        testRunner.run();
+
+        testRunner.assertAllFlowFilesTransferred(PublishAMQP.REL_FAILURE);
+    }
+
+    @Test
+    public void validateFlowFileRoutedToFailureOnBrokerNack() {
+        final LocalPublishAMQP proc = new LocalPublishAMQP();
+        final TestRunner testRunner = TestRunners.newTestRunner(proc);
+        setConnectionProperties(testRunner);
+        testRunner.setProperty(PublishAMQP.DELIVERY_GUARANTEE, 
PublishAMQP.DeliveryGuarantee.AT_LEAST_ONCE);
+        proc.getTestChannel().setSimulateNackOnConfirm(true);
+
+        testRunner.enqueue("Hello Joe".getBytes());
+        testRunner.run();
+
+        testRunner.assertAllFlowFilesTransferred(PublishAMQP.REL_FAILURE);
+    }
+
     private void setConnectionProperties(TestRunner runner) {
         runner.setProperty(PublishAMQP.BROKERS, "injvm:5672");
         runner.setProperty(PublishAMQP.USER, "user");
@@ -278,5 +310,9 @@ public class PublishAMQPTest {
         public Connection getConnection() {
             return connection;
         }
+
+        public TestChannel getTestChannel() {
+            return connection.getTestChannel();
+        }
     }
 }
diff --git 
a/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/TestChannel.java
 
b/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/TestChannel.java
index ada7f18958f..20c1ccb7774 100644
--- 
a/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/TestChannel.java
+++ 
b/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/TestChannel.java
@@ -78,6 +78,9 @@ class TestChannel implements Channel {
     private final BitSet acknowledgments = new BitSet();
     private final BitSet nacks = new BitSet();
     private int prefetchCount = 0;
+    private boolean simulateShutdownOnConfirm = false;
+    private boolean simulateNackOnConfirm = false;
+    private boolean simulateSynchronousReturn = false;
 
     public TestChannel(Map<String, String> exchangeToRoutingKeyMappings,
             Map<String, List<String>> routingKeyToQueueMappings) {
@@ -100,6 +103,24 @@ class TestChannel implements Channel {
         this.corrupted = true;
     }
 
+    /** Causes the next {@link #waitForConfirms(long)} call to throw {@link 
ShutdownSignalException},
+     *  simulating the broker closing the channel (e.g., exchange not found, 
404 NOT_FOUND). */
+    void setSimulateShutdownOnConfirm(boolean simulate) {
+        this.simulateShutdownOnConfirm = simulate;
+    }
+
+    /** Causes the next {@link #waitForConfirms(long)} call to return {@code 
false},
+     *  simulating the broker sending a NACK for the published message. */
+    void setSimulateNackOnConfirm(boolean simulate) {
+        this.simulateNackOnConfirm = simulate;
+    }
+
+    /** When {@code true}, return listeners are invoked synchronously inside
+     *  {@link #basicPublish} rather than asynchronously, making tests 
deterministic. */
+    void setSimulateSynchronousReturn(boolean simulate) {
+        this.simulateSynchronousReturn = simulate;
+    }
+
     void setConnection(Connection connection) {
         this.connection = connection;
     }
@@ -283,15 +304,23 @@ class TestChannel implements Channel {
 
     private void discard(final String exchange, final String routingKey, 
boolean mandatory, final BasicProperties props,
             final byte[] body) {
-        // NO ROUTE. Invoke return listener async
+        // NO ROUTE. Invoke return listener — synchronously when simulating 
for tests, async otherwise.
         for (final ReturnListener listener : returnListeners) {
-            this.executorService.execute(() -> {
+            if (simulateSynchronousReturn) {
                 try {
                     listener.handleReturn(-9, "Rejecting", exchange, 
routingKey, props, body);
                 } catch (Exception e) {
                     throw new IllegalStateException("Failed to send return 
message", e);
                 }
-            });
+            } else {
+                this.executorService.execute(() -> {
+                    try {
+                        listener.handleReturn(-9, "Rejecting", exchange, 
routingKey, props, body);
+                    } catch (Exception e) {
+                        throw new IllegalStateException("Failed to send return 
message", e);
+                    }
+                });
+            }
         }
     }
 
@@ -582,7 +611,7 @@ class TestChannel implements Channel {
 
     @Override
     public com.rabbitmq.client.AMQP.Confirm.SelectOk confirmSelect() throws 
IOException {
-        throw new UnsupportedOperationException("This method is not currently 
supported as it is not used by current API in testing");
+        return null; // no-op: publisher confirms enabled for testing
     }
 
     @Override
@@ -597,7 +626,10 @@ class TestChannel implements Channel {
 
     @Override
     public boolean waitForConfirms(long timeout) throws InterruptedException, 
TimeoutException {
-        throw new UnsupportedOperationException("This method is not currently 
supported as it is not used by current API in testing");
+        if (simulateShutdownOnConfirm) {
+            throw new ShutdownSignalException(false, false, null, this);
+        }
+        return !simulateNackOnConfirm;
     }
 
     @Override
diff --git 
a/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/TestConnection.java
 
b/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/TestConnection.java
index 996c00dd8ce..10adb50438a 100644
--- 
a/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/TestConnection.java
+++ 
b/nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/TestConnection.java
@@ -122,6 +122,10 @@ class TestConnection implements Connection {
         return this.channel;
     }
 
+    public TestChannel getTestChannel() {
+        return this.channel;
+    }
+
     @Override
     public Channel createChannel(int channelNumber) throws IOException {
         throw new UnsupportedOperationException("This method is not currently 
supported as it is not used by current API in testing");


Reply via email to