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 590d392265ec CAMEL-25758: camel-azure-storage-blob - moveAfterRead 
deletes the source blob only after the copy succeeded (#28073)
590d392265ec is described below

commit 590d392265eceea44b96850d0f027f483506785a
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Oct 19 13:50:36 2026 +0530

    CAMEL-25758: camel-azure-storage-blob - moveAfterRead deletes the source 
blob only after the copy succeeded (#28073)
    
    With moveAfterRead the consumer moves a blob with 
beginCopy(...).waitForCompletion(timeout) and then deletes the
    source blob. A copy is asynchronous on the service, and the poller of the 
SDK returns normally when the copy ended as
    failed or aborted, or when the status of a pending copy could not be read 
(status POLLING_FAILED). The consumer did
    not look at the returned status and deleted the source blob in all these 
cases: the blob was lost, the destination of
    a failed or aborted copy has no content.
    
    The consumer now deletes the source blob only when the copy completed 
successfully. Otherwise it reports the copy
    status to the exception handler and keeps the blob, which is consumed again 
on the next poll, as after a failed copy
    request.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../component/azure/storage/blob/BlobConsumer.java |  33 +++-
 .../BlobConsumerMoveAfterReadCopyStatusTest.java   | 173 +++++++++++++++++++++
 2 files changed, 199 insertions(+), 7 deletions(-)

diff --git 
a/components/camel-azure/camel-azure-storage-blob/src/main/java/org/apache/camel/component/azure/storage/blob/BlobConsumer.java
 
b/components/camel-azure/camel-azure-storage-blob/src/main/java/org/apache/camel/component/azure/storage/blob/BlobConsumer.java
index c08ff5a44bea..5c0158fd06c6 100644
--- 
a/components/camel-azure/camel-azure-storage-blob/src/main/java/org/apache/camel/component/azure/storage/blob/BlobConsumer.java
+++ 
b/components/camel-azure/camel-azure-storage-blob/src/main/java/org/apache/camel/component/azure/storage/blob/BlobConsumer.java
@@ -25,8 +25,11 @@ import java.util.Queue;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 
+import com.azure.core.util.polling.LongRunningOperationStatus;
+import com.azure.core.util.polling.PollResponse;
 import com.azure.storage.blob.BlobClient;
 import com.azure.storage.blob.BlobContainerClient;
+import com.azure.storage.blob.models.BlobCopyInfo;
 import com.azure.storage.blob.models.BlobItem;
 import com.azure.storage.blob.models.BlobStorageException;
 import org.apache.camel.AsyncCallback;
@@ -280,7 +283,21 @@ public class BlobConsumer extends 
ScheduledBatchPollingConsumer {
                 final String destinationBlobName = 
buildDestinationBlobName(blobName);
 
                 // Copy to destination
-                copyBlobToDestination(containerName, blobName, 
destinationContainer, destinationBlobName);
+                final PollResponse<BlobCopyInfo> copy
+                        = copyBlobToDestination(containerName, blobName, 
destinationContainer, destinationBlobName);
+                if (copy.getStatus() != 
LongRunningOperationStatus.SUCCESSFULLY_COMPLETED) {
+                    // the copy failed, was aborted, or its status could not 
be read: keep the source blob, it is
+                    // consumed again on the next poll
+                    final String error = copy.getValue() != null ? 
copy.getValue().getError() : null;
+                    getExceptionHandler().handleException(
+                            "Error occurred during moving blob. The blob is 
not deleted and will be consumed again.",
+                            exchange,
+                            new IllegalStateException(
+                                    "Copy of blob " + blobName + " to 
container " + destinationContainer
+                                                      + " ended with status " 
+ copy.getStatus()
+                                                      + (error != null ? ": " 
+ error : "")));
+                    return;
+                }
 
                 // Delete source blob after successful copy (move = copy + 
delete)
                 deleteBlob(containerName, blobName);
@@ -338,12 +355,14 @@ public class BlobConsumer extends 
ScheduledBatchPollingConsumer {
     /**
      * Copies a blob from source container to destination container within the 
same storage account.
      *
-     * @param sourceContainer      the source container name
-     * @param sourceBlobName       the source blob name
-     * @param destinationContainer the destination container name
-     * @param destinationBlobName  the destination blob name
+     * @param  sourceContainer      the source container name
+     * @param  sourceBlobName       the source blob name
+     * @param  destinationContainer the destination container name
+     * @param  destinationBlobName  the destination blob name
+     * @return                      the last response of the copy: the copy is 
asynchronous on the service, and the
+     *                              poller returns normally also when the copy 
failed or was aborted
      */
-    private void copyBlobToDestination(
+    private PollResponse<BlobCopyInfo> copyBlobToDestination(
             String sourceContainer, String sourceBlobName,
             String destinationContainer, String destinationBlobName) {
         BlobContainerClient sourceContainerClient
@@ -359,7 +378,7 @@ public class BlobConsumer extends 
ScheduledBatchPollingConsumer {
         if (timeout == null) {
             timeout = Duration.ofMinutes(5);
         }
-        destBlobClient.beginCopy(sourceBlobClient.getBlobUrl(), 
Duration.ofSeconds(2))
+        return destBlobClient.beginCopy(sourceBlobClient.getBlobUrl(), 
Duration.ofSeconds(2))
                 .waitForCompletion(timeout);
     }
 
diff --git 
a/components/camel-azure/camel-azure-storage-blob/src/test/java/org/apache/camel/component/azure/storage/blob/BlobConsumerMoveAfterReadCopyStatusTest.java
 
b/components/camel-azure/camel-azure-storage-blob/src/test/java/org/apache/camel/component/azure/storage/blob/BlobConsumerMoveAfterReadCopyStatusTest.java
new file mode 100644
index 000000000000..6d77f523ea6e
--- /dev/null
+++ 
b/components/camel-azure/camel-azure-storage-blob/src/test/java/org/apache/camel/component/azure/storage/blob/BlobConsumerMoveAfterReadCopyStatusTest.java
@@ -0,0 +1,173 @@
+/*
+ * 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.blob;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.azure.core.http.rest.PagedIterable;
+import com.azure.core.http.rest.PagedResponseBase;
+import com.azure.core.util.polling.LongRunningOperationStatus;
+import com.azure.core.util.polling.PollResponse;
+import com.azure.core.util.polling.SyncPoller;
+import com.azure.storage.blob.BlobClient;
+import com.azure.storage.blob.BlobContainerClient;
+import com.azure.storage.blob.BlobServiceClient;
+import com.azure.storage.blob.models.BlobCopyInfo;
+import com.azure.storage.blob.models.BlobItem;
+import com.azure.storage.blob.models.BlobProperties;
+import com.azure.storage.blob.models.BlobRange;
+import com.azure.storage.blob.models.BlobRequestConditions;
+import com.azure.storage.blob.models.ListBlobsOptions;
+import com.azure.storage.blob.specialized.BlobInputStream;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.ExceptionHandler;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.nullable;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * With moveAfterRead the consumer copies the blob to the destination 
container with
+ * {@code beginCopy(...).waitForCompletion(timeout)} and then deletes the 
source blob. A copy is asynchronous on the
+ * service and the poller returns normally when the copy ended as failed, 
aborted, or when its status could not be read:
+ * the source blob must then stay, else the blob is lost (the destination of a 
failed or aborted copy has no content).
+ */
+class BlobConsumerMoveAfterReadCopyStatusTest extends CamelTestSupport {
+
+    private final AtomicInteger handled = new AtomicInteger();
+    private final CountDownLatch copied = new CountDownLatch(1);
+    private final CountDownLatch errorHandled = new CountDownLatch(1);
+
+    private BlobClient source;
+
+    BlobConsumerMoveAfterReadCopyStatusTest() {
+        testConfiguration().withUseRouteBuilder(false);
+    }
+
+    @Test
+    void sourceBlobIsKeptWhenTheCopyFailed() throws Exception {
+        assertSourceKept(LongRunningOperationStatus.FAILED);
+    }
+
+    @Test
+    void sourceBlobIsKeptWhenTheCopyWasAborted() throws Exception {
+        assertSourceKept(LongRunningOperationStatus.USER_CANCELLED);
+    }
+
+    @Test
+    void sourceBlobIsKeptWhenTheCopyStatusCouldNotBeRead() throws Exception {
+        // the status that BlobClientBase.beginCopy reports when reading the 
copy status of a pending copy failed
+        
assertSourceKept(LongRunningOperationStatus.fromString("POLLING_FAILED", true));
+    }
+
+    @Test
+    void sourceBlobIsDeletedWhenTheCopySucceeded() throws Exception {
+        startRoute(LongRunningOperationStatus.SUCCESSFULLY_COMPLETED);
+
+        assertTrue(copied.await(10, TimeUnit.SECONDS), "the blob was not 
copied");
+        context.getRouteController().stopRoute("blob");
+
+        verify(source).delete();
+        assertEquals(0, handled.get());
+    }
+
+    private void assertSourceKept(LongRunningOperationStatus status) throws 
Exception {
+        startRoute(status);
+
+        assertTrue(copied.await(10, TimeUnit.SECONDS), "the blob was not 
copied");
+        // a graceful stop waits for the exchange and its on completion (the 
move)
+        context.getRouteController().stopRoute("blob");
+
+        verify(source, never()).delete();
+        assertTrue(errorHandled.await(10, TimeUnit.SECONDS), "the copy that 
did not succeed was not reported");
+    }
+
+    private void startRoute(LongRunningOperationStatus copyStatus) throws 
Exception {
+        context.getRegistry().bind("client", serviceClient(copyStatus));
+        context.getRegistry().bind("handler", (ExceptionHandler) new 
ExceptionHandler() {
+            @Override
+            public void handleException(Throwable exception) {
+                handleException(null, null, exception);
+            }
+
+            @Override
+            public void handleException(String message, Throwable exception) {
+                handleException(message, null, exception);
+            }
+
+            @Override
+            public void handleException(String message, Exchange exchange, 
Throwable exception) {
+                handled.incrementAndGet();
+                errorHandled.countDown();
+            }
+        });
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("azure-storage-blob://account/in?serviceClient=#client&moveAfterRead=true"
+                     + 
"&destinationContainer=done&exceptionHandler=#handler&initialDelay=0&delay=60000")
+                        .routeId("blob")
+                        .to("mock:result");
+            }
+        });
+        context.start();
+    }
+
+    @SuppressWarnings("unchecked")
+    private BlobServiceClient serviceClient(LongRunningOperationStatus 
copyStatus) {
+        BlobInputStream stream = mock(BlobInputStream.class);
+        when(stream.getProperties()).thenReturn(mock(BlobProperties.class));
+
+        source = mock(BlobClient.class);
+        when(source.openInputStream(nullable(BlobRange.class), 
nullable(BlobRequestConditions.class))).thenReturn(stream);
+        
when(source.getBlobUrl()).thenReturn("https://account.blob.core.windows.net/in/a.txt";);
+
+        SyncPoller<BlobCopyInfo, Void> poller = mock(SyncPoller.class);
+        when(poller.waitForCompletion(any(Duration.class))).thenAnswer(i -> {
+            copied.countDown();
+            return new PollResponse<BlobCopyInfo>(copyStatus, null);
+        });
+        BlobClient destination = mock(BlobClient.class);
+        when(destination.beginCopy(anyString(), 
any(Duration.class))).thenReturn(poller);
+
+        BlobContainerClient in = mock(BlobContainerClient.class);
+        when(in.listBlobs(any(ListBlobsOptions.class), 
nullable(Duration.class))).thenAnswer(i -> new PagedIterable<>(
+                () -> new PagedResponseBase<Void, BlobItem>(
+                        null, 200, null, List.of(new 
BlobItem().setName("a.txt")), null, null)));
+        when(in.getBlobClient("a.txt")).thenReturn(source);
+        BlobContainerClient done = mock(BlobContainerClient.class);
+        when(done.getBlobClient("a.txt")).thenReturn(destination);
+
+        BlobServiceClient client = mock(BlobServiceClient.class);
+        when(client.getBlobContainerClient("in")).thenReturn(in);
+        when(client.getBlobContainerClient("done")).thenReturn(done);
+        return client;
+    }
+}

Reply via email to