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 940e7ecf558f CAMEL-25519: camel-google-storage - Do not consume an 
object again while its exchange is in flight (#27670)
940e7ecf558f is described below

commit 940e7ecf558f3630d87ce94936c3850bbbbad6e2
Author: allthingssecurity <[email protected]>
AuthorDate: Sun Oct 11 12:32:18 2026 +0530

    CAMEL-25519: camel-google-storage - Do not consume an object again while 
its exchange is in flight (#27670)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../google/storage/GoogleCloudStorageConsumer.java |  92 +++++++--
 ...oogleCloudStorageConsumerDeletedObjectTest.java | 117 +++++++++++
 ...eCloudStorageConsumerInProgressFailureTest.java |  79 ++++++++
 .../GoogleCloudStorageConsumerInProgressTest.java  | 153 +++++++++++++++
 ...oogleCloudStorageConsumerListingWindowTest.java | 214 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |   6 +
 6 files changed, 646 insertions(+), 15 deletions(-)

diff --git 
a/components/camel-google/camel-google-storage/src/main/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumer.java
 
b/components/camel-google/camel-google-storage/src/main/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumer.java
index 8a5ed9f40708..2ecf1997355f 100644
--- 
a/components/camel-google/camel-google-storage/src/main/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumer.java
+++ 
b/components/camel-google/camel-google-storage/src/main/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumer.java
@@ -21,6 +21,8 @@ import java.util.Date;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Queue;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 
 import com.google.api.gax.paging.Page;
 import com.google.cloud.storage.Blob;
@@ -29,6 +31,7 @@ import com.google.cloud.storage.Bucket;
 import com.google.cloud.storage.CopyWriter;
 import com.google.cloud.storage.Storage;
 import com.google.cloud.storage.Storage.CopyRequest;
+import com.google.cloud.storage.StorageException;
 import org.apache.camel.CamelContext;
 import org.apache.camel.Exchange;
 import org.apache.camel.ExchangePattern;
@@ -52,6 +55,11 @@ public class GoogleCloudStorageConsumer extends 
ScheduledBatchPollingConsumer {
     private static final Logger LOG = 
LoggerFactory.getLogger(GoogleCloudStorageConsumer.class);
 
     private final Language language;
+    // objects whose exchanges are being processed, which a later poll must 
not consume again
+    private final Set<String> inProgress = ConcurrentHashMap.newKeySet();
+    // the objects that were in progress when the current poll started, before 
it listed or got the objects. Only the
+    // thread that polls reads and writes it.
+    private Set<String> inProgressAtPollStart = Set.of();
 
     public GoogleCloudStorageConsumer(GoogleCloudStorageEndpoint endpoint, 
Processor processor) {
         super(endpoint, processor);
@@ -89,6 +97,11 @@ public class GoogleCloudStorageConsumer extends 
ScheduledBatchPollingConsumer {
         Queue<Exchange> exchanges;
         String filter = getConfiguration().getFilter();
 
+        // taken before the objects are listed (or got): an object that is in 
flight now can complete, and be deleted
+        // and released, after it was listed and before createExchanges looks 
at it, and must then not be consumed
+        // again from the stale listing
+        inProgressAtPollStart = Set.copyOf(inProgress);
+
         if (fileName != null) {
             LOG.trace("Getting object in bucket [{}] with file name [{}]...", 
bucketName, fileName);
 
@@ -140,8 +153,7 @@ public class GoogleCloudStorageConsumer extends 
ScheduledBatchPollingConsumer {
 
     protected Queue<Exchange> createExchanges(Blob blob, String key) {
         Queue<Exchange> answer = new LinkedList<>();
-        Exchange exchange = createExchange(blob, key);
-        answer.add(exchange);
+        addExchange(blob, key, answer);
         return answer;
     }
 
@@ -156,7 +168,7 @@ public class GoogleCloudStorageConsumer extends 
ScheduledBatchPollingConsumer {
                 if (includeObject(blob)) {
                     String key = blob.getBlobId().getName();
                     try {
-                        answer.add(createExchange(blob, key));
+                        addExchange(blob, key, answer);
                     } catch (IllegalArgumentException e) {
                         // the object name is rejected as a local download 
path: skip this object only, so a single
                         // object with such a name does not stop the rest of 
the bucket from being consumed
@@ -169,12 +181,47 @@ public class GoogleCloudStorageConsumer extends 
ScheduledBatchPollingConsumer {
             }
         } catch (Exception e) {
             LOG.warn("Error getting object due: {}", e.getMessage(), e);
+            // none of the objects of this poll is processed
+            releaseInProgress(answer);
             throw e;
         }
 
         return answer;
     }
 
+    private void addExchange(Blob blob, String key, Queue<Exchange> answer) {
+        // skip the object if an exchange of a previous poll is still 
processing it, or was when this poll started
+        if (inProgressAtPollStart.contains(key) || !inProgress.add(key)) {
+            LOG.trace("Skipping object {} as it is already in progress", key);
+            return;
+        }
+        try {
+            answer.add(createExchange(blob, key));
+        } catch (RuntimeException 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;
+        }
+    }
+
+    private static boolean isNotFound(Exception e) {
+        StorageException se = 
ObjectHelper.getException(StorageException.class, e);
+        return se != null && se.getCode() == 404;
+    }
+
+    private void releaseInProgress(Queue<?> notProcessed) {
+        for (Object notRouted : notProcessed) {
+            Exchange exchange = (Exchange) notRouted;
+            
inProgress.remove(exchange.getIn().getHeader(GoogleCloudStorageConstants.OBJECT_NAME,
 String.class));
+            // the exchange is never routed, so nothing else releases it to 
the exchange factory
+            releaseExchange(exchange, false);
+        }
+    }
+
     /**
      * Decide whether to include the Objects in the results
      *
@@ -208,14 +255,23 @@ public class GoogleCloudStorageConsumer extends 
ScheduledBatchPollingConsumer {
             // update pending number of exchanges
             pendingExchanges = total - index - 1;
 
+            final String key = 
exchange.getIn().getHeader(GoogleCloudStorageConstants.OBJECT_NAME, 
String.class);
             // add on completion to handle after work when the exchange is done
             exchange.getExchangeExtension().addOnCompletion(new 
Synchronization() {
                 public void onComplete(Exchange exchange) {
-                    processCommit(exchange);
+                    try {
+                        processCommit(exchange);
+                    } finally {
+                        inProgress.remove(key);
+                    }
                 }
 
                 public void onFailure(Exchange exchange) {
-                    processRollback(exchange);
+                    try {
+                        processRollback(exchange);
+                    } finally {
+                        inProgress.remove(key);
+                    }
                 }
 
                 @Override
@@ -228,6 +284,8 @@ public class GoogleCloudStorageConsumer extends 
ScheduledBatchPollingConsumer {
             defaultConsumerCallback(exchange, true);
             getAsyncProcessor().process(exchange, EmptyAsyncCallback.get());
         }
+        // the remaining exchanges are not processed as the consumer is 
stopping
+        releaseInProgress(exchanges);
 
         return total;
     }
@@ -308,6 +366,18 @@ public class GoogleCloudStorageConsumer extends 
ScheduledBatchPollingConsumer {
         }
 
         Exchange exchange = createExchange(true);
+        try {
+            populateExchange(exchange, pattern, blob, key);
+        } catch (RuntimeException e) {
+            // the object is not consumed (its name is rejected as a local 
path, or its download failed, for example
+            // as it no longer exists), so the exchange created for it is not 
routed either
+            releaseExchange(exchange, false);
+            throw e;
+        }
+        return exchange;
+    }
+
+    private void populateExchange(Exchange exchange, ExchangePattern pattern, 
Blob blob, String key) {
         exchange.setPattern(pattern);
         Message message = exchange.getIn();
 
@@ -315,14 +385,8 @@ public class GoogleCloudStorageConsumer extends 
ScheduledBatchPollingConsumer {
             // download as file
             if (getConfiguration().getDownloadFileName() != null) {
                 // create a dummy exchange as Exchange is needed for 
expression evaluation
-                String result;
-                try {
-                    result = evaluateFileExpression(exchange, 
getConfiguration().getDownloadFileName(), blob.getName());
-                } catch (IllegalArgumentException e) {
-                    // the object is not consumed, so the exchange created for 
it is not routed either
-                    releaseExchange(exchange, false);
-                    throw e;
-                }
+                String result
+                        = evaluateFileExpression(exchange, 
getConfiguration().getDownloadFileName(), blob.getName());
                 if (result != null) {
                     File file = new File(result);
                     blob.downloadTo(file.toPath());
@@ -367,8 +431,6 @@ public class GoogleCloudStorageConsumer extends 
ScheduledBatchPollingConsumer {
         message.setHeader(GoogleCloudStorageConstants.METADATA_STORAGE_CLASS, 
blob.getStorageClass());
         message.setHeader(GoogleCloudStorageConstants.METADATA_CREATE_TIME, 
blob.getCreateTime());
         message.setHeader(GoogleCloudStorageConstants.METADATA_LAST_UPDATE, 
new Date(blob.getUpdateTime()));
-
-        return exchange;
     }
 
     protected String evaluateFileExpression(Exchange exchange, String 
downloadFileName, String blogName) {
diff --git 
a/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumerDeletedObjectTest.java
 
b/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumerDeletedObjectTest.java
new file mode 100644
index 000000000000..a84cd42af13f
--- /dev/null
+++ 
b/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumerDeletedObjectTest.java
@@ -0,0 +1,117 @@
+/*
+ * 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.google.storage;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Proxy;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.google.cloud.storage.BlobInfo;
+import com.google.cloud.storage.BucketInfo;
+import com.google.cloud.storage.Storage;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Consumer;
+import org.apache.camel.Endpoint;
+import org.apache.camel.ExchangePropertyKey;
+import org.apache.camel.builder.RouteBuilder;
+import 
org.apache.camel.component.google.storage.localstorage.LocalStorageHelper;
+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.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * An object deleted (for example by another consumer) after the listing and 
before its content is downloaded is
+ * skipped, and the other objects of the poll are consumed.
+ */
+class GoogleCloudStorageConsumerDeletedObjectTest extends CamelTestSupport {
+
+    private final AtomicInteger failedPolls = new AtomicInteger();
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+        Storage delegate = LocalStorageHelper.getOptions().getService();
+        delegate.create(BucketInfo.of("myCamelBucket"));
+        for (String name : new String[] { "a.txt", "b.txt", "c.txt" }) {
+            delegate.create(BlobInfo.newBuilder("myCamelBucket", 
name).build(), name.getBytes(StandardCharsets.UTF_8));
+        }
+        AtomicBoolean deleted = new AtomicBoolean();
+        // the listed blobs download their content through the delegate, in 
which b.txt no longer exists
+        Storage storage = (Storage) 
Proxy.newProxyInstance(Storage.class.getClassLoader(), new Class<?>[] { 
Storage.class },
+                (proxy, method, args) -> {
+                    Object result;
+                    try {
+                        result = method.invoke(delegate, args);
+                    } catch (InvocationTargetException e) {
+                        throw e.getCause();
+                    }
+                    if ("list".equals(method.getName()) && 
deleted.compareAndSet(false, true)) {
+                        delegate.delete("myCamelBucket", "b.txt");
+                    }
+                    return result;
+                });
+        context.getComponent("google-storage", 
GoogleCloudStorageComponent.class).getConfiguration()
+                .setStorageClient(storage);
+        context.getRegistry().bind("countFailedPolls", new 
PollingConsumerPollStrategy() {
+            @Override
+            public boolean begin(Consumer consumer, Endpoint endpoint) {
+                return true;
+            }
+
+            @Override
+            public void commit(Consumer consumer, Endpoint endpoint, int 
polledMessages) {
+                // only failed polls are counted
+            }
+
+            @Override
+            public boolean rollback(Consumer consumer, Endpoint endpoint, int 
retryCounter, Exception e) {
+                failedPolls.incrementAndGet();
+                return false;
+            }
+        });
+        return context;
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("google-storage://myCamelBucket?delay=10&pollStrategy=#countFailedPolls")
+                        .to("mock:result");
+            }
+        };
+    }
+
+    @Test
+    void deletedObjectIsSkipped() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceivedInAnyOrder("a.txt", "c.txt");
+        // the batch is built after the downloads, so it only counts the 
objects that are consumed
+        
mock.allMessages().exchangeProperty(ExchangePropertyKey.BATCH_SIZE.getName()).isEqualTo(2);
+
+        mock.assertIsSatisfied();
+        assertThat(failedPolls)
+                .as("the poll failed because b.txt was deleted between the 
listing and the download")
+                .hasValue(0);
+    }
+}
diff --git 
a/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumerInProgressFailureTest.java
 
b/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumerInProgressFailureTest.java
new file mode 100644
index 000000000000..7384e965db53
--- /dev/null
+++ 
b/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumerInProgressFailureTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.google.storage;
+
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.google.cloud.storage.BlobInfo;
+import com.google.cloud.storage.BucketInfo;
+import com.google.cloud.storage.Storage;
+import org.apache.camel.CamelContext;
+import org.apache.camel.builder.RouteBuilder;
+import 
org.apache.camel.component.google.storage.localstorage.LocalStorageHelper;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * An object whose exchange failed is no longer in progress, so a later poll 
consumes it again.
+ */
+class GoogleCloudStorageConsumerInProgressFailureTest extends CamelTestSupport 
{
+
+    private final AtomicInteger attempts = new AtomicInteger();
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+        Storage storage = LocalStorageHelper.getOptions().getService();
+        storage.create(BucketInfo.of("myCamelBucket"));
+        storage.create(BlobInfo.newBuilder("myCamelBucket", "a.txt").build(), 
"hello".getBytes(StandardCharsets.UTF_8));
+        context.getComponent("google-storage", 
GoogleCloudStorageComponent.class).getConfiguration()
+                .setStorageClient(storage);
+        return context;
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("google-storage://myCamelBucket?delay=10")
+                        .to("seda:work");
+
+                from("seda:work")
+                        .process(e -> {
+                            if (attempts.incrementAndGet() == 1) {
+                                throw new IllegalStateException("first attempt 
fails");
+                            }
+                        })
+                        .to("mock:result");
+            }
+        };
+    }
+
+    @Test
+    void failedObjectIsConsumedAgain() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("hello");
+
+        mock.assertIsSatisfied();
+        assertThat(attempts).hasValue(2);
+    }
+}
diff --git 
a/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumerInProgressTest.java
 
b/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumerInProgressTest.java
new file mode 100644
index 000000000000..b843973cf59a
--- /dev/null
+++ 
b/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumerInProgressTest.java
@@ -0,0 +1,153 @@
+/*
+ * 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.google.storage;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.google.cloud.storage.Blob;
+import com.google.cloud.storage.BlobInfo;
+import com.google.cloud.storage.BucketInfo;
+import com.google.cloud.storage.Storage;
+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.google.storage.localstorage.LocalStorageHelper;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.PollingConsumerPollStrategy;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.apache.camel.util.CastUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * An object whose exchange is still being routed asynchronously must not be 
consumed again by the next poll, and an
+ * object whose exchange a stopping consumer did not process is consumed again.
+ */
+class GoogleCloudStorageConsumerInProgressTest extends CamelTestSupport {
+
+    private final AtomicInteger consumed = 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();
+        Storage storage = LocalStorageHelper.getOptions().getService();
+        storage.create(BucketInfo.of("myCamelBucket"));
+        storage.create(BlobInfo.newBuilder("myCamelBucket", "a.txt").build(), 
"hello".getBytes(StandardCharsets.UTF_8));
+        context.getComponent("google-storage", 
GoogleCloudStorageComponent.class).getConfiguration()
+                .setStorageClient(storage);
+        // 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:work")
+                        .process(e -> {
+                            inRoute.countDown();
+                            release.await(20, TimeUnit.SECONDS);
+                        })
+                        .to("mock:result");
+            }
+        };
+    }
+
+    @Test
+    void objectInFlightIsNotConsumedAgain() throws Exception {
+        assertNotConsumedAgain("");
+    }
+
+    @Test
+    void objectInFlightIsNotConsumedAgainWithObjectName() throws Exception {
+        assertNotConsumedAgain("&objectName=a.txt");
+    }
+
+    @Test
+    void notProcessedObjectIsConsumedAgain() throws Exception {
+        GoogleCloudStorageEndpoint endpoint
+                = context.getEndpoint("google-storage://myCamelBucket", 
GoogleCloudStorageEndpoint.class);
+        // never started, so the consumer does not process any exchange of the 
batch
+        GoogleCloudStorageConsumer consumer = (GoogleCloudStorageConsumer) 
endpoint.createConsumer(exchange -> {
+        });
+        List<Blob> blobs = 
List.of(endpoint.getStorageClient().get("myCamelBucket", "a.txt"));
+
+        consumer.processBatch(CastUtils.cast(consumer.createExchanges(blobs)));
+
+        assertThat(consumer.createExchanges(blobs))
+                .as("a.txt stayed in progress although its exchange was not 
processed")
+                .hasSize(1);
+    }
+
+    private void assertNotConsumedAgain(String options) throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("hello");
+
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("google-storage://myCamelBucket?delay=10&pollStrategy=#countPolls" + 
options)
+                        .process(e -> consumed.incrementAndGet())
+                        .to("seda:work");
+            }
+        });
+
+        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 consumedWhileInFlight = consumed.get();
+        release.countDown();
+
+        assertThat(consumedWhileInFlight)
+                .as("a.txt was consumed again by a later poll while its first 
exchange was still in flight")
+                .isEqualTo(1);
+        mock.assertIsSatisfied();
+    }
+}
diff --git 
a/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumerListingWindowTest.java
 
b/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumerListingWindowTest.java
new file mode 100644
index 000000000000..0c2a00da4dca
--- /dev/null
+++ 
b/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/GoogleCloudStorageConsumerListingWindowTest.java
@@ -0,0 +1,214 @@
+/*
+ * 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.google.storage;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Proxy;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import com.google.cloud.storage.Blob;
+import com.google.cloud.storage.BlobInfo;
+import com.google.cloud.storage.BucketInfo;
+import com.google.cloud.storage.Storage;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Consumer;
+import org.apache.camel.Endpoint;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import 
org.apache.camel.component.google.storage.localstorage.LocalStorageHelper;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.CamelEvent;
+import org.apache.camel.spi.CamelEvent.ExchangeCompletedEvent;
+import org.apache.camel.spi.PollingConsumerPollStrategy;
+import org.apache.camel.support.EventNotifierSupport;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * An object that is in flight when a poll lists (or gets) it, and whose 
exchange completes (the object is deleted and
+ * no longer in progress) before the poll looks at the listed object, must not 
be consumed again from the stale listing.
+ * With includeBody=false nothing is downloaded, so there is no 404 either 
that would skip it.
+ */
+class GoogleCloudStorageConsumerListingWindowTest extends CamelTestSupport {
+
+    private final AtomicInteger consumed = new AtomicInteger();
+    private final CountDownLatch inRoute = new CountDownLatch(1);
+    private final CountDownLatch release = new CountDownLatch(1);
+    private final CountDownLatch completed = new CountDownLatch(1);
+    private final CountDownLatch windowPollDone = new CountDownLatch(1);
+    // runs once, after the next listing (or get) has read the bucket and 
before the consumer receives the result
+    private final AtomicReference<Runnable> afterNextRead = new 
AtomicReference<>();
+    private volatile boolean windowOpened;
+    private volatile String heldExchangeId;
+    private Storage delegate;
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+        delegate = LocalStorageHelper.getOptions().getService();
+        delegate.create(BucketInfo.of("myCamelBucket"));
+        delegate.create(BlobInfo.newBuilder("myCamelBucket", "a.txt").build(), 
"hello".getBytes(StandardCharsets.UTF_8));
+        Storage storage = (Storage) 
Proxy.newProxyInstance(Storage.class.getClassLoader(), new Class<?>[] { 
Storage.class },
+                (proxy, method, args) -> {
+                    Object result;
+                    try {
+                        result = method.invoke(delegate, args);
+                    } catch (InvocationTargetException e) {
+                        throw e.getCause();
+                    }
+                    boolean list = "list".equals(method.getName());
+                    boolean getObject = "get".equals(method.getName()) && 
args.length > 1 && args[1] instanceof String;
+                    if (list || getObject) {
+                        Runnable hook = afterNextRead.getAndSet(null);
+                        if (hook != null) {
+                            hook.run();
+                        }
+                    }
+                    return result;
+                });
+        context.getComponent("google-storage", 
GoogleCloudStorageComponent.class).getConfiguration()
+                .setStorageClient(storage);
+        // the completed event of an exchange is sent after all its on 
completions have run
+        context.getManagementStrategy().addEventNotifier(new 
EventNotifierSupport() {
+            @Override
+            public void notify(CamelEvent event) {
+                if (event instanceof ExchangeCompletedEvent done
+                        && 
done.getExchange().getExchangeId().equals(heldExchangeId)) {
+                    completed.countDown();
+                }
+            }
+        });
+        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) {
+                if (windowOpened) {
+                    windowPollDone.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 of the consumer (the delete, 
then the release of the object name) over
+                // to this route
+                from("seda:work")
+                        .process(e -> {
+                            heldExchangeId = e.getExchangeId();
+                            inRoute.countDown();
+                            release.await(20, TimeUnit.SECONDS);
+                        })
+                        .to("mock:result");
+            }
+        };
+    }
+
+    @Test
+    void objectCompletedAfterTheListingIsNotConsumedAgain() throws Exception {
+        assertNotConsumedFromStaleRead("");
+    }
+
+    @Test
+    void objectCompletedAfterTheGetIsNotConsumedAgainWithObjectName() throws 
Exception {
+        assertNotConsumedFromStaleRead("&objectName=a.txt");
+    }
+
+    @Test
+    void exchangeOfAnObjectThatNoLongerExistsIsReleased() throws Exception {
+        GoogleCloudStorageEndpoint endpoint
+                = context.getEndpoint("google-storage://myCamelBucket", 
GoogleCloudStorageEndpoint.class);
+        List<Exchange> released = new ArrayList<>();
+        GoogleCloudStorageConsumer consumer = new 
GoogleCloudStorageConsumer(endpoint, exchange -> {
+        }) {
+            @Override
+            public void releaseExchange(Exchange exchange, boolean 
autoRelease) {
+                released.add(exchange);
+                super.releaseExchange(exchange, autoRelease);
+            }
+        };
+        Blob listed = delegate.get("myCamelBucket", "a.txt");
+        delegate.delete("myCamelBucket", "a.txt");
+
+        assertThat(consumer.createExchanges(List.of(listed))).isEmpty();
+
+        assertThat(released).as("the exchange created for a.txt, whose 
download failed, was not released").hasSize(1);
+    }
+
+    private void assertNotConsumedFromStaleRead(String options) throws 
Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedMessageCount(1);
+
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("google-storage://myCamelBucket?includeBody=false&delay=10&pollStrategy=#countPolls"
 + options)
+                        .process(e -> consumed.incrementAndGet())
+                        .to("seda:work");
+            }
+        });
+
+        assertThat(inRoute.await(20, TimeUnit.SECONDS)).as("the object was not 
consumed").isTrue();
+        AtomicReference<Boolean> completedInWindow = new AtomicReference<>();
+        // the next poll reads a.txt while its exchange is in flight; before 
the consumer looks at what it read, the
+        // exchange completes: a.txt is deleted and no longer in progress
+        afterNextRead.set(() -> {
+            release.countDown();
+            try {
+                completedInWindow.set(completed.await(20, TimeUnit.SECONDS));
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+            }
+            windowOpened = true;
+        });
+
+        assertThat(windowPollDone.await(20, TimeUnit.SECONDS)).as("the poll 
that read the stale a.txt did not end").isTrue();
+        assertThat(completedInWindow.get()).as("the exchange of a.txt did not 
complete during the poll").isTrue();
+        assertThat(delegate.get("myCamelBucket", "a.txt")).as("a.txt was not 
deleted").isNull();
+        assertThat(consumed)
+                .as("a.txt was consumed again from a listing made while its 
exchange was in flight")
+                .hasValue(1);
+        mock.assertIsSatisfied();
+    }
+}
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 ff9c0d0a3386..f1dc62dde5dc 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
@@ -3737,6 +3737,12 @@ When `downloadFileName` is fully dynamic and has no 
static directory prefix (for
 `downloadFileName=${file:name}`), a relative destination must stay within the 
working directory. An absolute
 destination that comes from the route's own expression is not confined.
 
+The consumer no longer consumes 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` it is consumed again once its exchange is done). An 
object that no longer exists when its
+content is downloaded (for example deleted by another consumer after the 
listing) is now skipped instead of failing the
+poll.
+
 === camel-jt400
 
 The endpoint syntax in the component metadata is now 
`jt400:userID:password@systemName/objectPath`, which is what the

Reply via email to