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 4de40eae3f68 CAMEL-25770: camel-google-storage - skip a listed object
that cannot be got instead of failing the poll (#28132)
4de40eae3f68 is described below
commit 4de40eae3f68cb5c0fef18ddf68a05df5c2bd5f0
Author: allthingssecurity <[email protected]>
AuthorDate: Wed Oct 21 00:31:55 2026 +0530
CAMEL-25770: camel-google-storage - skip a listed object that cannot be got
instead of failing the poll (#28132)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../google/storage/GoogleCloudStorageConsumer.java | 74 ++++++---
.../storage/localstorage/FakeStorageRpc.java | 19 +++
.../storage/localstorage/LocalStorageHelper.java | 8 +
.../unit/ConsumerObjectThatCannotBeGotTest.java | 166 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 10 ++
5 files changed, 255 insertions(+), 22 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 2ecf1997355f..7c1db25cb0b9 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
@@ -18,8 +18,10 @@ package org.apache.camel.component.google.storage;
import java.io.File;
import java.util.Date;
+import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
+import java.util.Map;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@@ -163,46 +165,74 @@ public class GoogleCloudStorageConsumer extends
ScheduledBatchPollingConsumer {
}
Queue<Exchange> answer = new LinkedList<>();
- try {
- for (Blob blob : blobList) {
- if (includeObject(blob)) {
- String key = blob.getBlobId().getName();
- try {
- 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
- getExceptionHandler().handleException(
- "Skipping object " + key + " in bucket " +
getConfiguration().getBucketName()
- + " as it cannot
be downloaded: " + e.getMessage(),
- e);
- }
+ // the listed objects that could not be got, other than those that no
longer exist, by name
+ Map<String, RuntimeException> failures = new LinkedHashMap<>();
+ // whether at least one listed object could be got
+ boolean anyGot = false;
+ for (Blob blob : blobList) {
+ if (includeObject(blob)) {
+ String key = blob.getBlobId().getName();
+ try {
+ anyGot |= 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
+ getExceptionHandler().handleException(
+ "Skipping object " + key + " in bucket " +
getConfiguration().getBucketName()
+ + " as it cannot be
downloaded: " + e.getMessage(),
+ e);
+ } catch (RuntimeException e) {
+ // a failure to get one listed object (access denied, a
key that cannot decrypt it) does not fail
+ // the poll as long as another listed object could be got:
the object is skipped, the other listed
+ // objects are consumed, and a later poll tries it again
+ failures.put(key, e);
}
}
- } 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;
+ }
+
+ if (!failures.isEmpty()) {
+ if (!anyGot) {
+ // none of the listed objects could be got, so this is not
about one object (expired credentials, no
+ // permission to get objects, no connection): the poll fails,
and the backoff of the scheduled consumer
+ // applies
+ releaseInProgress(answer);
+ Map.Entry<String, RuntimeException> first =
failures.entrySet().iterator().next();
+ LOG.warn("None of the listed objects of bucket {} could be got
({} failed, the first is {}): the poll fails",
+ getConfiguration().getBucketName(), failures.size(),
first.getKey());
+ throw first.getValue();
+ }
+ for (Map.Entry<String, RuntimeException> failure :
failures.entrySet()) {
+ getExceptionHandler().handleException("Error getting object "
+ failure.getKey() + " from bucket "
+ +
getConfiguration().getBucketName()
+ + ". The object is
skipped for this poll.",
+ failure.getValue());
+ }
}
return answer;
}
- private void addExchange(Blob blob, String key, Queue<Exchange> answer) {
+ /**
+ * Gets a listed object and adds its exchange.
+ *
+ * @return true if the object was got and its exchange added, false if it
is skipped (an exchange is still
+ * processing it, or it no longer exists)
+ */
+ private boolean 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;
+ return false;
}
try {
answer.add(createExchange(blob, key));
+ return true;
} 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;
+ return false;
}
throw e;
}
diff --git
a/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/localstorage/FakeStorageRpc.java
b/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/localstorage/FakeStorageRpc.java
index c11b76c75e30..ab87a818c559 100644
---
a/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/localstorage/FakeStorageRpc.java
+++
b/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/localstorage/FakeStorageRpc.java
@@ -33,9 +33,11 @@ import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -116,6 +118,9 @@ class FakeStorageRpc extends StorageRpcTestBase {
//Bucketname -> bucket
Map<String, Bucket> buckets = new HashMap<>();
+ // fullnames of the objects whose content cannot be read (403), as for an
object the caller has no access to
+ Set<String> refusedReads = new HashSet<>();
+
private final boolean throwIfOption;
/** @param throwIfOption if true, we throw when given any option */
@@ -129,6 +134,17 @@ class FakeStorageRpc extends StorageRpcTestBase {
metadata = new HashMap<>();
contents = new HashMap<>();
buckets = new HashMap<>();
+ refusedReads = new HashSet<>();
+ }
+
+ void refuseReads(String bucket, String name) {
+ refusedReads.add(fullname(bucket, name));
+ }
+
+ private void checkReadable(String key) {
+ if (refusedReads.contains(key)) {
+ throw new StorageException(403, "Access denied: " + key);
+ }
}
@Override
@@ -326,6 +342,7 @@ class FakeStorageRpc extends StorageRpcTestBase {
@Override
public byte[] load(StorageObject storageObject, Map<Option, ?> options)
throws StorageException {
String key = fullname(storageObject);
+ checkReadable(key);
if (!contents.containsKey(key)) {
throw new StorageException(NOT_FOUND, "File not found: " + key);
}
@@ -346,6 +363,7 @@ class FakeStorageRpc extends StorageRpcTestBase {
}
}
String key = fullname(from);
+ checkReadable(key);
if (!contents.containsKey(key)) {
throw new StorageException(NOT_FOUND, "File not found: " + key);
}
@@ -381,6 +399,7 @@ class FakeStorageRpc extends StorageRpcTestBase {
}
}
String key = fullname(from);
+ checkReadable(key);
if (!contents.containsKey(key)) {
throw new StorageException(NOT_FOUND, "File not found: " + key);
}
diff --git
a/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/localstorage/LocalStorageHelper.java
b/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/localstorage/LocalStorageHelper.java
index 25919320dada..a02240907452 100644
---
a/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/localstorage/LocalStorageHelper.java
+++
b/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/localstorage/LocalStorageHelper.java
@@ -83,6 +83,14 @@ public final class LocalStorageHelper {
.build();
}
+ /**
+ * Every read of the content of the object of the static FakeStorageRpc
instance is refused with 403, until the next
+ * {@link #getOptions()}.
+ */
+ public static void refuseReads(String bucket, String name) {
+ INSTANCE.refuseReads(bucket, name);
+ }
+
/**
* Returns a {@link StorageOptions} that creates a new FakeStorageRpc
instance with the given option.
*/
diff --git
a/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/unit/ConsumerObjectThatCannotBeGotTest.java
b/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/unit/ConsumerObjectThatCannotBeGotTest.java
new file mode 100644
index 000000000000..bfb8c5e71ee4
--- /dev/null
+++
b/components/camel-google/camel-google-storage/src/test/java/org/apache/camel/component/google/storage/unit/ConsumerObjectThatCannotBeGotTest.java
@@ -0,0 +1,166 @@
+/*
+ * 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.unit;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import com.google.cloud.storage.BlobId;
+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.GoogleCloudStorageComponent;
+import
org.apache.camel.component.google.storage.localstorage.LocalStorageHelper;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.ExceptionHandler;
+import org.apache.camel.spi.PollingConsumerPollStrategy;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A listed object that cannot be got (access denied, an object encrypted with
a key the consumer cannot use) must not
+ * stop the consumer: the other listed objects are consumed, and the poll
fails only when none of the listed objects
+ * could be got. The consumer lists the whole bucket at every poll, so before
this one such object stopped the consumer
+ * from consuming any object of the bucket.
+ */
+class ConsumerObjectThatCannotBeGotTest extends CamelTestSupport {
+
+ private static final String BUCKET = "bkt";
+
+ private Storage storage;
+ private final CountDownLatch failedPolls = new CountDownLatch(2);
+ private final CountDownLatch skippedReported = new CountDownLatch(1);
+ private final List<String> handled = new CopyOnWriteArrayList<>();
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+ storage = LocalStorageHelper.getOptions().getService();
+ storage.create(BucketInfo.of(BUCKET));
+ context.getComponent("google-storage",
GoogleCloudStorageComponent.class).getConfiguration()
+ .setStorageClient(storage);
+ 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) {
+ // noop
+ }
+
+ @Override
+ public boolean rollback(Consumer consumer, Endpoint endpoint, int
retryCounter, Exception e) {
+ failedPolls.countDown();
+ return false;
+ }
+ });
+ context.getRegistry().bind("recordErrors", new ExceptionHandler() {
+ @Override
+ public void handleException(Throwable exception) {
+ handled.add(String.valueOf(exception.getMessage()));
+ }
+
+ @Override
+ public void handleException(String message, Throwable exception) {
+ record(message);
+ }
+
+ @Override
+ public void handleException(String message, Exchange exchange,
Throwable exception) {
+ record(message);
+ }
+
+ private void record(String message) {
+ handled.add(message);
+ if (message.contains("b.txt")) {
+ skippedReported.countDown();
+ }
+ }
+ });
+ return context;
+ }
+
+ @Test
+ void objectThatCannotBeGotDoesNotBlockTheOtherObjects() throws Exception {
+ put("a.txt");
+ put("b.txt");
+ put("c.txt");
+ LocalStorageHelper.refuseReads(BUCKET, "b.txt");
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceivedInAnyOrder("a.txt", "c.txt");
+
+ addConsumerRoute("&exceptionHandler=#recordErrors");
+
+ mock.assertIsSatisfied();
+ assertTrue(skippedReported.await(20, TimeUnit.SECONDS), "the skipped
object is reported: " + handled);
+ assertNotNull(storage.get(BlobId.of(BUCKET, "b.txt")), "b.txt stays");
+ }
+
+ @Test
+ void pollFailsWhenNoListedObjectCanBeGot() throws Exception {
+ put("a.txt");
+ put("b.txt");
+ LocalStorageHelper.refuseReads(BUCKET, "a.txt");
+ LocalStorageHelper.refuseReads(BUCKET, "b.txt");
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedMessageCount(0);
+
+
addConsumerRoute("&pollStrategy=#countPolls&exceptionHandler=#recordErrors");
+
+ // as for a failed listing (expired credentials, no permission to get
objects): the poll fails, so the error
+ // handling and the backoff of the scheduled consumer apply
+ assertTrue(failedPolls.await(20, TimeUnit.SECONDS), "the polls fail");
+ mock.assertIsSatisfied();
+ // the failed poll reaches the exception handler, and no object is
reported as skipped before it
+ assertTrue(handled.stream().anyMatch(m -> m.startsWith("Failed polling
endpoint")),
+ "the failed polls are reported: " + handled);
+ assertNull(skippedReportedMessage(), "no object is reported as
skipped: " + handled);
+ }
+
+ private String skippedReportedMessage() {
+ return handled.stream().filter(m ->
m.contains("skipped")).findFirst().orElse(null);
+ }
+
+ private void put(String name) {
+ storage.create(BlobInfo.newBuilder(BUCKET, name).build(),
name.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private void addConsumerRoute(String options) throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("google-storage://" + BUCKET + "?initialDelay=0&delay=10"
+ options)
+ .convertBodyTo(String.class)
+ .to("mock:result");
+ }
+ });
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 6804586084fc..df6c80a2e330 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -1837,6 +1837,16 @@ a Spring Boot or Quarkus app that `camel run` runs
(`<name>.log`), also while th
tools keep, and the history of the AI prompt. Directories are not counted, and
`--clean` counts only the files it
removed.
+=== camel-google-storage - a listed object that cannot be got is skipped
+
+When the consumer lists the bucket, a failure to download one of the listed
objects (for example access denied, or an
+object whose encryption key the consumer cannot use) no longer fails the poll,
as long as another listed object could
+be got: the object is skipped, the other listed objects are consumed, and the
skipped object is tried again by a later
+poll. The failure is given to the exception handler of the consumer, with the
object name in the message (by default a
+`WARN` with the stack trace, at every poll that lists the object). Before, the
poll failed, and as every poll lists the
+whole bucket, one such object stopped the consumer from consuming any object.
The poll still fails when none of the
+listed objects could be got.
+
=== camel-mongodb-gridfs - the consumer routes a file once
With the `TimeStamp` (default) and `PersistentTimestamp` query strategies, the
`mongodb-gridfs` consumer routed