This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new d09593b4a584 CAMEL-25700: camel-aws2-sqs - extend the visibility of
every message of the batch, not only from its turn (#27985)
d09593b4a584 is described below
commit d09593b4a5847fb24ee47fa0b12dace2890e3762
Author: allthingssecurity <[email protected]>
AuthorDate: Sat Oct 17 16:06:00 2026 +0530
CAMEL-25700: camel-aws2-sqs - extend the visibility of every message of the
batch, not only from its turn (#27985)
With extendMessageVisibility=true and maxMessagesPerPoll greater than one,
processBatch registered a
message with the TimeoutExtender only when its turn came. The visibility
timeout of all messages of a
poll starts at the receive, so the messages that waited behind slow ones
were not extended at all and
became visible again: SQS delivered them a second time (the scenario of
CAMEL-10587).
processBatch now registers every message of the batch with the extender
before it routes the first
one, and removes the messages that it does not route (the consumer is
stopping or suspending, or the
processor threw) so that they become visible again as before. The
extender's rule of extending an
entry only when its deadline is near is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../camel/component/aws2/sqs/Sqs2Consumer.java | 45 +++++--
...qsConsumerExtendMessageVisibilityBatchTest.java | 147 +++++++++++++++++++++
2 files changed, 184 insertions(+), 8 deletions(-)
diff --git
a/components/camel-aws/camel-aws2-sqs/src/main/java/org/apache/camel/component/aws2/sqs/Sqs2Consumer.java
b/components/camel-aws/camel-aws2-sqs/src/main/java/org/apache/camel/component/aws2/sqs/Sqs2Consumer.java
index e51d2912a363..99cb666cd3ae 100644
---
a/components/camel-aws/camel-aws2-sqs/src/main/java/org/apache/camel/component/aws2/sqs/Sqs2Consumer.java
+++
b/components/camel-aws/camel-aws2-sqs/src/main/java/org/apache/camel/component/aws2/sqs/Sqs2Consumer.java
@@ -136,6 +136,16 @@ public class Sqs2Consumer extends
ScheduledBatchPollingConsumer {
public int processBatch(Queue<Object> exchanges) throws Exception {
int total = exchanges.size();
+ // the visibility timeout of every message of the batch runs from the
receive, so all of them are extended from
+ // now on, and not only when their turn comes: otherwise a message
that waits behind slow ones becomes visible
+ // again and is delivered a second time
+ final TimeoutExtender extender = this.timeoutExtender;
+ if (extender != null) {
+ for (Object exchange : exchanges) {
+ extender.add((Exchange) exchange);
+ }
+ }
+
for (int index = 0; index < total && isBatchAllowed(); index++) {
// only loop if we are started (allowed to run)
final Exchange exchange = ObjectHelper.cast(Exchange.class,
exchanges.poll());
@@ -147,10 +157,6 @@ public class Sqs2Consumer extends
ScheduledBatchPollingConsumer {
// update pending number of exchanges
pendingExchanges = total - index - 1;
- if (this.timeoutExtender != null) {
- timeoutExtender.add(exchange);
- }
-
// add on completion to handle after work when the exchange is done
exchange.getExchangeExtension().addOnCompletion(new
Synchronization() {
@Override
@@ -174,18 +180,33 @@ public class Sqs2Consumer extends
ScheduledBatchPollingConsumer {
try {
getAsyncProcessor().process(exchange, cb);
} catch (Error e) {
- if (this.timeoutExtender != null) {
+ if (extender != null) {
// fatal error so stop the timeout extender
- timeoutExtender.cancel();
- timeoutExtender.entries.clear();
+ extender.cancel();
+ extender.entries.clear();
}
throw e;
+ } catch (RuntimeException e) {
+ stopExtending(extender, exchanges);
+ throw e;
}
}
+ // the messages that are not routed (the consumer is stopping or
suspending) are no longer extended, so they
+ // become visible again when their current visibility timeout runs out
+ stopExtending(extender, exchanges);
+
return total;
}
+ private static void stopExtending(TimeoutExtender extender, Queue<Object>
notRouted) {
+ if (extender != null) {
+ for (Object exchange : notRouted) {
+ extender.remove((Exchange) exchange);
+ }
+ }
+ }
+
/**
* Strategy to delete the message after being processed.
*
@@ -412,7 +433,7 @@ public class Sqs2Consumer extends
ScheduledBatchPollingConsumer {
private void remove(Exchange exchange) {
LOG.trace("Removing exchangeId {} from the
TimeoutExtender, processing done", exchange.getExchangeId());
- entries.remove(exchange.getExchangeId());
+ TimeoutExtender.this.remove(exchange);
}
});
@@ -423,6 +444,14 @@ public class Sqs2Consumer extends
ScheduledBatchPollingConsumer {
entries.put(exchange.getExchangeId(), new
TimeoutExtenderEntry(entry));
}
+ /**
+ * Stops extending the visibility of the message of the given
exchange. The message is not made visible: it
+ * becomes visible again when its current (possibly just extended)
visibility timeout runs out.
+ */
+ public void remove(Exchange exchange) {
+ entries.remove(exchange.getExchangeId());
+ }
+
public void cancel() {
// cancel by setting to no longer run
run.set(false);
diff --git
a/components/camel-aws/camel-aws2-sqs/src/test/java/org/apache/camel/component/aws2/sqs/SqsConsumerExtendMessageVisibilityBatchTest.java
b/components/camel-aws/camel-aws2-sqs/src/test/java/org/apache/camel/component/aws2/sqs/SqsConsumerExtendMessageVisibilityBatchTest.java
new file mode 100644
index 000000000000..39de83b93b2a
--- /dev/null
+++
b/components/camel-aws/camel-aws2-sqs/src/test/java/org/apache/camel/component/aws2/sqs/SqsConsumerExtendMessageVisibilityBatchTest.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.aws2.sqs;
+
+import java.time.Duration;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.EndpointInject;
+import org.apache.camel.ServiceStatus;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import
software.amazon.awssdk.services.sqs.model.ChangeMessageVisibilityBatchRequest;
+import
software.amazon.awssdk.services.sqs.model.ChangeMessageVisibilityBatchResponse;
+import software.amazon.awssdk.services.sqs.model.Message;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * With {@code extendMessageVisibility=true} and more than one message per
poll, the messages that wait in the batch for
+ * their turn must be kept invisible too: their visibility timeout runs from
the receive, not from the start of their
+ * own processing.
+ */
+class SqsConsumerExtendMessageVisibilityBatchTest extends CamelTestSupport {
+
+ private static final int VISIBILITY_TIMEOUT_SECONDS = 2;
+ private static final String FIRST_HANDLE = "receipt-handle-1";
+ private static final String SECOND_HANDLE = "receipt-handle-2";
+
+ private final CountDownLatch firstStarted = new CountDownLatch(1);
+ private final CountDownLatch releaseFirst = new CountDownLatch(1);
+ private final CountDownLatch secondExtended = new CountDownLatch(1);
+ private final ExecutorService suspender =
Executors.newSingleThreadExecutor();
+
+ @EndpointInject("mock:result")
+ private MockEndpoint mock;
+
+ @BindToRegistry("amazonSQSClient")
+ private final AmazonSQSClientMock client = new AmazonSQSClientMock() {
+ @Override
+ public ChangeMessageVisibilityBatchResponse
changeMessageVisibilityBatch(ChangeMessageVisibilityBatchRequest request) {
+ if (request.entries().stream().anyMatch(e ->
SECOND_HANDLE.equals(e.receiptHandle()))) {
+ secondExtended.countDown();
+ }
+ return super.changeMessageVisibilityBatch(request);
+ }
+ };
+
+ {
+ // both messages are on the queue before the route starts, so that the
first poll receives them together
+
client.addMessage(Message.builder().body("first").messageId("id-1").receiptHandle(FIRST_HANDLE).build());
+
client.addMessage(Message.builder().body("second").messageId("id-2").receiptHandle(SECOND_HANDLE).build());
+ }
+
+ @AfterEach
+ void releaseRoute() {
+ releaseFirst.countDown();
+ suspender.shutdownNow();
+ }
+
+ @Test
+ void waitingMessageOfTheBatchIsExtendedWhileTheFirstIsProcessed() throws
Exception {
+ mock.expectedBodiesReceived("first", "second");
+
+ assertThat(firstStarted.await(10, TimeUnit.SECONDS)).as("the first
message was routed").isTrue();
+ // the second message has been received with the first one; it is not
routed before the first is done, but its
+ // visibility timeout is running, so the extender must cover it now
(the first is held for 5 timeouts at most)
+ boolean extended = secondExtended.await(VISIBILITY_TIMEOUT_SECONDS *
5L, TimeUnit.SECONDS);
+ releaseFirst.countDown();
+
+ assertThat(extended)
+ .as("the visibility of the second message of the batch is
extended while the first message is processed"
+ + " (otherwise SQS makes it visible again and delivers it
a second time)")
+ .isTrue();
+ mock.assertIsSatisfied();
+ }
+
+ @Test
+ void messageLeftInTheBatchIsNoLongerExtendedAfterSuspend() throws
Exception {
+ mock.expectedBodiesReceived("first");
+
+ assertThat(firstStarted.await(10, TimeUnit.SECONDS)).as("the first
message was routed").isTrue();
+ // suspend the route while the first message is processed: the second
message of the batch is then not routed
+ Future<?> suspend = suspender.submit(() -> {
+ context.getRouteController().suspendRoute("sqs", 30,
TimeUnit.SECONDS);
+ return null;
+ });
+ await().atMost(Duration.ofSeconds(10))
+ .until(() -> context.getRoute("sqs").getConsumer() instanceof
Sqs2Consumer c && c.isSuspended());
+ releaseFirst.countDown();
+ suspend.get(30, TimeUnit.SECONDS);
+
assertThat(context.getRouteController().getRouteStatus("sqs")).isEqualTo(ServiceStatus.Suspended);
+ mock.assertIsSatisfied();
+
+ int requestsSoFar =
client.getChangeMessageVisibilityBatchRequests().size();
+ // the message that was not routed must not be kept invisible while
the route is suspended: no extension for
+ // it during several runs of the extender (it runs every
VISIBILITY_TIMEOUT_SECONDS / 2 seconds)
+ await().during(Duration.ofSeconds(VISIBILITY_TIMEOUT_SECONDS *
2L)).atMost(Duration.ofSeconds(10))
+ .until(() ->
client.getChangeMessageVisibilityBatchRequests().stream()
+ .skip(requestsSoFar)
+ .flatMap(r -> r.entries().stream())
+ .noneMatch(e ->
SECOND_HANDLE.equals(e.receiptHandle())));
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("aws2-sqs://MyQueue?amazonSQSClient=#amazonSQSClient&maxMessagesPerPoll=2&visibilityTimeout="
+ + VISIBILITY_TIMEOUT_SECONDS +
"&extendMessageVisibility=true")
+ .routeId("sqs")
+ .process(exchange -> {
+ if
("first".equals(exchange.getIn().getBody(String.class))) {
+ firstStarted.countDown();
+ if (!releaseFirst.await(30, TimeUnit.SECONDS))
{
+ throw new IllegalStateException("the test
did not release the first message");
+ }
+ }
+ })
+ .to("mock:result");
+ }
+ };
+ }
+}