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 2aef0593380d CAMEL-25945: camel-azure-storage-queue - the consumer 
deletes the message it received, whatever the route did with its headers 
(#28463)
2aef0593380d is described below

commit 2aef0593380d1c364ee78accd6531b66648849fd
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Oct 26 13:31:54 2026 +0530

    CAMEL-25945: camel-azure-storage-queue - the consumer deletes the message 
it received, whatever the route did with its headers (#28463)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../azure/storage/queue/QueueConsumer.java         |  31 ++-
 .../queue/QueueConsumerDeleteMessageTest.java      | 211 +++++++++++++++++++++
 2 files changed, 233 insertions(+), 9 deletions(-)

diff --git 
a/components/camel-azure/camel-azure-storage-queue/src/main/java/org/apache/camel/component/azure/storage/queue/QueueConsumer.java
 
b/components/camel-azure/camel-azure-storage-queue/src/main/java/org/apache/camel/component/azure/storage/queue/QueueConsumer.java
index eda1377e0dde..eb1cd56c7443 100644
--- 
a/components/camel-azure/camel-azure-storage-queue/src/main/java/org/apache/camel/component/azure/storage/queue/QueueConsumer.java
+++ 
b/components/camel-azure/camel-azure-storage-queue/src/main/java/org/apache/camel/component/azure/storage/queue/QueueConsumer.java
@@ -32,7 +32,6 @@ import org.apache.camel.ExchangePropertyKey;
 import org.apache.camel.Message;
 import org.apache.camel.Processor;
 import 
org.apache.camel.component.azure.storage.queue.client.QueueClientWrapper;
-import 
org.apache.camel.component.azure.storage.queue.operations.QueueOperations;
 import org.apache.camel.spi.Synchronization;
 import org.apache.camel.support.ScheduledBatchPollingConsumer;
 import org.apache.camel.support.SynchronizationAdapter;
@@ -47,7 +46,6 @@ public class QueueConsumer extends 
ScheduledBatchPollingConsumer {
     private static final Logger LOG = 
LoggerFactory.getLogger(QueueConsumer.class);
 
     private QueueClientWrapper clientWrapper;
-    private QueueOperations queueOperations;
 
     public QueueConsumer(final QueueEndpoint endpoint, final Processor 
processor) {
         super(endpoint, processor);
@@ -58,7 +56,6 @@ public class QueueConsumer extends 
ScheduledBatchPollingConsumer {
         super.doStart();
 
         clientWrapper = new 
QueueClientWrapper(getServiceClient().getQueueClient(getConfiguration().getQueueName()));
-        queueOperations = new QueueOperations(getConfiguration(), 
clientWrapper);
     }
 
     @Override
@@ -125,11 +122,17 @@ public class QueueConsumer extends 
ScheduledBatchPollingConsumer {
             // update pending number of exchanges
             pendingExchanges = total - index - 1;
 
+            // the message to delete once the exchange is done. It is taken 
now, as the route may change these headers
+            // before it ends: an azure-storage-queue producer sets them to 
the message it sent, and a route may remove
+            // the Camel headers before it calls another system
+            final String messageId = 
QueueExchangeHeaders.getMessageIdFromHeaders(exchange);
+            final String popReceipt = 
QueueExchangeHeaders.getPopReceiptFromHeaders(exchange);
+
             // add on completion to handle after work when the exchange is done
             exchange.getExchangeExtension().addOnCompletion(new 
Synchronization() {
                 @Override
                 public void onComplete(Exchange exchange) {
-                    processCommit(exchange);
+                    processCommit(exchange, messageId, popReceipt);
                 }
 
                 @Override
@@ -168,15 +171,25 @@ public class QueueConsumer extends 
ScheduledBatchPollingConsumer {
     /**
      * Strategy to delete the message after being processed.
      *
-     * @param exchange the exchange
+     * @param exchange   the exchange
+     * @param messageId  the id of the message the exchange was created from
+     * @param popReceipt the pop receipt the message was received with
      */
-    private void processCommit(final Exchange exchange) {
+    private void processCommit(final Exchange exchange, final String 
messageId, final String popReceipt) {
+        // a route that updates the message (operation=updateMessage) gets a 
new pop receipt for it, and the receipt it
+        // was received with is no longer valid: the header then has the new 
receipt, along with the same message id
+        String receipt = popReceipt;
+        if (messageId != null && 
messageId.equals(QueueExchangeHeaders.getMessageIdFromHeaders(exchange))) {
+            final String current = 
QueueExchangeHeaders.getPopReceiptFromHeaders(exchange);
+            if (current != null) {
+                receipt = current;
+            }
+        }
         try {
             if (LOG.isTraceEnabled()) {
-                LOG.trace("Deleting message with pop receipt handle {}...",
-                        
QueueExchangeHeaders.getPopReceiptFromHeaders(exchange));
+                LOG.trace("Deleting message {} with pop receipt handle {}...", 
messageId, receipt);
             }
-            queueOperations.deleteMessage(exchange);
+            clientWrapper.deleteMessage(messageId, receipt, 
getConfiguration().getTimeout());
         } catch (QueueStorageException ex) {
             getExceptionHandler().handleException("Error occurred during 
deleting message. This exception is ignored.",
                     exchange, ex);
diff --git 
a/components/camel-azure/camel-azure-storage-queue/src/test/java/org/apache/camel/component/azure/storage/queue/QueueConsumerDeleteMessageTest.java
 
b/components/camel-azure/camel-azure-storage-queue/src/test/java/org/apache/camel/component/azure/storage/queue/QueueConsumerDeleteMessageTest.java
new file mode 100644
index 000000000000..7edc8bd63602
--- /dev/null
+++ 
b/components/camel-azure/camel-azure-storage-queue/src/test/java/org/apache/camel/component/azure/storage/queue/QueueConsumerDeleteMessageTest.java
@@ -0,0 +1,211 @@
+/*
+ * 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.azure.storage.queue;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Consumer;
+
+import com.azure.core.http.HttpHeaderName;
+import com.azure.core.http.HttpHeaders;
+import com.azure.core.http.HttpResponse;
+import com.azure.core.http.rest.PagedIterable;
+import com.azure.core.http.rest.SimpleResponse;
+import com.azure.core.util.BinaryData;
+import com.azure.storage.queue.QueueClient;
+import com.azure.storage.queue.QueueServiceClient;
+import com.azure.storage.queue.models.QueueErrorCode;
+import com.azure.storage.queue.models.QueueMessageItem;
+import com.azure.storage.queue.models.QueueStorageException;
+import com.azure.storage.queue.models.SendMessageResult;
+import com.azure.storage.queue.models.UpdateMessageResult;
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.model.RouteDefinition;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * The consumer deletes the message it received once the exchange is done, 
whatever the route did with the message id
+ * and pop receipt headers in the meantime.
+ */
+class QueueConsumerDeleteMessageTest extends CamelTestSupport {
+
+    private static final String SOURCE_URI
+            = 
"azure-storage-queue://account/source?serviceClient=#serviceClient&delay=50&initialDelay=0";
+
+    @BindToRegistry("serviceClient")
+    private final QueueServiceClient serviceClient = 
mock(QueueServiceClient.class);
+
+    private final FakeQueue source = new FakeQueue("source", "a");
+    private final FakeQueue target = new FakeQueue("target", "b");
+
+    @Override
+    protected void doPreSetup() throws Exception {
+        when(serviceClient.getQueueClient("source")).thenReturn(source.client);
+        when(serviceClient.getQueueClient("target")).thenReturn(target.client);
+        source.add("hello");
+    }
+
+    @Test
+    void forwardedMessageIsDeletedFromTheSourceQueue() throws Exception {
+        // the producer sets the message id and pop receipt headers to the 
ones of the message it sent to the target
+        context.addRoutes(route(from -> 
from.to("azure-storage-queue://account/target?serviceClient=#serviceClient")));
+
+        verify(source.client, 
timeout(5000)).deleteMessageWithResponse(eq("a-1"), eq("a-1-receipt-1"), any(), 
any());
+        // and it is the only delete
+        verify(source.client).deleteMessageWithResponse(any(), any(), any(), 
any());
+        assertEquals(List.of("b-1"), List.copyOf(target.messages.keySet()));
+    }
+
+    @Test
+    void messageIsDeletedWhenTheRouteRemovesTheCamelHeaders() throws Exception 
{
+        context.addRoutes(route(from -> from.removeHeaders("Camel*")));
+
+        verify(source.client, 
timeout(5000)).deleteMessageWithResponse(eq("a-1"), eq("a-1-receipt-1"), any(), 
any());
+        // and it is the only delete
+        verify(source.client).deleteMessageWithResponse(any(), any(), any(), 
any());
+    }
+
+    @Test
+    void updatedMessageIsDeletedWithItsNewPopReceipt() throws Exception {
+        // control, which passes without the fix too: updating the message 
gives it a new pop receipt, which the
+        // producer sets in the header, and only the new receipt deletes the 
message
+        context.addRoutes(route(from -> from.to(
+                
"azure-storage-queue://account/source?serviceClient=#serviceClient&operation=updateMessage"
+                                                + "&visibilityTimeout=60s")));
+
+        verify(source.client, 
timeout(5000)).deleteMessageWithResponse(eq("a-1"), eq("a-1-receipt-2"), any(), 
any());
+        // and it is the only delete
+        verify(source.client).deleteMessageWithResponse(any(), any(), any(), 
any());
+    }
+
+    private RouteBuilder route(Consumer<RouteDefinition> steps) {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                steps.accept(from(SOURCE_URI));
+            }
+        };
+    }
+
+    /**
+     * A queue that answers like the Azure Queue Storage service (REST API 
reference of Put Message, Get Messages,
+     * Update Message and Delete Message):
+     * <ul>
+     * <li>Get Messages hides the message for the visibility timeout and gives 
it a new pop receipt;</li>
+     * <li>Put Message returns the id and the pop receipt of the message it 
added;</li>
+     * <li>Update Message returns a new pop receipt, and the one the message 
had before no longer works;</li>
+     * <li>Delete Message fails with 404 MessageNotFound when the queue has no 
message with that id, and with 400
+     * PopReceiptMismatch when the pop receipt is not the current one of the 
message. The SDK throws both as a
+     * {@link QueueStorageException} ({@code 
QueueClient.deleteMessageWithResponse}).</li>
+     * </ul>
+     */
+    private static final class FakeQueue {
+
+        private final QueueClient client = mock(QueueClient.class);
+        private final Map<String, String> messages = new ConcurrentHashMap<>();
+        private final Map<String, String> receipts = new ConcurrentHashMap<>();
+        private final String name;
+        private final String idPrefix;
+        private int sent;
+
+        private FakeQueue(String name, String idPrefix) {
+            this.name = name;
+            this.idPrefix = idPrefix;
+            when(client.receiveMessages(any(), any(), any(), 
any())).thenAnswer(invocation -> receive());
+            when(client.sendMessageWithResponse(anyString(), any(), any(), 
any(), any()))
+                    .thenAnswer(invocation -> {
+                        String id = add(invocation.getArgument(0));
+                        SendMessageResult result = new 
SendMessageResult().setMessageId(id)
+                                .setPopReceipt(receipts.get(id));
+                        return new SimpleResponse<>(null, 201, new 
HttpHeaders(), result);
+                    });
+            when(client.updateMessageWithResponse(anyString(), anyString(), 
any(), any(), any(), any()))
+                    .thenAnswer(invocation -> {
+                        String id = invocation.getArgument(0);
+                        checkReceipt(id, invocation.getArgument(1));
+                        String receipt = nextReceipt(id);
+                        UpdateMessageResult result = new 
UpdateMessageResult(receipt, OffsetDateTime.now());
+                        return new SimpleResponse<>(null, 204, new 
HttpHeaders(), result);
+                    });
+            when(client.deleteMessageWithResponse(any(), any(), any(), 
any())).thenAnswer(invocation -> {
+                String id = invocation.getArgument(0);
+                checkReceipt(id, invocation.getArgument(1));
+                messages.remove(id);
+                receipts.remove(id);
+                return new SimpleResponse<>(null, 204, new HttpHeaders(), 
null);
+            });
+        }
+
+        private synchronized String add(String text) {
+            String id = idPrefix + "-" + ++sent;
+            messages.put(id, text);
+            receipts.put(id, id + "-receipt-0");
+            return id;
+        }
+
+        private synchronized PagedIterable<QueueMessageItem> receive() {
+            PagedIterable<QueueMessageItem> page = mock();
+            // the message stays invisible until the test ends, so it is 
received once
+            List<QueueMessageItem> items = messages.entrySet().stream()
+                    .filter(e -> 
receipts.get(e.getKey()).endsWith("-receipt-0"))
+                    .map(e -> new 
QueueMessageItem().setMessageId(e.getKey()).setPopReceipt(nextReceipt(e.getKey()))
+                            .setBody(BinaryData.fromString(e.getValue())))
+                    .toList();
+            when(page.stream()).thenAnswer(invocation -> items.stream());
+            return page;
+        }
+
+        private String nextReceipt(String id) {
+            String current = receipts.get(id);
+            int number = 
Integer.parseInt(current.substring(current.lastIndexOf('-') + 1));
+            String next = id + "-receipt-" + (number + 1);
+            receipts.put(id, next);
+            return next;
+        }
+
+        private void checkReceipt(String id, String popReceipt) {
+            if (!messages.containsKey(id)) {
+                throw error(404, QueueErrorCode.MESSAGE_NOT_FOUND, "The 
specified message does not exist in " + name);
+            }
+            if (!receipts.get(id).equals(popReceipt)) {
+                throw error(400, QueueErrorCode.POP_RECEIPT_MISMATCH,
+                        "The specified pop receipt did not match the pop 
receipt for a dequeued message");
+            }
+        }
+
+        private static QueueStorageException error(int status, QueueErrorCode 
code, String message) {
+            HttpResponse response = mock(HttpResponse.class);
+            when(response.getStatusCode()).thenReturn(status);
+            when(response.getHeaders())
+                    .thenReturn(new 
HttpHeaders().set(HttpHeaderName.fromString("x-ms-error-code"), 
code.toString()));
+            return new QueueStorageException(message, response, null);
+        }
+    }
+}

Reply via email to