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 ab5932958d36 CAMEL-25551: camel-azure-storage-blob, 
camel-huaweicloud-obs, camel-alibaba-oss, camel-aws2-s3-vectors - do not 
consume an object again while its exchange is in flight (#27682)
ab5932958d36 is described below

commit ab5932958d36cc4e5045a4810afeaf7281587d45
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Oct 11 09:01:12 2026 +0200

    CAMEL-25551: camel-azure-storage-blob, camel-huaweicloud-obs, 
camel-alibaba-oss, camel-aws2-s3-vectors - do not consume an object again while 
its exchange is in flight (#27682)
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../catalog/docs/azure-storage-blob-consumer.adoc  |   2 +
 .../camel/component/alibaba/oss/OSSConsumer.java   |  73 ++++-
 .../alibaba/oss/OSSConsumerInProgressTest.java     | 280 +++++++++++++++++++
 .../aws2/s3vectors/AWS2S3VectorsConsumer.java      |  43 ++-
 .../AWS2S3VectorsConsumerInProgressTest.java       | 163 +++++++++++
 .../src/main/docs/azure-storage-blob-consumer.adoc |   2 +
 .../component/azure/storage/blob/BlobConsumer.java |  82 +++++-
 .../storage/blob/BlobConsumerInProgressTest.java   | 277 +++++++++++++++++++
 .../component/huaweicloud/obs/OBSConsumer.java     |  92 ++++++-
 .../huaweicloud/obs/OBSConsumerInProgressTest.java | 300 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |  20 ++
 11 files changed, 1299 insertions(+), 35 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/azure-storage-blob-consumer.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/azure-storage-blob-consumer.adoc
index 278efa914273..a15d2b697f50 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/azure-storage-blob-consumer.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/azure-storage-blob-consumer.adoc
@@ -171,6 +171,8 @@ IMPORTANT: The delete operation is only performed if the 
Exchange is successfull
 
 NOTE: When `deleteAfterRead` is set to `false` (the default), the same blobs 
will be retrieved repeatedly in subsequent polls. In this case, you should use 
the Idempotent Consumer EIP to filter out duplicates based on the 
`CamelAzureStorageBlobBlobName` header.
 
+NOTE: A blob is not retrieved again while the exchange of a previous poll is 
still processing it, for example when the route continues asynchronously (such 
as `to("seda:...")`). It is retrieved again by a later poll once that exchange 
is done, unless it was deleted or moved. A blob that was deleted (for example 
by another consumer) between the listing and its download is skipped.
+
 === Move After Read
 
 The consumer can move blobs to a different container after successful 
processing. This is useful for archiving processed blobs or implementing a 
processing pipeline across containers.
diff --git 
a/components/camel-alibaba/camel-alibaba-oss/src/main/java/org/apache/camel/component/alibaba/oss/OSSConsumer.java
 
b/components/camel-alibaba/camel-alibaba-oss/src/main/java/org/apache/camel/component/alibaba/oss/OSSConsumer.java
index 4c285604ead4..e83163dcf2d0 100644
--- 
a/components/camel-alibaba/camel-alibaba-oss/src/main/java/org/apache/camel/component/alibaba/oss/OSSConsumer.java
+++ 
b/components/camel-alibaba/camel-alibaba-oss/src/main/java/org/apache/camel/component/alibaba/oss/OSSConsumer.java
@@ -19,8 +19,11 @@ package org.apache.camel.component.alibaba.oss;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Queue;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 
 import com.aliyun.sdk.service.oss2.OSSClient;
+import com.aliyun.sdk.service.oss2.exceptions.ServiceException;
 import com.aliyun.sdk.service.oss2.models.DeleteObjectRequest;
 import com.aliyun.sdk.service.oss2.models.GetObjectRequest;
 import com.aliyun.sdk.service.oss2.models.GetObjectResult;
@@ -44,6 +47,8 @@ public class OSSConsumer extends 
ScheduledBatchPollingConsumer {
     private static final Logger LOG = 
LoggerFactory.getLogger(OSSConsumer.class);
 
     private final OSSEndpoint endpoint;
+    // objects whose exchanges are being processed, which a later poll must 
not consume again
+    private final Set<String> inProgress = ConcurrentHashMap.newKeySet();
     private OSSClient ossClient;
     private String continuationToken;
 
@@ -114,21 +119,32 @@ public class OSSConsumer extends 
ScheduledBatchPollingConsumer {
 
             pendingExchanges = total - index - 1;
 
+            final String objectKey = 
exchange.getIn().getHeader(OSSHeaders.OBJECT_KEY, String.class);
             exchange.getExchangeExtension().addOnCompletion(new 
Synchronization() {
                 @Override
                 public void onComplete(Exchange exchange) {
-                    processComplete(exchange);
+                    try {
+                        processComplete(exchange);
+                    } finally {
+                        inProgress.remove(objectKey);
+                    }
                 }
 
                 @Override
                 public void onFailure(Exchange exchange) {
-                    processFailure(exchange);
+                    try {
+                        processFailure(exchange);
+                    } finally {
+                        inProgress.remove(objectKey);
+                    }
                 }
             });
 
             AsyncCallback callback = defaultConsumerCallback(exchange, true);
             getAsyncProcessor().process(exchange, callback);
         }
+        // the remaining exchanges are not processed as the consumer is 
stopping
+        releaseNotProcessed(exchanges);
 
         return total;
     }
@@ -139,14 +155,31 @@ public class OSSConsumer extends 
ScheduledBatchPollingConsumer {
             return answer;
         }
 
-        for (ObjectSummary summary : summaries) {
-            if (summary.key() == null || summary.key().endsWith("/")) {
-                continue;
+        try {
+            for (ObjectSummary summary : summaries) {
+                if (summary.key() == null || summary.key().endsWith("/")) {
+                    continue;
+                }
+                addExchange(bucketName, summary.key(), answer);
             }
+        } catch (Exception e) {
+            // none of the objects of this poll is processed
+            releaseNotProcessed(answer);
+            throw e;
+        }
+        return answer;
+    }
 
+    private void addExchange(String bucketName, String key, Queue<Exchange> 
answer) throws Exception {
+        // skip the object if an exchange of a previous poll is still 
processing it
+        if (!inProgress.add(key)) {
+            LOG.trace("Skipping object {} as it is already in progress", key);
+            return;
+        }
+        try {
             GetObjectResult result = 
ossClient.getObject(GetObjectRequest.newBuilder()
                     .bucket(bucketName)
-                    .key(summary.key())
+                    .key(key)
                     .build());
 
             byte[] body;
@@ -156,10 +189,34 @@ public class OSSConsumer extends 
ScheduledBatchPollingConsumer {
 
             Exchange exchange = createExchange(true);
             exchange.setPattern(endpoint.getExchangePattern());
-            OSSUtils.mapOssObject(exchange, bucketName, summary.key(), result, 
body);
+            OSSUtils.mapOssObject(exchange, bucketName, key, result, body);
             answer.add(exchange);
+        } catch (Exception e) {
+            inProgress.remove(key);
+            if (isNotFound(e)) {
+                // deleted (for example by another consumer) after it was 
listed
+                LOG.debug("Skipping object {} as it no longer exists", key);
+                return;
+            }
+            throw e;
         }
-        return answer;
+    }
+
+    private static boolean isNotFound(Exception e) {
+        ServiceException se = 
ObjectHelper.getException(ServiceException.class, e);
+        return se != null && se.statusCode() == 404;
+    }
+
+    private void releaseNotProcessed(Queue<?> exchanges) {
+        for (Object o : exchanges) {
+            Exchange exchange = (Exchange) o;
+            String key = exchange.getIn().getHeader(OSSHeaders.OBJECT_KEY, 
String.class);
+            if (key != null) {
+                inProgress.remove(key);
+            }
+            releaseExchange(exchange, true);
+        }
+        exchanges.clear();
     }
 
     private void processComplete(Exchange exchange) {
diff --git 
a/components/camel-alibaba/camel-alibaba-oss/src/test/java/org/apache/camel/component/alibaba/oss/OSSConsumerInProgressTest.java
 
b/components/camel-alibaba/camel-alibaba-oss/src/test/java/org/apache/camel/component/alibaba/oss/OSSConsumerInProgressTest.java
new file mode 100644
index 000000000000..8125bdc8f74a
--- /dev/null
+++ 
b/components/camel-alibaba/camel-alibaba-oss/src/test/java/org/apache/camel/component/alibaba/oss/OSSConsumerInProgressTest.java
@@ -0,0 +1,280 @@
+/*
+ * 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.alibaba.oss;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentSkipListSet;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.aliyun.sdk.service.oss2.OSSClient;
+import com.aliyun.sdk.service.oss2.exceptions.OperationException;
+import com.aliyun.sdk.service.oss2.exceptions.ServiceException;
+import com.aliyun.sdk.service.oss2.models.DeleteObjectRequest;
+import com.aliyun.sdk.service.oss2.models.GetObjectRequest;
+import com.aliyun.sdk.service.oss2.models.GetObjectResult;
+import com.aliyun.sdk.service.oss2.models.ListObjectsV2Request;
+import com.aliyun.sdk.service.oss2.models.ListObjectsV2Result;
+import com.aliyun.sdk.service.oss2.models.ObjectSummary;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Consumer;
+import org.apache.camel.Endpoint;
+import org.apache.camel.RuntimeCamelException;
+import org.apache.camel.ServiceStatus;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.PollingConsumerPollStrategy;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * The consumer keeps the objects whose exchanges are being processed: an 
object still in flight is not consumed again
+ * by the next poll, an object is consumed again once its exchange failed or 
completed (without deleteAfterRead), an
+ * object deleted after the listing is skipped without failing the poll, and 
the objects of a batch that a stopping
+ * consumer does not process are consumed when the route is started again.
+ */
+class OSSConsumerInProgressTest extends CamelTestSupport {
+
+    private final Set<String> objects = new ConcurrentSkipListSet<>();
+    private final Set<String> deletedAfterListing = 
ConcurrentHashMap.newKeySet();
+    private final Map<String, AtomicInteger> gets = new ConcurrentHashMap<>();
+    private final AtomicInteger failedPolls = new AtomicInteger();
+    private final AtomicInteger attempts = new AtomicInteger();
+    private final CountDownLatch inRoute = new CountDownLatch(1);
+    private final CountDownLatch twoPolls = new CountDownLatch(2);
+    private final CountDownLatch release = new CountDownLatch(1);
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+        context.getRegistry().bind("ossClient", ossClient());
+        // counts the polls that completed (the exchanges of the poll were 
handed to the route) and the failed polls
+        context.getRegistry().bind("countPolls", new 
PollingConsumerPollStrategy() {
+            @Override
+            public boolean begin(Consumer consumer, Endpoint endpoint) {
+                return true;
+            }
+
+            @Override
+            public void commit(Consumer consumer, Endpoint endpoint, int 
polledMessages) {
+                twoPolls.countDown();
+            }
+
+            @Override
+            public boolean rollback(Consumer consumer, Endpoint endpoint, int 
retryCounter, Exception e) {
+                failedPolls.incrementAndGet();
+                return false;
+            }
+        });
+        return context;
+    }
+
+    @AfterEach
+    void releaseRoute() {
+        release.countDown();
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                // seda hands the on completions (the delete) over to this 
route
+                from("seda:hold")
+                        .process(e -> {
+                            inRoute.countDown();
+                            release.await(20, TimeUnit.SECONDS);
+                        })
+                        .to("mock:result");
+
+                from("seda:failOnce")
+                        .process(e -> {
+                            if (attempts.incrementAndGet() == 1) {
+                                throw new IllegalStateException("first attempt 
fails");
+                            }
+                        })
+                        .to("mock:result");
+            }
+        };
+    }
+
+    @Test
+    void objectInFlightIsNotConsumedAgain() throws Exception {
+        objects.add("a.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("a.txt");
+
+        addConsumerRoute("&deleteAfterRead=true&pollStrategy=#countPolls", 
"seda:hold");
+
+        assertThat(inRoute.await(20, TimeUnit.SECONDS)).as("the object was not 
consumed").isTrue();
+        // the second poll has completed while the first exchange is still 
held in the seda route
+        assertThat(twoPolls.await(20, TimeUnit.SECONDS)).as("the consumer did 
not poll again").isTrue();
+        int getsWhileInFlight = gets("a.txt");
+        release.countDown();
+
+        assertThat(getsWhileInFlight)
+                .as("a.txt was consumed again by a later poll while its first 
exchange was still in flight")
+                .isEqualTo(1);
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void failedObjectIsConsumedAgain() throws Exception {
+        objects.add("a.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("a.txt");
+
+        addConsumerRoute("&deleteAfterRead=true", "seda:failOnce");
+
+        mock.assertIsSatisfied();
+        assertThat(attempts).as("a.txt should be consumed again after its 
first exchange failed").hasValue(2);
+    }
+
+    @Test
+    void completedObjectIsConsumedAgainWithoutDelete() throws Exception {
+        objects.add("a.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedMinimumMessageCount(2);
+        mock.allMessages().body().isEqualTo("a.txt");
+
+        addConsumerRoute("", "mock:result");
+
+        // without deleteAfterRead every poll reads the object again once its 
previous exchange is done
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void deletedObjectIsSkipped() throws Exception {
+        objects.add("a.txt");
+        objects.add("b.txt");
+        objects.add("c.txt");
+        deletedAfterListing.add("b.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceivedInAnyOrder("a.txt", "c.txt");
+
+        addConsumerRoute("&deleteAfterRead=true&pollStrategy=#countPolls", 
"mock:result");
+
+        mock.assertIsSatisfied();
+        assertThat(failedPolls).as("the poll failed because b.txt was deleted 
after the listing").hasValue(0);
+        assertThat(gets("a.txt")).as("a.txt was read again because the poll 
that listed b.txt failed").isEqualTo(1);
+    }
+
+    @Test
+    void objectsOfAStoppedBatchAreConsumedAfterRestart() throws Exception {
+        objects.add("a.txt");
+        objects.add("b.txt");
+        objects.add("c.txt");
+        AtomicBoolean first = new AtomicBoolean(true);
+        MockEndpoint mock = getMockEndpoint("mock:result");
+
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("alibaba-oss:bucket?ossClient=#ossClient&initialDelay=0&delay=10&deleteAfterRead=true").routeId("oss")
+                        .process(e -> {
+                            if (first.compareAndSet(true, false)) {
+                                // stop the route while the first exchange of 
the batch is processed, and wait until
+                                // the consumer does not process the rest of 
the batch
+                                OSSConsumer consumer = (OSSConsumer) 
context.getRoute("oss").getConsumer();
+                                CompletableFuture.runAsync(() -> {
+                                    try {
+                                        
context.getRouteController().stopRoute("oss");
+                                    } catch (Exception ex) {
+                                        throw new RuntimeCamelException(ex);
+                                    }
+                                });
+                                await().atMost(20, TimeUnit.SECONDS).until(() 
-> !consumer.isBatchAllowed());
+                            }
+                        })
+                        .convertBodyTo(String.class)
+                        .to("mock:result");
+            }
+        });
+
+        mock.expectedBodiesReceived("a.txt");
+        mock.assertIsSatisfied();
+        await().atMost(20, TimeUnit.SECONDS)
+                .until(() -> 
context.getRouteController().getRouteStatus("oss") == ServiceStatus.Stopped);
+
+        mock.reset();
+        mock.expectedBodiesReceivedInAnyOrder("b.txt", "c.txt");
+        context.getRouteController().startRoute("oss");
+
+        mock.assertIsSatisfied();
+    }
+
+    private void addConsumerRoute(String options, String to) throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("alibaba-oss:bucket?ossClient=#ossClient&initialDelay=0&delay=10" + 
options)
+                        .convertBodyTo(String.class)
+                        .to(to);
+            }
+        });
+    }
+
+    private int gets(String key) {
+        AtomicInteger n = gets.get(key);
+        return n != null ? n.get() : 0;
+    }
+
+    private OSSClient ossClient() {
+        OSSClient client = Mockito.mock(OSSClient.class);
+        
Mockito.when(client.listObjectsV2(Mockito.any(ListObjectsV2Request.class))).thenAnswer(i
 -> {
+            List<ObjectSummary> contents = objects.stream().map(key -> {
+                ObjectSummary summary = Mockito.mock(ObjectSummary.class);
+                Mockito.when(summary.key()).thenReturn(key);
+                return summary;
+            }).toList();
+            // deleted (as if by another consumer) right after the listing 
returned it
+            objects.removeAll(deletedAfterListing);
+            ListObjectsV2Result result = 
Mockito.mock(ListObjectsV2Result.class);
+            Mockito.when(result.contents()).thenReturn(contents);
+            return result;
+        });
+        
Mockito.when(client.getObject(Mockito.any(GetObjectRequest.class))).thenAnswer(i
 -> {
+            String key = i.<GetObjectRequest> getArgument(0).key();
+            if (!objects.contains(key)) {
+                throw new OperationException(
+                        "GetObject", 
ServiceException.newBuilder().statusCode(404).build());
+            }
+            gets.computeIfAbsent(key, k -> new 
AtomicInteger()).incrementAndGet();
+            GetObjectResult result = Mockito.mock(GetObjectResult.class);
+            Mockito.when(result.body()).thenReturn(new 
ByteArrayInputStream(key.getBytes(StandardCharsets.UTF_8)));
+            return result;
+        });
+        
Mockito.when(client.deleteObject(Mockito.any(DeleteObjectRequest.class))).thenAnswer(i
 -> {
+            objects.remove(i.<DeleteObjectRequest> getArgument(0).key());
+            return null;
+        });
+        return client;
+    }
+}
diff --git 
a/components/camel-aws/camel-aws2-s3-vectors/src/main/java/org/apache/camel/component/aws2/s3vectors/AWS2S3VectorsConsumer.java
 
b/components/camel-aws/camel-aws2-s3-vectors/src/main/java/org/apache/camel/component/aws2/s3vectors/AWS2S3VectorsConsumer.java
index 3a7811d62dc0..134aa102e3c3 100644
--- 
a/components/camel-aws/camel-aws2-s3-vectors/src/main/java/org/apache/camel/component/aws2/s3vectors/AWS2S3VectorsConsumer.java
+++ 
b/components/camel-aws/camel-aws2-s3-vectors/src/main/java/org/apache/camel/component/aws2/s3vectors/AWS2S3VectorsConsumer.java
@@ -17,11 +17,11 @@
 package org.apache.camel.component.aws2.s3vectors;
 
 import java.util.ArrayList;
-import java.util.HashSet;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Queue;
 import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 
 import org.apache.camel.AsyncCallback;
 import org.apache.camel.Exchange;
@@ -47,7 +47,10 @@ public class AWS2S3VectorsConsumer extends 
ScheduledBatchPollingConsumer {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(AWS2S3VectorsConsumer.class);
 
-    private final Set<String> processedVectorIds = new HashSet<>();
+    // vectors already consumed (without deleteAfterRead) or whose exchanges 
are being processed (with
+    // deleteAfterRead), which a later poll must not consume again. It is 
updated from the threads that complete the
+    // exchanges, so it must be thread-safe.
+    private final Set<String> processedVectorIds = 
ConcurrentHashMap.newKeySet();
 
     public AWS2S3VectorsConsumer(AWS2S3VectorsEndpoint endpoint, Processor 
processor) {
         super(endpoint, processor);
@@ -103,8 +106,8 @@ public class AWS2S3VectorsConsumer extends 
ScheduledBatchPollingConsumer {
                 for (QueryOutputVector vector : vectors) {
                     String vectorId = vector.key();
 
-                    // Skip if already processed (to avoid duplicates in 
subsequent polls)
-                    if (processedVectorIds.contains(vectorId)) {
+                    // Skip if already processed or still in flight (to avoid 
duplicates in subsequent polls)
+                    if (vectorId != null && !processedVectorIds.add(vectorId)) 
{
                         LOG.trace("Skipping already processed vector [{}]", 
vectorId);
                         continue;
                     }
@@ -123,15 +126,16 @@ public class AWS2S3VectorsConsumer extends 
ScheduledBatchPollingConsumer {
 
                     // Add delete callback if deleteAfterRead is enabled
                     if (getConfiguration().isDeleteAfterRead()) {
+                        // the vector is tracked only while its exchange is in 
flight, as a deleted vector cannot be
+                        // returned again, and one that failed must be retried 
on a subsequent poll
                         exchange.getExchangeExtension().addOnCompletion(
                                 new VectorDeleteSynchronization(
                                         getS3VectorsClient(), 
vectorBucketName, vectorIndexName,
                                         vectorId));
                     } else {
-                        // Track for de-duplication only when we are not 
deleting (a deleted vector cannot be
-                        // returned again). Mark it now to avoid re-delivering 
it on overlapping polls, but drop it
-                        // again if the exchange fails so it can be retried on 
a subsequent poll.
-                        processedVectorIds.add(vectorId);
+                        // Track for de-duplication when we are not deleting, 
to avoid re-delivering the vector on
+                        // later polls, but drop it again if the exchange 
fails so it can be retried on a subsequent
+                        // poll.
                         exchange.getExchangeExtension().addOnCompletion(new 
VectorDedupSynchronization(vectorId));
                     }
 
@@ -168,6 +172,13 @@ public class AWS2S3VectorsConsumer extends 
ScheduledBatchPollingConsumer {
             AsyncCallback cb = defaultConsumerCallback(exchange, true);
             getAsyncProcessor().process(exchange, cb);
         }
+        // the remaining exchanges are not processed as the consumer is 
stopping, so a later poll must consume their
+        // vectors again
+        for (Object exchange : exchanges) {
+            releaseVector(((Exchange) 
exchange).getIn().getHeader(AWS2S3VectorsConstants.VECTOR_ID, String.class));
+            releaseExchange((Exchange) exchange, false);
+        }
+        exchanges.clear();
 
         return total;
     }
@@ -238,10 +249,17 @@ public class AWS2S3VectorsConsumer extends 
ScheduledBatchPollingConsumer {
         return result;
     }
 
+    private void releaseVector(String vectorId) {
+        if (vectorId != null) {
+            processedVectorIds.remove(vectorId);
+        }
+    }
+
     /**
-     * Synchronization callback to delete a vector after successful processing
+     * Synchronization callback to delete a vector after successful 
processing. The vector is no longer in flight once
+     * its exchange is done, whether it was deleted or failed.
      */
-    private static class VectorDeleteSynchronization extends 
SynchronizationAdapter {
+    private class VectorDeleteSynchronization extends SynchronizationAdapter {
 
         private final S3VectorsClient client;
         private final String vectorBucketName;
@@ -275,12 +293,15 @@ public class AWS2S3VectorsConsumer extends 
ScheduledBatchPollingConsumer {
                 LOG.debug("Deleted vector [{}] after successful processing", 
vectorId);
             } catch (Exception e) {
                 LOG.warn("Error deleting vector [{}] after processing: {}", 
vectorId, e.getMessage(), e);
+            } finally {
+                releaseVector(vectorId);
             }
         }
 
         @Override
         public void onFailure(Exchange exchange) {
             LOG.trace("Exchange failed, not deleting vector [{}]", vectorId);
+            releaseVector(vectorId);
         }
     }
 
@@ -298,7 +319,7 @@ public class AWS2S3VectorsConsumer extends 
ScheduledBatchPollingConsumer {
 
         @Override
         public void onFailure(Exchange exchange) {
-            processedVectorIds.remove(vectorId);
+            releaseVector(vectorId);
         }
     }
 }
diff --git 
a/components/camel-aws/camel-aws2-s3-vectors/src/test/java/org/apache/camel/component/aws2/s3vectors/AWS2S3VectorsConsumerInProgressTest.java
 
b/components/camel-aws/camel-aws2-s3-vectors/src/test/java/org/apache/camel/component/aws2/s3vectors/AWS2S3VectorsConsumerInProgressTest.java
new file mode 100644
index 000000000000..b89d50f3cd93
--- /dev/null
+++ 
b/components/camel-aws/camel-aws2-s3-vectors/src/test/java/org/apache/camel/component/aws2/s3vectors/AWS2S3VectorsConsumerInProgressTest.java
@@ -0,0 +1,163 @@
+/*
+ * 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.s3vectors;
+
+import java.util.Set;
+import java.util.concurrent.ConcurrentSkipListSet;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Consumer;
+import org.apache.camel.Endpoint;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.PollingConsumerPollStrategy;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import software.amazon.awssdk.services.s3vectors.S3VectorsClient;
+import software.amazon.awssdk.services.s3vectors.model.DeleteVectorsRequest;
+import software.amazon.awssdk.services.s3vectors.model.DeleteVectorsResponse;
+import software.amazon.awssdk.services.s3vectors.model.QueryOutputVector;
+import software.amazon.awssdk.services.s3vectors.model.QueryVectorsRequest;
+import software.amazon.awssdk.services.s3vectors.model.QueryVectorsResponse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * With deleteAfterRead the consumer keeps the vectors whose exchanges are in 
flight: such a vector is not consumed
+ * again by the next poll, and a vector whose exchange failed is consumed 
again.
+ */
+class AWS2S3VectorsConsumerInProgressTest extends CamelTestSupport {
+
+    private final Set<String> vectors = new ConcurrentSkipListSet<>();
+    private final AtomicInteger consumed = new AtomicInteger();
+    private final AtomicInteger attempts = new AtomicInteger();
+    private final CountDownLatch inRoute = new CountDownLatch(1);
+    private final CountDownLatch twoPolls = new CountDownLatch(2);
+    private final CountDownLatch release = new CountDownLatch(1);
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+        S3VectorsClient client = Mockito.mock(S3VectorsClient.class);
+        
Mockito.when(client.queryVectors(Mockito.any(QueryVectorsRequest.class))).thenAnswer(i
 -> QueryVectorsResponse
+                .builder()
+                .vectors(vectors.stream().map(key -> 
QueryOutputVector.builder().key(key).build()).toList())
+                .build());
+        
Mockito.when(client.deleteVectors(Mockito.any(DeleteVectorsRequest.class))).thenAnswer(i
 -> {
+            i.<DeleteVectorsRequest> 
getArgument(0).keys().forEach(vectors::remove);
+            return DeleteVectorsResponse.builder().build();
+        });
+        context.getRegistry().bind("client", client);
+        // counts the polls that completed (the exchanges of the poll were 
handed to the route)
+        context.getRegistry().bind("countPolls", new 
PollingConsumerPollStrategy() {
+            @Override
+            public boolean begin(Consumer consumer, Endpoint endpoint) {
+                return true;
+            }
+
+            @Override
+            public void commit(Consumer consumer, Endpoint endpoint, int 
polledMessages) {
+                twoPolls.countDown();
+            }
+
+            @Override
+            public boolean rollback(Consumer consumer, Endpoint endpoint, int 
retryCounter, Exception e) {
+                return false;
+            }
+        });
+        return context;
+    }
+
+    @AfterEach
+    void releaseRoute() {
+        release.countDown();
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                // seda hands the on completions (the delete) over to this 
route
+                from("seda:hold")
+                        .process(e -> {
+                            inRoute.countDown();
+                            release.await(20, TimeUnit.SECONDS);
+                        })
+                        .to("mock:result");
+
+                from("seda:failOnce")
+                        .process(e -> {
+                            if (attempts.incrementAndGet() == 1) {
+                                throw new IllegalStateException("first attempt 
fails");
+                            }
+                        })
+                        .to("mock:result");
+            }
+        };
+    }
+
+    @Test
+    void vectorInFlightIsNotConsumedAgain() throws Exception {
+        vectors.add("v1");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedHeaderReceived(AWS2S3VectorsConstants.VECTOR_ID, "v1");
+
+        addConsumerRoute("&pollStrategy=#countPolls", "seda:hold");
+
+        assertThat(inRoute.await(20, TimeUnit.SECONDS)).as("the vector was not 
consumed").isTrue();
+        // the second poll has completed while the first exchange is still 
held in the seda route
+        assertThat(twoPolls.await(20, TimeUnit.SECONDS)).as("the consumer did 
not poll again").isTrue();
+        int consumedWhileInFlight = consumed.get();
+        release.countDown();
+
+        assertThat(consumedWhileInFlight)
+                .as("v1 was consumed again by a later poll while its first 
exchange was still in flight")
+                .isEqualTo(1);
+        mock.assertIsSatisfied();
+        assertThat(vectors).as("v1 should be deleted after its exchange 
completed").isEmpty();
+    }
+
+    @Test
+    void failedVectorIsConsumedAgain() throws Exception {
+        vectors.add("v1");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedHeaderReceived(AWS2S3VectorsConstants.VECTOR_ID, "v1");
+
+        addConsumerRoute("", "seda:failOnce");
+
+        mock.assertIsSatisfied();
+        assertThat(attempts).as("v1 should be consumed again after its first 
exchange failed").hasValue(2);
+    }
+
+    private void addConsumerRoute(String options, String to) throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("aws2-s3-vectors://bucket?vectorIndexName=index&s3VectorsClient=#client&consumerQueryVector=0.1,0.2"
+                     + "&deleteAfterRead=true&initialDelay=0&delay=10" + 
options)
+                        .process(e -> consumed.incrementAndGet())
+                        .to(to);
+            }
+        });
+    }
+}
diff --git 
a/components/camel-azure/camel-azure-storage-blob/src/main/docs/azure-storage-blob-consumer.adoc
 
b/components/camel-azure/camel-azure-storage-blob/src/main/docs/azure-storage-blob-consumer.adoc
index 278efa914273..a15d2b697f50 100644
--- 
a/components/camel-azure/camel-azure-storage-blob/src/main/docs/azure-storage-blob-consumer.adoc
+++ 
b/components/camel-azure/camel-azure-storage-blob/src/main/docs/azure-storage-blob-consumer.adoc
@@ -171,6 +171,8 @@ IMPORTANT: The delete operation is only performed if the 
Exchange is successfull
 
 NOTE: When `deleteAfterRead` is set to `false` (the default), the same blobs 
will be retrieved repeatedly in subsequent polls. In this case, you should use 
the Idempotent Consumer EIP to filter out duplicates based on the 
`CamelAzureStorageBlobBlobName` header.
 
+NOTE: A blob is not retrieved again while the exchange of a previous poll is 
still processing it, for example when the route continues asynchronously (such 
as `to("seda:...")`). It is retrieved again by a later poll once that exchange 
is done, unless it was deleted or moved. A blob that was deleted (for example 
by another consumer) between the listing and its download is skipped.
+
 === Move After Read
 
 The consumer can move blobs to a different container after successful 
processing. This is useful for archiving processed blobs or implementing a 
processing pipeline across containers.
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 89a16bf70561..c08ff5a44bea 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
@@ -16,11 +16,14 @@
  */
 package org.apache.camel.component.azure.storage.blob;
 
+import java.io.Closeable;
 import java.io.IOException;
 import java.time.Duration;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Queue;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 
 import com.azure.storage.blob.BlobClient;
 import com.azure.storage.blob.BlobContainerClient;
@@ -38,6 +41,7 @@ import 
org.apache.camel.component.azure.storage.blob.operations.BlobOperations;
 import org.apache.camel.spi.Synchronization;
 import org.apache.camel.support.ScheduledBatchPollingConsumer;
 import org.apache.camel.util.CastUtils;
+import org.apache.camel.util.IOHelper;
 import org.apache.camel.util.ObjectHelper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -46,6 +50,9 @@ public class BlobConsumer extends 
ScheduledBatchPollingConsumer {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(BlobConsumer.class);
 
+    // blobs whose exchanges are being processed, which a later poll must not 
consume again
+    private final Set<String> inProgress = ConcurrentHashMap.newKeySet();
+
     public BlobConsumer(final BlobEndpoint endpoint, final Processor 
processor) {
         super(endpoint, processor);
     }
@@ -75,9 +82,8 @@ public class BlobConsumer extends 
ScheduledBatchPollingConsumer {
         try {
             if (ObjectHelper.isNotEmpty(blobName)) {
                 // here we have a blob which means we just download a single 
blob
-                final Exchange exchange = createExchangeFromBlob(blobName, 
blobContainerClient);
                 exchanges = new LinkedList<>();
-                exchanges.add(exchange);
+                addExchange(blobName, blobContainerClient, exchanges, false);
             } else {
                 // download multiple blobs since we only have no blobName set
                 exchanges = 
createBatchExchangesFromContainer(blobContainerClient);
@@ -127,12 +133,59 @@ public class BlobConsumer extends 
ScheduledBatchPollingConsumer {
         forceConsumerAsReady();
 
         final Queue<Exchange> exchanges = new LinkedList<>();
-        for (BlobItem blobItem : blobs) {
-            exchanges.add(createExchangeFromBlob(blobItem.getName(), 
blobContainerClient));
+        try {
+            for (BlobItem blobItem : blobs) {
+                addExchange(blobItem.getName(), blobContainerClient, 
exchanges, true);
+            }
+        } catch (Exception e) {
+            // none of the blobs of this poll is processed
+            releaseNotProcessed(exchanges);
+            throw e;
         }
         return exchanges;
     }
 
+    private void addExchange(
+            final String blobName, final BlobContainerClient 
blobContainerClient, final Queue<Exchange> exchanges,
+            final boolean listed)
+            throws IOException {
+        // skip the blob if an exchange of a previous poll is still processing 
it
+        if (!inProgress.add(blobName)) {
+            LOG.trace("Skipping blob {} as it is already in progress", 
blobName);
+            return;
+        }
+        try {
+            exchanges.add(createExchangeFromBlob(blobName, 
blobContainerClient));
+        } catch (Exception e) {
+            inProgress.remove(blobName);
+            if (listed && e instanceof BlobStorageException bse && 
bse.getStatusCode() == 404) {
+                // deleted (for example by another consumer) after it was 
listed
+                LOG.debug("Skipping blob {} as it no longer exists", blobName);
+                return;
+            }
+            throw e;
+        }
+    }
+
+    /**
+     * Releases the exchanges that are not routed: their blobs are no longer 
in progress, so a later poll consumes them
+     * again, and their blob input streams are closed, as no on completion 
does it.
+     */
+    private void releaseNotProcessed(final Queue<?> exchanges) {
+        for (Object o : exchanges) {
+            final Exchange exchange = (Exchange) o;
+            final String name = 
exchange.getIn().getHeader(BlobConstants.BLOB_NAME, String.class);
+            if (name != null) {
+                inProgress.remove(name);
+            }
+            if (exchange.getIn().getBody() instanceof Closeable body) {
+                IOHelper.close(body);
+            }
+            releaseExchange(exchange, true);
+        }
+        exchanges.clear();
+    }
+
     @Override
     public BlobEndpoint getEndpoint() {
         return (BlobEndpoint) super.getEndpoint();
@@ -154,16 +207,25 @@ public class BlobConsumer extends 
ScheduledBatchPollingConsumer {
             // update pending number of exchanges
             pendingExchanges = total - index - 1;
 
+            final String blobName = 
exchange.getIn().getHeader(BlobConstants.BLOB_NAME, String.class);
             // 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);
+                    try {
+                        processCommit(exchange);
+                    } finally {
+                        releaseInProgress(blobName);
+                    }
                 }
 
                 @Override
                 public void onFailure(Exchange exchange) {
-                    processRollback(exchange);
+                    try {
+                        processRollback(exchange);
+                    } finally {
+                        releaseInProgress(blobName);
+                    }
                 }
             });
 
@@ -171,9 +233,17 @@ public class BlobConsumer extends 
ScheduledBatchPollingConsumer {
             AsyncCallback cb = defaultConsumerCallback(exchange, true);
             getAsyncProcessor().process(exchange, cb);
         }
+        // the remaining exchanges are not processed as the consumer is 
stopping
+        releaseNotProcessed(exchanges);
         return total;
     }
 
+    private void releaseInProgress(final String blobName) {
+        if (blobName != null) {
+            inProgress.remove(blobName);
+        }
+    }
+
     /**
      * Strategy when processing the exchange failed.
      *
diff --git 
a/components/camel-azure/camel-azure-storage-blob/src/test/java/org/apache/camel/component/azure/storage/blob/BlobConsumerInProgressTest.java
 
b/components/camel-azure/camel-azure-storage-blob/src/test/java/org/apache/camel/component/azure/storage/blob/BlobConsumerInProgressTest.java
new file mode 100644
index 000000000000..26d8c4d4f87f
--- /dev/null
+++ 
b/components/camel-azure/camel-azure-storage-blob/src/test/java/org/apache/camel/component/azure/storage/blob/BlobConsumerInProgressTest.java
@@ -0,0 +1,277 @@
+/*
+ * 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.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentSkipListSet;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.azure.core.http.HttpResponse;
+import com.azure.core.http.rest.PagedIterable;
+import com.azure.storage.blob.BlobClient;
+import com.azure.storage.blob.BlobContainerClient;
+import com.azure.storage.blob.BlobServiceClient;
+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.BlobStorageException;
+import com.azure.storage.blob.specialized.BlobInputStream;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Consumer;
+import org.apache.camel.Endpoint;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.PollingConsumerPollStrategy;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.AfterEach;
+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.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * The consumer keeps the blobs whose exchanges are being processed: a blob 
still in flight is not consumed again by the
+ * next poll, a blob is consumed again once its exchange failed or completed 
(without deleteAfterRead), a blob deleted
+ * after the listing is skipped without failing the poll, and the blobs of a 
batch that a stopping consumer does not
+ * process are released and their streams closed.
+ */
+class BlobConsumerInProgressTest extends CamelTestSupport {
+
+    private final Set<String> blobs = new ConcurrentSkipListSet<>();
+    private final Set<String> deletedAfterListing = 
ConcurrentHashMap.newKeySet();
+    private final Map<String, AtomicInteger> reads = new ConcurrentHashMap<>();
+    private final List<BlobInputStream> streams = new CopyOnWriteArrayList<>();
+    private final AtomicInteger failedPolls = new AtomicInteger();
+    private final AtomicInteger attempts = new AtomicInteger();
+    private final CountDownLatch inRoute = new CountDownLatch(1);
+    private final CountDownLatch twoPolls = new CountDownLatch(2);
+    private final CountDownLatch release = new CountDownLatch(1);
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+        context.getRegistry().bind("client", serviceClient());
+        // counts the polls that completed (the exchanges of the poll were 
handed to the route) and the failed polls
+        context.getRegistry().bind("countPolls", new 
PollingConsumerPollStrategy() {
+            @Override
+            public boolean begin(Consumer consumer, Endpoint endpoint) {
+                return true;
+            }
+
+            @Override
+            public void commit(Consumer consumer, Endpoint endpoint, int 
polledMessages) {
+                twoPolls.countDown();
+            }
+
+            @Override
+            public boolean rollback(Consumer consumer, Endpoint endpoint, int 
retryCounter, Exception e) {
+                failedPolls.incrementAndGet();
+                return false;
+            }
+        });
+        return context;
+    }
+
+    @AfterEach
+    void releaseRoute() {
+        release.countDown();
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                // seda hands the on completions (the delete) over to this 
route
+                from("seda:hold")
+                        .process(e -> {
+                            inRoute.countDown();
+                            release.await(20, TimeUnit.SECONDS);
+                        })
+                        .to("mock:result");
+
+                from("seda:failOnce")
+                        .process(e -> {
+                            if (attempts.incrementAndGet() == 1) {
+                                throw new IllegalStateException("first attempt 
fails");
+                            }
+                        })
+                        .to("mock:result");
+            }
+        };
+    }
+
+    @Test
+    void blobInFlightIsNotConsumedAgain() throws Exception {
+        assertNotConsumedAgain("");
+    }
+
+    @Test
+    void blobInFlightIsNotConsumedAgainWithBlobName() throws Exception {
+        assertNotConsumedAgain("&blobName=a.txt");
+    }
+
+    @Test
+    void failedBlobIsConsumedAgain() throws Exception {
+        blobs.add("a.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedHeaderReceived(BlobConstants.BLOB_NAME, "a.txt");
+
+        addConsumerRoute("&deleteAfterRead=true", "seda:failOnce");
+
+        mock.assertIsSatisfied();
+        assertEquals(2, attempts.get(), "a.txt should be consumed again after 
its first exchange failed");
+    }
+
+    @Test
+    void completedBlobIsConsumedAgainWithoutDelete() throws Exception {
+        blobs.add("a.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedMinimumMessageCount(2);
+        mock.allMessages().header(BlobConstants.BLOB_NAME).isEqualTo("a.txt");
+
+        addConsumerRoute("", "mock:result");
+
+        // without deleteAfterRead every poll reads the blob again once its 
previous exchange is done
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void deletedBlobIsSkipped() throws Exception {
+        blobs.add("a.txt");
+        blobs.add("b.txt");
+        blobs.add("c.txt");
+        deletedAfterListing.add("b.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedHeaderValuesReceivedInAnyOrder(BlobConstants.BLOB_NAME, 
"a.txt", "c.txt");
+
+        addConsumerRoute("&deleteAfterRead=true&pollStrategy=#countPolls", 
"mock:result");
+
+        mock.assertIsSatisfied();
+        // the poll that listed b.txt is not dropped, so the blobs read before 
b.txt are not read again by a later poll
+        assertEquals(1, reads("a.txt"), "a.txt was read again because b.txt 
was deleted after the listing");
+        assertEquals(1, reads("c.txt"));
+        assertEquals(0, failedPolls.get(), "the poll failed because b.txt was 
deleted after the listing");
+    }
+
+    @Test
+    void notProcessedBlobIsReleased() throws Exception {
+        blobs.add("a.txt");
+        BlobEndpoint endpoint = context.getEndpoint(
+                
"azure-storage-blob://account/container?serviceClient=#client&deleteAfterRead=true",
 BlobEndpoint.class);
+        // never started, so the consumer does not process any exchange of the 
batch
+        BlobConsumer consumer = (BlobConsumer) 
endpoint.createConsumer(exchange -> {
+            throw new IllegalStateException("no exchange should be processed");
+        });
+
+        consumer.poll();
+        consumer.poll();
+
+        assertEquals(2, reads("a.txt"), "a.txt stayed in progress although its 
exchange was not processed");
+        assertEquals(2, streams.size());
+        for (BlobInputStream stream : streams) {
+            verify(stream).close();
+        }
+    }
+
+    private void assertNotConsumedAgain(String options) throws Exception {
+        blobs.add("a.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedHeaderReceived(BlobConstants.BLOB_NAME, "a.txt");
+
+        addConsumerRoute("&deleteAfterRead=true&pollStrategy=#countPolls" + 
options, "seda:hold");
+
+        assertTrue(inRoute.await(20, TimeUnit.SECONDS), "the blob was not 
consumed");
+        // the second poll has completed while the first exchange is still 
held in the seda route
+        assertTrue(twoPolls.await(20, TimeUnit.SECONDS), "the consumer did not 
poll again");
+        int readsWhileInFlight = reads("a.txt");
+        release.countDown();
+
+        assertEquals(1, readsWhileInFlight,
+                "a.txt was consumed again by a later poll while its first 
exchange was still in flight");
+        mock.assertIsSatisfied();
+    }
+
+    private void addConsumerRoute(String options, String to) throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("azure-storage-blob://account/container?serviceClient=#client&initialDelay=0&delay=10"
 + options)
+                        .to(to);
+            }
+        });
+    }
+
+    private int reads(String name) {
+        AtomicInteger n = reads.get(name);
+        return n != null ? n.get() : 0;
+    }
+
+    @SuppressWarnings("unchecked")
+    private BlobServiceClient serviceClient() {
+        BlobServiceClient serviceClient = mock(BlobServiceClient.class);
+        BlobContainerClient containerClient = mock(BlobContainerClient.class);
+        
when(serviceClient.getBlobContainerClient(anyString())).thenReturn(containerClient);
+
+        PagedIterable<BlobItem> listing = mock(PagedIterable.class);
+        when(listing.stream()).thenAnswer(i -> {
+            List<BlobItem> items = blobs.stream().map(name -> new 
BlobItem().setName(name)).toList();
+            // deleted (as if by another consumer) right after the listing 
returned it
+            blobs.removeAll(deletedAfterListing);
+            return items.stream();
+        });
+        when(containerClient.listBlobs(any(), any())).thenReturn(listing);
+
+        when(containerClient.getBlobClient(anyString())).thenAnswer(i -> 
blobClient(i.getArgument(0)));
+        return serviceClient;
+    }
+
+    private BlobClient blobClient(String name) {
+        BlobClient blobClient = mock(BlobClient.class);
+        when(blobClient.openInputStream(nullable(BlobRange.class), 
nullable(BlobRequestConditions.class))).thenAnswer(i -> {
+            if (!blobs.contains(name)) {
+                HttpResponse response = mock(HttpResponse.class);
+                when(response.getStatusCode()).thenReturn(404);
+                throw new BlobStorageException("The specified blob does not 
exist.", response, null);
+            }
+            reads.computeIfAbsent(name, k -> new 
AtomicInteger()).incrementAndGet();
+            BlobInputStream stream = mock(BlobInputStream.class);
+            
when(stream.getProperties()).thenReturn(mock(BlobProperties.class));
+            streams.add(stream);
+            return stream;
+        });
+        doAnswer(i -> {
+            blobs.remove(name);
+            return null;
+        }).when(blobClient).delete();
+        return blobClient;
+    }
+}
diff --git 
a/components/camel-huawei/camel-huaweicloud-obs/src/main/java/org/apache/camel/component/huaweicloud/obs/OBSConsumer.java
 
b/components/camel-huawei/camel-huaweicloud-obs/src/main/java/org/apache/camel/component/huaweicloud/obs/OBSConsumer.java
index 79d2b361b34d..385fb1e4f479 100644
--- 
a/components/camel-huawei/camel-huaweicloud-obs/src/main/java/org/apache/camel/component/huaweicloud/obs/OBSConsumer.java
+++ 
b/components/camel-huawei/camel-huaweicloud-obs/src/main/java/org/apache/camel/component/huaweicloud/obs/OBSConsumer.java
@@ -16,10 +16,11 @@
  */
 package org.apache.camel.component.huaweicloud.obs;
 
-import java.util.ArrayList;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Queue;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 
 import com.obs.services.ObsClient;
 import com.obs.services.exception.ObsException;
@@ -44,6 +45,8 @@ public class OBSConsumer extends 
ScheduledBatchPollingConsumer {
     private static final Logger LOG = 
LoggerFactory.getLogger(OBSConsumer.class.getName());
 
     private final OBSEndpoint endpoint;
+    // objects whose exchanges are being processed, which a later poll must 
not consume again
+    private final Set<String> inProgress = ConcurrentHashMap.newKeySet();
     private ObsClient obsClient;
     private String marker;
     private boolean destinationBucketCreated;
@@ -117,10 +120,20 @@ public class OBSConsumer extends 
ScheduledBatchPollingConsumer {
             exchanges = new LinkedList<>();
         } else if (ObjectHelper.isNotEmpty(fileName)) {
             // when file name is given, just download that object
-            ObsObject object = obsClient.getObject(bucketName, fileName);
-            List<ObsObject> list = new ArrayList<>();
-            list.add(object);
-            exchanges = createExchanges(list);
+            exchanges = new LinkedList<>();
+            // skip the object if an exchange of a previous poll is still 
processing it
+            if (inProgress.add(fileName)) {
+                ObsObject object;
+                try {
+                    object = obsClient.getObject(bucketName, fileName);
+                } catch (RuntimeException e) {
+                    inProgress.remove(fileName);
+                    throw e;
+                }
+                addExchange(fileName, object, exchanges);
+            } else {
+                LOG.trace("Skipping object {} as it is already in progress", 
fileName);
+            }
         } else {
             // if file name is not given, get a list of all objects
             ListObjectsRequest request = new ListObjectsRequest(bucketName);
@@ -169,21 +182,32 @@ public class OBSConsumer extends 
ScheduledBatchPollingConsumer {
             // update number of pending exchanges
             pendingExchanges = total - index - 1;
 
+            final String objectKey = 
exchange.getIn().getHeader(OBSHeaders.OBJECT_KEY, String.class);
             exchange.getExchangeExtension().addOnCompletion(new 
Synchronization() {
                 @Override
                 public void onComplete(Exchange exchange) {
-                    processComplete(exchange);
+                    try {
+                        processComplete(exchange);
+                    } finally {
+                        inProgress.remove(objectKey);
+                    }
                 }
 
                 @Override
                 public void onFailure(Exchange exchange) {
-                    processFailure(exchange);
+                    try {
+                        processFailure(exchange);
+                    } finally {
+                        inProgress.remove(objectKey);
+                    }
                 }
             });
 
             AsyncCallback callback = defaultConsumerCallback(exchange, true);
             getAsyncProcessor().process(exchange, callback);
         }
+        // the remaining exchanges are not processed as the consumer is 
stopping
+        releaseNotProcessed(exchanges);
 
         return total;
     }
@@ -193,12 +217,44 @@ public class OBSConsumer extends 
ScheduledBatchPollingConsumer {
      */
     private Queue<Exchange> createExchanges(List<ObsObject> obsObjects) {
         Queue<Exchange> answer = new LinkedList<>();
-        for (ObsObject objectSummary : obsObjects) {
+        try {
+            for (ObsObject objectSummary : obsObjects) {
+                String objectKey = objectSummary.getObjectKey();
+                // skip the object if an exchange of a previous poll is still 
processing it
+                if (!inProgress.add(objectKey)) {
+                    LOG.trace("Skipping object {} as it is already in 
progress", objectKey);
+                    continue;
+                }
+                try {
+                    addExchange(objectKey, objectSummary, answer);
+                } catch (ObsException e) {
+                    if (e.getResponseCode() == 404) {
+                        // deleted (for example by another consumer) after it 
was listed
+                        LOG.debug("Skipping object {} as it no longer exists", 
objectKey);
+                        continue;
+                    }
+                    throw e;
+                }
+            }
+        } catch (RuntimeException e) {
+            // none of the objects of this poll is processed
+            releaseNotProcessed(answer);
+            throw e;
+        }
+        return answer;
+    }
+
+    /**
+     * Adds an exchange for the object, which is in progress, to the answer. 
The object is no longer in progress if no
+     * exchange is added.
+     */
+    private void addExchange(String objectKey, ObsObject objectSummary, 
Queue<Exchange> answer) {
+        try {
             ObsObject obsObject;
 
             if (objectSummary.getMetadata().getContentType() == null) {
                 // object was from list objects. Since not all object data is 
included when listing objects, we must retrieve all the data by calling 
getObject
-                obsObject = obsClient.getObject(endpoint.getBucketName(), 
objectSummary.getObjectKey());
+                obsObject = obsClient.getObject(endpoint.getBucketName(), 
objectKey);
             } else {
                 // object was already retrieved using getObjects
                 obsObject = objectSummary;
@@ -208,9 +264,25 @@ public class OBSConsumer extends 
ScheduledBatchPollingConsumer {
             if (includeObsObject(obsObject)) {
                 Exchange exchange = createExchange(obsObject);
                 answer.add(exchange);
+            } else {
+                inProgress.remove(objectKey);
             }
+        } catch (RuntimeException e) {
+            inProgress.remove(objectKey);
+            throw e;
         }
-        return answer;
+    }
+
+    private void releaseNotProcessed(Queue<?> exchanges) {
+        for (Object o : exchanges) {
+            Exchange exchange = (Exchange) o;
+            String objectKey = 
exchange.getIn().getHeader(OBSHeaders.OBJECT_KEY, String.class);
+            if (objectKey != null) {
+                inProgress.remove(objectKey);
+            }
+            releaseExchange(exchange, true);
+        }
+        exchanges.clear();
     }
 
     /**
diff --git 
a/components/camel-huawei/camel-huaweicloud-obs/src/test/java/org/apache/camel/component/huaweicloud/obs/OBSConsumerInProgressTest.java
 
b/components/camel-huawei/camel-huaweicloud-obs/src/test/java/org/apache/camel/component/huaweicloud/obs/OBSConsumerInProgressTest.java
new file mode 100644
index 000000000000..7e09843ffb11
--- /dev/null
+++ 
b/components/camel-huawei/camel-huaweicloud-obs/src/test/java/org/apache/camel/component/huaweicloud/obs/OBSConsumerInProgressTest.java
@@ -0,0 +1,300 @@
+/*
+ * 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.huaweicloud.obs;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentSkipListSet;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.obs.services.ObsClient;
+import com.obs.services.exception.ObsException;
+import com.obs.services.model.ListObjectsRequest;
+import com.obs.services.model.ObjectListing;
+import com.obs.services.model.ObjectMetadata;
+import com.obs.services.model.ObsObject;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Consumer;
+import org.apache.camel.Endpoint;
+import org.apache.camel.RuntimeCamelException;
+import org.apache.camel.ServiceStatus;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.PollingConsumerPollStrategy;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The consumer keeps the objects whose exchanges are being processed: an 
object still in flight is not consumed again
+ * by the next poll, an object is consumed again once its exchange failed or 
completed (without deleteAfterRead), an
+ * object deleted after the listing is skipped without failing the poll, and 
the objects of a batch that a stopping
+ * consumer does not process are consumed when the route is started again.
+ */
+public class OBSConsumerInProgressTest extends CamelTestSupport {
+
+    private static final String BUCKET = "bucket";
+
+    private final Set<String> objects = new ConcurrentSkipListSet<>();
+    private final Set<String> deletedAfterListing = 
ConcurrentHashMap.newKeySet();
+    private final Map<String, AtomicInteger> gets = new ConcurrentHashMap<>();
+    private final AtomicInteger failedPolls = new AtomicInteger();
+    private final AtomicInteger attempts = new AtomicInteger();
+    private final CountDownLatch inRoute = new CountDownLatch(1);
+    private final CountDownLatch twoPolls = new CountDownLatch(2);
+    private final CountDownLatch release = new CountDownLatch(1);
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+        context.getRegistry().bind("obsClient", obsClient());
+        // counts the polls that completed (the exchanges of the poll were 
handed to the route) and the failed polls
+        context.getRegistry().bind("countPolls", new 
PollingConsumerPollStrategy() {
+            @Override
+            public boolean begin(Consumer consumer, Endpoint endpoint) {
+                return true;
+            }
+
+            @Override
+            public void commit(Consumer consumer, Endpoint endpoint, int 
polledMessages) {
+                twoPolls.countDown();
+            }
+
+            @Override
+            public boolean rollback(Consumer consumer, Endpoint endpoint, int 
retryCounter, Exception e) {
+                failedPolls.incrementAndGet();
+                return false;
+            }
+        });
+        return context;
+    }
+
+    @AfterEach
+    void releaseRoute() {
+        release.countDown();
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                // seda hands the on completions (the delete) over to this 
route
+                from("seda:hold")
+                        .process(e -> {
+                            inRoute.countDown();
+                            release.await(20, TimeUnit.SECONDS);
+                        })
+                        .to("mock:result");
+
+                from("seda:failOnce")
+                        .process(e -> {
+                            if (attempts.incrementAndGet() == 1) {
+                                throw new IllegalStateException("first attempt 
fails");
+                            }
+                        })
+                        .to("mock:result");
+            }
+        };
+    }
+
+    @Test
+    public void objectInFlightIsNotConsumedAgain() throws Exception {
+        assertNotConsumedAgain("");
+    }
+
+    @Test
+    public void objectInFlightIsNotConsumedAgainWithFileName() throws 
Exception {
+        assertNotConsumedAgain("&fileName=a.txt");
+    }
+
+    @Test
+    public void failedObjectIsConsumedAgain() throws Exception {
+        objects.add("a.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("a.txt");
+
+        addConsumerRoute("&deleteAfterRead=true", "seda:failOnce");
+
+        mock.assertIsSatisfied();
+        assertEquals(2, attempts.get(), "a.txt should be consumed again after 
its first exchange failed");
+    }
+
+    @Test
+    public void completedObjectIsConsumedAgainWithoutDelete() throws Exception 
{
+        objects.add("a.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedMinimumMessageCount(2);
+        mock.allMessages().body().isEqualTo("a.txt");
+
+        addConsumerRoute("", "mock:result");
+
+        // without deleteAfterRead every poll reads the object again once its 
previous exchange is done
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    public void deletedObjectIsSkipped() throws Exception {
+        objects.add("a.txt");
+        objects.add("b.txt");
+        objects.add("c.txt");
+        deletedAfterListing.add("b.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceivedInAnyOrder("a.txt", "c.txt");
+
+        addConsumerRoute("&deleteAfterRead=true&pollStrategy=#countPolls", 
"mock:result");
+
+        mock.assertIsSatisfied();
+        assertEquals(0, failedPolls.get(), "the poll failed because b.txt was 
deleted after the listing");
+        assertEquals(1, gets("a.txt"), "a.txt was read again because the poll 
that listed b.txt failed");
+    }
+
+    @Test
+    public void objectsOfAStoppedBatchAreConsumedAfterRestart() throws 
Exception {
+        objects.add("a.txt");
+        objects.add("b.txt");
+        objects.add("c.txt");
+        AtomicBoolean first = new AtomicBoolean(true);
+        MockEndpoint mock = getMockEndpoint("mock:result");
+
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from(obsUri("&deleteAfterRead=true")).routeId("obs")
+                        .process(e -> {
+                            if (first.compareAndSet(true, false)) {
+                                // stop the route while the first exchange of 
the batch is processed, and wait until
+                                // the consumer does not process the rest of 
the batch
+                                OBSConsumer consumer = (OBSConsumer) 
context.getRoute("obs").getConsumer();
+                                CompletableFuture.runAsync(() -> {
+                                    try {
+                                        
context.getRouteController().stopRoute("obs");
+                                    } catch (Exception ex) {
+                                        throw new RuntimeCamelException(ex);
+                                    }
+                                });
+                                await().atMost(20, TimeUnit.SECONDS).until(() 
-> !consumer.isBatchAllowed());
+                            }
+                        })
+                        .convertBodyTo(String.class)
+                        .to("mock:result");
+            }
+        });
+
+        mock.expectedBodiesReceived("a.txt");
+        mock.assertIsSatisfied();
+        await().atMost(20, TimeUnit.SECONDS)
+                .until(() -> 
context.getRouteController().getRouteStatus("obs") == ServiceStatus.Stopped);
+
+        mock.reset();
+        mock.expectedBodiesReceivedInAnyOrder("b.txt", "c.txt");
+        context.getRouteController().startRoute("obs");
+
+        mock.assertIsSatisfied();
+    }
+
+    private void assertNotConsumedAgain(String options) throws Exception {
+        objects.add("a.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("a.txt");
+
+        addConsumerRoute("&deleteAfterRead=true&pollStrategy=#countPolls" + 
options, "seda:hold");
+
+        assertTrue(inRoute.await(20, TimeUnit.SECONDS), "the object was not 
consumed");
+        // the second poll has completed while the first exchange is still 
held in the seda route
+        assertTrue(twoPolls.await(20, TimeUnit.SECONDS), "the consumer did not 
poll again");
+        int getsWhileInFlight = gets("a.txt");
+        release.countDown();
+
+        assertEquals(1, getsWhileInFlight,
+                "a.txt was consumed again by a later poll while its first 
exchange was still in flight");
+        mock.assertIsSatisfied();
+    }
+
+    private String obsUri(String options) {
+        return "hwcloud-obs:?bucketName=" + BUCKET + 
"&obsClient=#obsClient&initialDelay=0&delay=10" + options;
+    }
+
+    private void addConsumerRoute(String options, String to) throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from(obsUri(options))
+                        .convertBodyTo(String.class)
+                        .to(to);
+            }
+        });
+    }
+
+    private int gets(String key) {
+        AtomicInteger n = gets.get(key);
+        return n != null ? n.get() : 0;
+    }
+
+    private ObsClient obsClient() {
+        ObsClient client = Mockito.mock(ObsClient.class);
+        
Mockito.when(client.listObjects(Mockito.any(ListObjectsRequest.class))).thenAnswer(i
 -> {
+            // listed objects carry no content type, so the consumer gets each 
of them
+            List<ObsObject> listed = objects.stream().map(key -> {
+                ObsObject summary = new ObsObject();
+                summary.setBucketName(BUCKET);
+                summary.setObjectKey(key);
+                return summary;
+            }).toList();
+            // deleted (as if by another consumer) right after the listing 
returned it
+            objects.removeAll(deletedAfterListing);
+            ObjectListing listing = Mockito.mock(ObjectListing.class);
+            Mockito.when(listing.getObjects()).thenReturn(listed);
+            return listing;
+        });
+        Mockito.when(client.getObject(Mockito.eq(BUCKET), 
Mockito.anyString())).thenAnswer(i -> {
+            String key = i.getArgument(1);
+            if (!objects.contains(key)) {
+                ObsException e = new ObsException("NoSuchKey");
+                e.setResponseCode(404);
+                throw e;
+            }
+            gets.computeIfAbsent(key, k -> new 
AtomicInteger()).incrementAndGet();
+            ObsObject object = new ObsObject();
+            object.setBucketName(BUCKET);
+            object.setObjectKey(key);
+            ObjectMetadata metadata = new ObjectMetadata();
+            metadata.setContentType("text/plain");
+            object.setMetadata(metadata);
+            object.setObjectContent(new 
ByteArrayInputStream(key.getBytes(StandardCharsets.UTF_8)));
+            return object;
+        });
+        Mockito.when(client.deleteObject(Mockito.eq(BUCKET), 
Mockito.anyString())).thenAnswer(i -> {
+            objects.remove(i.<String> getArgument(1));
+            return null;
+        });
+        return client;
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 7c6346d6a8e7..e79e7ed43dfe 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -3662,6 +3662,26 @@ Local downloads configured with `fileDir` now resolve 
existing filesystem path s
 that the destination remains inside the configured directory. Downloads 
through a symbolic link that
 resolves outside `fileDir` are rejected. Valid nested download paths continue 
to work.
 
+=== camel-azure-storage-blob - blobs whose exchange is in flight
+
+The consumer no longer consumes a blob again while an exchange of an earlier 
poll is still processing it, which
+happened with an asynchronous route (for example `to("seda:...")`), so the 
next polls now skip such a blob, and no longer
+download it again. With `deleteAfterRead=false` and `moveAfterRead=false` the 
blob is consumed again by a later poll once
+its exchange is done, as before. A listed blob that no longer exists when it 
is downloaded (for example deleted by
+another consumer after the listing) is now skipped, and the other blobs of the 
poll are consumed, instead of the whole
+poll being dropped.
+
+=== camel-huaweicloud-obs, camel-alibaba-oss and camel-aws2-s3-vectors - 
objects whose exchange is in flight
+
+The `camel-huaweicloud-obs` and `camel-alibaba-oss` consumers no longer 
consume an object again while an exchange of an
+earlier poll is still processing it, which happened with an asynchronous route 
(for example `to("seda:...")`), so the
+next polls now skip such an object. With `deleteAfterRead=false` (and 
`moveAfterRead=false`) the object is consumed again
+by a later poll once its exchange is done, as before. A listed object that no 
longer exists when it is downloaded (for
+example deleted by another consumer after the listing) is now skipped instead 
of failing the poll.
+
+The `camel-aws2-s3-vectors` consumer with `deleteAfterRead=true` no longer 
consumes a vector again while its exchange is
+still in flight. A vector whose exchange failed is still consumed again by a 
later poll.
+
 === camel-google-sheets - custom column names of a range that does not start 
at column A
 
 The `google-sheets:application-x-struct` data type now maps the custom 
`columnNames` to the columns of the range by

Reply via email to