Skip to content

Commit f9f4646

Browse files
authored
Merge branch 'main' into NIFI-15934-publishamqp-header-size
2 parents 3e8877d + 4048cd4 commit f9f4646

76 files changed

Lines changed: 2926 additions & 136 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

nifi-extension-bom/pom.xml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,19 +189,19 @@
189189
<dependency>
190190
<groupId>org.ow2.asm</groupId>
191191
<artifactId>asm</artifactId>
192-
<version>9.10</version>
192+
<version>9.10.1</version>
193193
<scope>provided</scope>
194194
</dependency>
195195
<dependency>
196196
<groupId>org.ow2.asm</groupId>
197197
<artifactId>asm-commons</artifactId>
198-
<version>9.10</version>
198+
<version>9.10.1</version>
199199
<scope>provided</scope>
200200
</dependency>
201201
<dependency>
202202
<groupId>org.ow2.asm</groupId>
203203
<artifactId>asm-tree</artifactId>
204-
<version>9.10</version>
204+
<version>9.10.1</version>
205205
<scope>provided</scope>
206206
</dependency>
207207
<!-- Jetty EE11 Apache JSP and deps -->

nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/main/java/org/apache/nifi/amqp/processors/AMQPPublisher.java

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,13 @@
2020
import com.rabbitmq.client.AlreadyClosedException;
2121
import com.rabbitmq.client.Connection;
2222
import com.rabbitmq.client.ReturnListener;
23+
import com.rabbitmq.client.ShutdownSignalException;
2324
import org.apache.nifi.logging.ComponentLog;
2425

2526
import java.io.IOException;
2627
import java.net.SocketException;
28+
import java.util.concurrent.TimeoutException;
29+
import java.util.concurrent.atomic.AtomicReference;
2730

2831
/**
2932
* Generic publisher of messages to AMQP-based messaging system. It is based on
@@ -32,17 +35,40 @@
3235
final class AMQPPublisher extends AMQPWorker {
3336

3437
private final String connectionString;
38+
private final boolean useConfirms;
39+
40+
/**
41+
* Stores the broker's return reason when a message is published with mandatory=true
42+
* but the broker cannot route it to any queue. Written by the AMQP I/O thread via
43+
* {@link UndeliverableMessageLogger} and read by the publishing thread after
44+
* {@link com.rabbitmq.client.Channel#waitForConfirms} synchronizes the two.
45+
* Only populated when {@link #useConfirms} is true.
46+
*/
47+
private final AtomicReference<String> undeliverableReturnReason = new AtomicReference<>(null);
3548

3649
/**
3750
* Creates an instance of this publisher
3851
*
3952
* @param connection instance of AMQP {@link Connection}
53+
* @param useConfirms when true, enables RabbitMQ Publisher Confirms so that
54+
* {@link #publish} waits for a broker ack/nack and reliably
55+
* detects undeliverable messages; when false, the original
56+
* fire-and-forget behaviour is used for maximum throughput
4057
*/
41-
AMQPPublisher(Connection connection, ComponentLog processorLog) {
58+
AMQPPublisher(Connection connection, ComponentLog processorLog, boolean useConfirms) {
4259
super(connection, processorLog);
60+
this.useConfirms = useConfirms;
4361
getChannel().addReturnListener(new UndeliverableMessageLogger());
4462
this.connectionString = connection.toString();
4563

64+
if (useConfirms) {
65+
try {
66+
getChannel().confirmSelect();
67+
} catch (final IOException e) {
68+
throw new AMQPException("Failed to enable Publisher Confirms on AMQP channel", e);
69+
}
70+
}
71+
4672
processorLog.info("Successfully connected AMQPPublisher to {}", this.connectionString);
4773
}
4874

@@ -68,13 +94,43 @@ void publish(byte[] bytes, BasicProperties properties, String routingKey, String
6894
processorLog.debug("Successfully connected AMQPPublisher to {} and '{}' exchange with '{}' as a routing key.", this.connectionString, exchange, routingKey);
6995
}
7096

97+
// Reset any stale return reason from a previous publish before sending.
98+
undeliverableReturnReason.set(null);
99+
71100
try {
72101
getChannel().basicPublish(exchange, routingKey, true, properties, bytes);
73102
} catch (AlreadyClosedException | SocketException e) {
74103
throw new AMQPRollbackException("Failed to publish message because the AMQP connection is lost or has been closed", e);
75104
} catch (Exception e) {
76105
throw new AMQPException("Failed to publish message to Exchange '" + exchange + "' with Routing Key '" + routingKey + "'.", e);
77106
}
107+
108+
if (useConfirms) {
109+
// Wait for the broker's publish confirm (ack/nack). Because the broker sends a basic.return
110+
// frame BEFORE the corresponding confirm frame for mandatory messages it cannot route,
111+
// UndeliverableMessageLogger.handleReturn() is guaranteed to have run by the time
112+
// waitForConfirms() returns. This makes undeliverable-message detection reliable.
113+
try {
114+
if (!getChannel().waitForConfirms(5_000L)) {
115+
throw new AMQPException("Broker negatively acknowledged (NACK) message published to Exchange '"
116+
+ exchange + "' with Routing Key '" + routingKey + "'");
117+
}
118+
} catch (InterruptedException e) {
119+
Thread.currentThread().interrupt();
120+
throw new AMQPException("Interrupted while waiting for publish confirmation from broker", e);
121+
} catch (TimeoutException e) {
122+
throw new AMQPException("Timed out waiting for publish confirmation from broker for Exchange '"
123+
+ exchange + "' with Routing Key '" + routingKey + "'", e);
124+
} catch (ShutdownSignalException e) {
125+
throw new AMQPException("Broker closed channel while waiting for publish confirmation — "
126+
+ "Exchange '" + exchange + "' may not exist: " + e.getMessage(), e);
127+
}
128+
129+
final String returnReason = undeliverableReturnReason.get();
130+
if (returnReason != null) {
131+
throw new AMQPException(returnReason);
132+
}
133+
}
78134
}
79135

80136
@Override
@@ -83,23 +139,26 @@ public String toString() {
83139
}
84140

85141
/**
86-
* Listener to listen and WARN-log undeliverable messages which are returned
87-
* back to the sender. Since in the current implementation messages are sent
88-
* with 'mandatory' bit set, such messages must have final destination
89-
* otherwise they are silently dropped which could cause a confusion
90-
* especially during early stages of flow development. This implies that
91-
* bindings between exchange -> routingKey -> queue must exist and are
92-
* typically done by AMQP administrator. This logger simply helps to monitor
93-
* for such conditions by logging such messages as warning. In the future
94-
* this can be extended to provide other type of functionality (e.g., fail
95-
* processor etc.)
142+
* Listens for messages returned by the broker when they cannot be routed to any queue
143+
* (mandatory=true publish with no matching binding).
144+
*
145+
* In {@link PublishAMQP.DeliveryGuarantee#AT_MOST_ONCE} mode (the default), this listener
146+
* only logs a warning — matching the original behaviour.
147+
*
148+
* In {@link PublishAMQP.DeliveryGuarantee#AT_LEAST_ONCE} mode, the return reason is also
149+
* stored in {@link #undeliverableReturnReason} so that {@link #publish} can detect it after
150+
* {@code waitForConfirms()} synchronizes the two threads and throw an {@link AMQPException}
151+
* to trigger REL_FAILURE routing.
96152
*/
97153
private final class UndeliverableMessageLogger implements ReturnListener {
98154
@Override
99155
public void handleReturn(int replyCode, String replyText, String exchangeName, String routingKey, BasicProperties properties, byte[] message) throws IOException {
100-
String logMessage = "Message destined for '" + exchangeName + "' exchange with '" + routingKey
101-
+ "' as routing key came back with replyCode=" + replyCode + " and replyText=" + replyText + ".";
102-
processorLog.warn(logMessage);
156+
final String reason = "Message returned as undeliverable by broker: exchange='" + exchangeName
157+
+ "' routingKey='" + routingKey + "' replyCode=" + replyCode + " replyText='" + replyText + "'";
158+
if (useConfirms) {
159+
undeliverableReturnReason.set(reason);
160+
}
161+
processorLog.warn(reason);
103162
}
104163
}
105164
}

nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/main/java/org/apache/nifi/amqp/processors/PublishAMQP.java

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,19 @@ public class PublishAMQP extends AbstractAMQPProcessor<AMQPPublisher> {
107107
.expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
108108
.addValidator(StandardValidators.createDataSizeBoundsValidator(1, MAXIMUM_INPUT_FLOWFILE_SIZE_LIMIT))
109109
.build();
110+
public static final PropertyDescriptor DELIVERY_GUARANTEE = new PropertyDescriptor.Builder()
111+
.name("Delivery Guarantee")
112+
.description("Controls whether the processor waits for a publish confirmation (broker ack/nack) before routing the FlowFile. "
113+
+ "\"At least once\" enables RabbitMQ Publisher Confirms: the processor blocks until the broker acknowledges the message, "
114+
+ "and undeliverable messages (no matching queue binding) are reliably routed to 'failure'. "
115+
+ "This prevents silent data loss at the cost of significantly higher latency, especially with remote brokers. "
116+
+ "\"At most once\" uses the original fire-and-forget mode: the message is sent without waiting for confirmation. "
117+
+ "Undeliverable messages are only logged as a warning and the FlowFile is still routed to 'success'. "
118+
+ "This mode offers maximum throughput but provides no delivery guarantee.")
119+
.required(true)
120+
.allowableValues(DeliveryGuarantee.class)
121+
.defaultValue(DeliveryGuarantee.AT_MOST_ONCE)
122+
.build();
110123
public static final PropertyDescriptor HEADERS_SOURCE = new PropertyDescriptor.Builder()
111124
.name("Headers Source")
112125
.description("The source of the headers which will be applied to the published message.")
@@ -149,6 +162,7 @@ public class PublishAMQP extends AbstractAMQPProcessor<AMQPPublisher> {
149162
EXCHANGE,
150163
ROUTING_KEY,
151164
MAXIMUM_INPUT_FLOWFILE_SIZE,
165+
DELIVERY_GUARANTEE,
152166
HEADERS_SOURCE,
153167
HEADERS_PATTERN,
154168
HEADER_SEPARATOR
@@ -228,7 +242,8 @@ public Set<Relationship> getRelationships() {
228242

229243
@Override
230244
protected AMQPPublisher createAMQPWorker(final ProcessContext context, final Connection connection) {
231-
return new AMQPPublisher(connection, getLogger());
245+
final boolean useConfirms = DeliveryGuarantee.AT_LEAST_ONCE == context.getProperty(DELIVERY_GUARANTEE).asAllowableValue(DeliveryGuarantee.class);
246+
return new AMQPPublisher(connection, getLogger(), useConfirms);
232247
}
233248

234249
@Override
@@ -377,6 +392,42 @@ protected Character getHeaderSeparator(ProcessContext context, InputHeaderSource
377392
};
378393
}
379394

395+
public enum DeliveryGuarantee implements DescribedValue {
396+
397+
AT_MOST_ONCE("At most once",
398+
"Fire-and-forget: message is sent without waiting for a broker acknowledgement. "
399+
+ "Undeliverable messages (no matching queue binding) are logged as a warning and "
400+
+ "the FlowFile is routed to 'success'. Offers maximum throughput."),
401+
AT_LEAST_ONCE("At least once",
402+
"Publisher Confirms are enabled: the processor blocks until the broker acknowledges "
403+
+ "the message (ack or nack). Undeliverable messages are reliably detected and routed "
404+
+ "to 'failure'. Prevents silent data loss at the cost of higher latency, particularly "
405+
+ "with remote brokers.");
406+
407+
private final String displayName;
408+
private final String description;
409+
410+
DeliveryGuarantee(final String displayName, final String description) {
411+
this.displayName = displayName;
412+
this.description = description;
413+
}
414+
415+
@Override
416+
public String getValue() {
417+
return name();
418+
}
419+
420+
@Override
421+
public String getDisplayName() {
422+
return displayName;
423+
}
424+
425+
@Override
426+
public String getDescription() {
427+
return description;
428+
}
429+
}
430+
380431
public enum InputHeaderSource implements DescribedValue {
381432

382433
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"),

nifi-extension-bundles/nifi-amqp-bundle/nifi-amqp-processors/src/test/java/org/apache/nifi/amqp/processors/AMQPPublisherTest.java

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,16 @@
3737

3838
public class AMQPPublisherTest {
3939

40-
@SuppressWarnings("resource")
4140
@Test
4241
public void failOnNullConnection() {
43-
assertThrows(IllegalArgumentException.class, () -> new AMQPPublisher(null, null));
42+
assertThrows(IllegalArgumentException.class, () -> new AMQPPublisher(null, null, false));
4443
}
4544

4645
@Test
4746
public void failPublishIfChannelClosed() {
4847
assertThrows(AMQPRollbackException.class, () -> {
4948
Connection conn = new TestConnection(null, null);
50-
try (AMQPPublisher sender = new AMQPPublisher(conn, mock(ComponentLog.class))) {
49+
try (AMQPPublisher sender = new AMQPPublisher(conn, mock(ComponentLog.class), false)) {
5150
conn.close();
5251
sender.publish("oleg".getBytes(), null, "foo", "");
5352
}
@@ -58,7 +57,7 @@ public void failPublishIfChannelClosed() {
5857
public void failPublishIfChannelFails() {
5958
assertThrows(AMQPException.class, () -> {
6059
TestConnection conn = new TestConnection(null, null);
61-
try (AMQPPublisher sender = new AMQPPublisher(conn, mock(ComponentLog.class))) {
60+
try (AMQPPublisher sender = new AMQPPublisher(conn, mock(ComponentLog.class), false)) {
6261
((TestChannel) conn.createChannel()).corruptChannel();
6362
sender.publish("oleg".getBytes(), null, "foo", "");
6463
}
@@ -74,7 +73,7 @@ public void validateSuccessfulPublishingAndRouting() throws Exception {
7473

7574
Connection connection = new TestConnection(exchangeToRoutingKeymap, routingMap);
7675

77-
try (AMQPPublisher sender = new AMQPPublisher(connection, mock(ComponentLog.class))) {
76+
try (AMQPPublisher sender = new AMQPPublisher(connection, mock(ComponentLog.class), false)) {
7877
sender.publish("hello".getBytes(), null, "key1", "myExchange");
7978
}
8079

@@ -96,7 +95,7 @@ public void validateSuccessfulPublishingAndUndeliverableRoutingKey() throws Exce
9695
ReturnListener retListener = mock(ReturnListener.class);
9796
connection.createChannel().addReturnListener(retListener);
9897

99-
try (AMQPPublisher sender = new AMQPPublisher(connection, new MockComponentLog("foo", ""))) {
98+
try (AMQPPublisher sender = new AMQPPublisher(connection, new MockComponentLog("foo", ""), false)) {
10099
sender.publish("hello".getBytes(), null, "key1", "myExchange");
101100
}
102101

@@ -105,4 +104,66 @@ public void validateSuccessfulPublishingAndUndeliverableRoutingKey() throws Exce
105104
connection.close();
106105
}
107106

107+
/**
108+
* Verifies that a {@link com.rabbitmq.client.ShutdownSignalException} thrown by
109+
* {@code waitForConfirms()} (e.g., broker closes channel with 404 NOT_FOUND because the
110+
* exchange does not exist) is converted to {@link AMQPException} so the FlowFile routes
111+
* to REL_FAILURE instead of surfacing as an unhandled processor error.
112+
*/
113+
@Test
114+
public void failPublishWhenBrokerClosesChannelDuringConfirmInAtLeastOnce() {
115+
assertThrows(AMQPException.class, () -> {
116+
TestConnection conn = new TestConnection(null, null);
117+
conn.getTestChannel().setSimulateShutdownOnConfirm(true);
118+
try (AMQPPublisher sender = new AMQPPublisher(conn, mock(ComponentLog.class), true)) {
119+
sender.publish("hello".getBytes(), null, "foo", "");
120+
}
121+
});
122+
}
123+
124+
@Test
125+
public void failPublishWhenBrokerNacksMessageInAtLeastOnce() {
126+
assertThrows(AMQPException.class, () -> {
127+
TestConnection conn = new TestConnection(null, null);
128+
conn.getTestChannel().setSimulateNackOnConfirm(true);
129+
try (AMQPPublisher sender = new AMQPPublisher(conn, mock(ComponentLog.class), true)) {
130+
sender.publish("hello".getBytes(), null, "foo", "");
131+
}
132+
});
133+
}
134+
135+
@Test
136+
public void failPublishWhenMessageReturnedAsUndeliverableInAtLeastOnce() {
137+
assertThrows(AMQPException.class, () -> {
138+
Map<String, List<String>> routingMap = new HashMap<>();
139+
routingMap.put("key1", Arrays.asList("queue1"));
140+
Map<String, String> exchangeToRoutingKeymap = new HashMap<>();
141+
exchangeToRoutingKeymap.put("myExchange", "key1");
142+
143+
TestConnection conn = new TestConnection(exchangeToRoutingKeymap, routingMap);
144+
conn.getTestChannel().setSimulateSynchronousReturn(true);
145+
146+
try (AMQPPublisher sender = new AMQPPublisher(conn, new MockComponentLog("id", ""), true)) {
147+
sender.publish("hello".getBytes(), null, "wrongKey", "myExchange");
148+
}
149+
});
150+
}
151+
152+
@Test
153+
public void succeedsPublishWhenMessageUndeliverableInAtMostOnceMode() throws Exception {
154+
Map<String, List<String>> routingMap = new HashMap<>();
155+
routingMap.put("key1", Arrays.asList("queue1"));
156+
Map<String, String> exchangeToRoutingKeymap = new HashMap<>();
157+
exchangeToRoutingKeymap.put("myExchange", "key1");
158+
159+
TestConnection conn = new TestConnection(exchangeToRoutingKeymap, routingMap);
160+
conn.getTestChannel().setSimulateSynchronousReturn(true);
161+
162+
try (AMQPPublisher sender = new AMQPPublisher(conn, new MockComponentLog("id", ""), false)) {
163+
// In AT_MOST_ONCE mode, undeliverable messages only produce a warning — no exception
164+
sender.publish("hello".getBytes(), null, "wrongKey", "myExchange");
165+
}
166+
conn.close();
167+
}
168+
108169
}

0 commit comments

Comments
 (0)