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 a2a592c0772b CAMEL-25760: camel-alibaba-oss - skip a listed object
that cannot be got instead of failing the poll (#28075)
a2a592c0772b is described below
commit a2a592c0772b990cfa381bc810ac0feaf224fe26
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Oct 19 19:58:44 2026 +0530
CAMEL-25760: camel-alibaba-oss - skip a listed object that cannot be got
instead of failing the poll (#28075)
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../camel/component/alibaba/oss/OSSConsumer.java | 70 +++++--
.../oss/OSSConsumerObjectThatCannotBeGotTest.java | 207 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 10 +
3 files changed, 267 insertions(+), 20 deletions(-)
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 e83163dcf2d0..f88f27ce06b0 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
@@ -16,8 +16,10 @@
*/
package org.apache.camel.component.alibaba.oss;
+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;
@@ -93,17 +95,16 @@ public class OSSConsumer extends
ScheduledBatchPollingConsumer {
forceConsumerAsReady();
- String nextToken = null;
+ // the next poll lists the next page, also when the objects of this
page cannot be got: such a page must not
+ // hold the consumer back, and its objects are listed again when the
listing starts over
if (Boolean.TRUE.equals(listing.isTruncated()) &&
listing.nextContinuationToken() != null) {
- nextToken = listing.nextContinuationToken();
+ continuationToken = listing.nextContinuationToken();
+ } else {
+ continuationToken = null;
}
Queue<Exchange> exchanges = createExchanges(bucketName,
listing.contents());
- int processed = processBatch(CastUtils.cast(exchanges));
- if (processed >= 0) {
- continuationToken = nextToken;
- }
- return processed;
+ return processBatch(CastUtils.cast(exchanges));
}
@Override
@@ -155,26 +156,54 @@ public class OSSConsumer extends
ScheduledBatchPollingConsumer {
return answer;
}
- try {
- for (ObjectSummary summary : summaries) {
- if (summary.key() == null || summary.key().endsWith("/")) {
- continue;
- }
- addExchange(bucketName, summary.key(), answer);
+ // the listed objects that could not be got, other than those that no
longer exist, by key
+ Map<String, Exception> failures = new LinkedHashMap<>();
+ // whether at least one listed object could be got
+ boolean anyGot = false;
+ for (ObjectSummary summary : summaries) {
+ String key = summary.key();
+ if (key == null || key.endsWith("/")) {
+ 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 {
+ anyGot |= addExchange(bucketName, key, answer);
+ } catch (Exception e) {
+ failures.put(key, 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, Exception> first =
failures.entrySet().iterator().next();
+ LOG.warn("None of the listed objects of bucket {} could be got
({} failed, the first is {}): the poll fails",
+ bucketName, failures.size(), first.getKey());
+ throw first.getValue();
+ }
+ for (Map.Entry<String, Exception> failure : failures.entrySet()) {
+ getExceptionHandler().handleException("Error getting object "
+ failure.getKey() + " from bucket "
+ + bucketName + ". The
object is skipped for this poll.",
+ failure.getValue());
}
- } 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 {
+ /**
+ * 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 of a previous poll
+ * is still processing it, or it no longer exists)
+ */
+ private boolean 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;
+ return false;
}
try {
GetObjectResult result =
ossClient.getObject(GetObjectRequest.newBuilder()
@@ -191,12 +220,13 @@ public class OSSConsumer extends
ScheduledBatchPollingConsumer {
exchange.setPattern(endpoint.getExchangePattern());
OSSUtils.mapOssObject(exchange, bucketName, key, result, body);
answer.add(exchange);
+ return true;
} 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;
+ return false;
}
throw e;
}
diff --git
a/components/camel-alibaba/camel-alibaba-oss/src/test/java/org/apache/camel/component/alibaba/oss/OSSConsumerObjectThatCannotBeGotTest.java
b/components/camel-alibaba/camel-alibaba-oss/src/test/java/org/apache/camel/component/alibaba/oss/OSSConsumerObjectThatCannotBeGotTest.java
new file mode 100644
index 000000000000..05ab87ecbf49
--- /dev/null
+++
b/components/camel-alibaba/camel-alibaba-oss/src/test/java/org/apache/camel/component/alibaba/oss/OSSConsumerObjectThatCannotBeGotTest.java
@@ -0,0 +1,207 @@
+/*
+ * 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.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.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.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.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * 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, the next page of the
listing is read, 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 continuation token, and answers the get of a
forbidden object with 403.
+ */
+class OSSConsumerObjectThatCannotBeGotTest extends CamelTestSupport {
+
+ 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("ossClient", ossClient());
+ 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(
+ () -> assertThat(objects).as("a.txt and c.txt are consumed and
deleted, b.txt stays").containsExactly("b.txt"));
+ await().atMost(20, TimeUnit.SECONDS).untilAsserted(
+ () -> assertThat(handled).as("the skipped object is
reported").anyMatch(m -> m.contains("b.txt")));
+ }
+
+ @Test
+ void pageOfObjectsThatCannotBeGotDoesNotBlockTheNextPage() throws
Exception {
+ objects.add("a.txt");
+ objects.add("b.txt");
+ objects.add("c.txt");
+ forbidden.add("a.txt");
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceivedInAnyOrder("b.txt", "c.txt");
+
+ // one object per page: the first page holds only a.txt
+
addConsumerRoute("&deleteAfterRead=true&maxMessagesPerPoll=1&pollStrategy=#countPolls");
+
+ mock.assertIsSatisfied();
+ await().atMost(20, TimeUnit.SECONDS).untilAsserted(
+ () -> assertThat(objects).as("b.txt and c.txt are consumed and
deleted, a.txt stays").containsExactly("a.txt"));
+ }
+
+ @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("alibaba-oss:bucket?ossClient=#ossClient&initialDelay=0&delay=10" +
options)
+ .convertBodyTo(String.class)
+ .to("mock:result");
+ }
+ });
+ }
+
+ private OSSClient ossClient() {
+ OSSClient client = Mockito.mock(OSSClient.class);
+
Mockito.when(client.listObjectsV2(Mockito.any(ListObjectsV2Request.class))).thenAnswer(i
-> {
+ ListObjectsV2Request request = i.getArgument(0);
+ String after = request.continuationToken();
+ long maxKeys = request.maxKeys() != null ? request.maxKeys() :
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();
+ List<ObjectSummary> contents = page.stream().map(key -> {
+ ObjectSummary summary = Mockito.mock(ObjectSummary.class);
+ Mockito.when(summary.key()).thenReturn(key);
+ return summary;
+ }).toList();
+ ListObjectsV2Result result =
Mockito.mock(ListObjectsV2Result.class);
+ Mockito.when(result.contents()).thenReturn(contents);
+ Mockito.when(result.isTruncated()).thenReturn(truncated);
+ Mockito.when(result.nextContinuationToken()).thenReturn(truncated
? page.get(page.size() - 1) : null);
+ 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());
+ }
+ if (forbidden.contains(key)) {
+ throw new OperationException("GetObject",
ServiceException.newBuilder().statusCode(403).build());
+ }
+ 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/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 7abafb4d7275..3c04245bfe41 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
@@ -1463,6 +1463,16 @@ anything there - the JDK does not support host name
verification on the server s
client certificate with `Endpoint Identification Algorithm HTTPS is not
supported on the server side` - so a consumer
that asks for client certificates and had the option set now completes the
handshake instead of failing it.
+=== camel-alibaba-oss - 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 and the next poll listed the same
+page again, so one such object stopped the consumer from consuming any object.
The poll still fails when none of the
+listed objects could be got, and the next poll then reads the next page of the
listing.
+
=== camel-dropbox - search and get read every page of the result
The `search` operation (producer and consumer) now returns every match that
the service gives. Previously it returned