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 e9d519bbd6c5 CAMEL-25760: camel-huaweicloud-obs - skip a listed object
that cannot be got instead of failing the poll (#28076)
e9d519bbd6c5 is described below
commit e9d519bbd6c5f271a30cf73556f56534edd348f0
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Oct 19 19:58:57 2026 +0530
CAMEL-25760: camel-huaweicloud-obs - skip a listed object that cannot be
got instead of failing the poll (#28076)
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../component/huaweicloud/obs/OBSConsumer.java | 63 ++++--
.../obs/OBSConsumerObjectThatCannotBeGotTest.java | 220 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 11 ++
3 files changed, 274 insertions(+), 20 deletions(-)
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 385fb1e4f479..b287757c1100 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,8 +16,10 @@
*/
package org.apache.camel.component.huaweicloud.obs;
+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;
@@ -217,29 +219,50 @@ public class OBSConsumer extends
ScheduledBatchPollingConsumer {
*/
private Queue<Exchange> createExchanges(List<ObsObject> obsObjects) {
Queue<Exchange> answer = new LinkedList<>();
- 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);
+ // the listed objects that could not be got, other than those that no
longer exist, by key
+ Map<String, RuntimeException> failures = new LinkedHashMap<>();
+ // whether at least one listed object could be got
+ boolean anyGot = false;
+ 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;
+ }
+ // a failure to get one listed object 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
+ try {
+ addExchange(objectKey, objectSummary, answer);
+ anyGot = true;
+ } 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;
}
- 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;
- }
+ failures.put(objectKey, e);
+ } catch (RuntimeException e) {
+ failures.put(objectKey, 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, no connection): the poll fails, and the
backoff of the scheduled consumer applies
+ releaseNotProcessed(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",
+ endpoint.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 "
+ +
endpoint.getBucketName()
+ + ". The object is
skipped for this poll.",
+ failure.getValue());
}
- } catch (RuntimeException e) {
- // none of the objects of this poll is processed
- releaseNotProcessed(answer);
- throw e;
}
return answer;
}
diff --git
a/components/camel-huawei/camel-huaweicloud-obs/src/test/java/org/apache/camel/component/huaweicloud/obs/OBSConsumerObjectThatCannotBeGotTest.java
b/components/camel-huawei/camel-huaweicloud-obs/src/test/java/org/apache/camel/component/huaweicloud/obs/OBSConsumerObjectThatCannotBeGotTest.java
new file mode 100644
index 000000000000..f0e97fa9076a
--- /dev/null
+++
b/components/camel-huawei/camel-huaweicloud-obs/src/test/java/org/apache/camel/component/huaweicloud/obs/OBSConsumerObjectThatCannotBeGotTest.java
@@ -0,0 +1,220 @@
+/*
+ * 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.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentSkipListSet;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+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.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+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 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;
+
+/**
+ * A listed object that cannot be got (access denied, an archived object that
is not restored) 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 fake bucket lists its keys in order, a page of maxKeys keys at a
time with the last key of a page as next
+ * marker, and answers the get of a forbidden object with 403.
+ */
+class OBSConsumerObjectThatCannotBeGotTest extends CamelTestSupport {
+
+ private static final String BUCKET = "bucket";
+
+ private final Set<String> objects = new ConcurrentSkipListSet<>();
+ private final Set<String> forbidden = ConcurrentHashMap.newKeySet();
+ private final AtomicInteger failedPolls = new AtomicInteger();
+ private final List<String> handled = new CopyOnWriteArrayList<>();
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+ context.getRegistry().bind("obsClient", obsClient());
+ 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.incrementAndGet();
+ 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) {
+ handled.add(message);
+ }
+
+ @Override
+ public void handleException(String message, Exchange exchange,
Throwable exception) {
+ handled.add(message);
+ }
+ });
+ return context;
+ }
+
+ @Test
+ void objectThatCannotBeGotDoesNotBlockTheOtherObjects() throws Exception {
+ objects.add("a.txt");
+ objects.add("b.txt");
+ objects.add("c.txt");
+ forbidden.add("b.txt");
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceivedInAnyOrder("a.txt", "c.txt");
+
+
addConsumerRoute("&deleteAfterRead=true&exceptionHandler=#recordErrors");
+
+ mock.assertIsSatisfied();
+ await().atMost(20, TimeUnit.SECONDS).untilAsserted(
+ () -> assertEquals(Set.of("b.txt"), Set.copyOf(objects),
+ "a.txt and c.txt are consumed and deleted, b.txt
stays"));
+ await().atMost(20, TimeUnit.SECONDS).untilAsserted(
+ () -> assertTrue(handled.stream().anyMatch(m ->
m.contains("b.txt")), "the skipped object is reported"));
+ }
+
+ @Test
+ void objectThatCannotBeGotDoesNotBlockTheObjectsOfItsPage() throws
Exception {
+ objects.add("a.txt");
+ objects.add("b.txt");
+ objects.add("c.txt");
+ objects.add("d.txt");
+ forbidden.add("a.txt");
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceivedInAnyOrder("b.txt", "c.txt", "d.txt");
+
+ // two objects per page: a.txt shares the first page with b.txt
+ addConsumerRoute("&deleteAfterRead=true&maxMessagesPerPoll=2");
+
+ mock.assertIsSatisfied();
+ await().atMost(20, TimeUnit.SECONDS).untilAsserted(
+ () -> assertEquals(Set.of("a.txt"), Set.copyOf(objects),
"b.txt, c.txt and d.txt are consumed and deleted"));
+ }
+
+ @Test
+ void pollFailsWhenNoListedObjectCanBeGot() throws Exception {
+ objects.add("a.txt");
+ objects.add("b.txt");
+ forbidden.add("a.txt");
+ forbidden.add("b.txt");
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedMessageCount(0);
+
+ addConsumerRoute("&deleteAfterRead=true&pollStrategy=#countPolls");
+
+ // 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
+ await().atMost(20, TimeUnit.SECONDS).until(() -> failedPolls.get() >=
2);
+ mock.assertIsSatisfied();
+ }
+
+ private void addConsumerRoute(String options) throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("hwcloud-obs:?bucketName=" + BUCKET +
"&obsClient=#obsClient&initialDelay=0&delay=10" + options)
+ .convertBodyTo(String.class)
+ .to("mock:result");
+ }
+ });
+ }
+
+ private ObsClient obsClient() {
+ ObsClient client = Mockito.mock(ObsClient.class);
+
Mockito.when(client.listObjects(Mockito.any(ListObjectsRequest.class))).thenAnswer(i
-> {
+ ListObjectsRequest request = i.getArgument(0);
+ String after = request.getMarker();
+ int maxKeys = request.getMaxKeys() > 0 ? request.getMaxKeys() :
1000;
+ List<String> remaining = objects.stream().filter(k -> after ==
null || k.compareTo(after) > 0).toList();
+ List<String> page = remaining.stream().limit(maxKeys).toList();
+ boolean truncated = remaining.size() > page.size();
+ // listed objects carry no content type, so the consumer gets each
of them
+ List<ObsObject> listed = page.stream().map(key -> {
+ ObsObject summary = new ObsObject();
+ summary.setBucketName(BUCKET);
+ summary.setObjectKey(key);
+ return summary;
+ }).toList();
+ ObjectListing listing = Mockito.mock(ObjectListing.class);
+ Mockito.when(listing.getObjects()).thenReturn(listed);
+ Mockito.when(listing.isTruncated()).thenReturn(truncated);
+ Mockito.when(listing.getNextMarker()).thenReturn(truncated ?
page.get(page.size() - 1) : null);
+ 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;
+ }
+ if (forbidden.contains(key)) {
+ ObsException e = new ObsException("AccessDenied");
+ e.setResponseCode(403);
+ throw e;
+ }
+ 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_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 3c04245bfe41..4d4f8a860fcc 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
@@ -1387,6 +1387,17 @@ became `café` in the headers and the body map. A
charset that the request or
that sends ISO-8859-1 form data without declaring a charset now gets U+FFFD
replacement characters instead of its
text, and must declare its charset (for example
`application/x-www-form-urlencoded; charset=ISO-8859-1`).
+=== camel-huaweicloud-obs - a listed object that cannot be got is skipped
+
+When the consumer lists the bucket, a failure to get one of the listed objects
(for example access denied, or an
+archived object that is not restored) no longer fails the poll, as long as
another object of that listing 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
key in the message (by default a `WARN`
+with the stack trace, at every poll that lists the object). Before, the poll
failed, so the other objects listed with
+it were not consumed either, and when the bucket fitted in one listing
(`maxMessagesPerPoll`, default 10) the consumer
+consumed no object at all. The poll still fails when none of the listed
objects could be got. With the `fileName`
+option, a failed get still fails the poll.
+
=== camel-github2 - consumers and items of a poll that were not routed
The `github2` consumers (`commit`, `event`, `pullRequest`,
`pullRequestComment`, `tag`) remembered every item of a