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 a806bd20f47c CAMEL-25770: camel-azure-storage-datalake - skip a listed 
file that cannot be got instead of failing the poll (#28131)
a806bd20f47c is described below

commit a806bd20f47cae6989bc98ba96f3ae84089d57e2
Author: allthingssecurity <[email protected]>
AuthorDate: Tue Oct 20 15:16:38 2026 +0530

    CAMEL-25770: camel-azure-storage-datalake - skip a listed file that cannot 
be got instead of failing the poll (#28131)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../azure/storage/datalake/DataLakeConsumer.java   |  48 ++++-
 .../DataLakeConsumerFileThatCannotBeGotTest.java   | 221 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  10 +
 3 files changed, 278 insertions(+), 1 deletion(-)

diff --git 
a/components/camel-azure/camel-azure-storage-datalake/src/main/java/org/apache/camel/component/azure/storage/datalake/DataLakeConsumer.java
 
b/components/camel-azure/camel-azure-storage-datalake/src/main/java/org/apache/camel/component/azure/storage/datalake/DataLakeConsumer.java
index ee9c739846b8..d2e2e606ef30 100644
--- 
a/components/camel-azure/camel-azure-storage-datalake/src/main/java/org/apache/camel/component/azure/storage/datalake/DataLakeConsumer.java
+++ 
b/components/camel-azure/camel-azure-storage-datalake/src/main/java/org/apache/camel/component/azure/storage/datalake/DataLakeConsumer.java
@@ -17,8 +17,10 @@
 package org.apache.camel.component.azure.storage.datalake;
 
 import java.io.IOException;
+import java.util.LinkedHashMap;
 import java.util.LinkedList;
 import java.util.List;
+import java.util.Map;
 import java.util.Queue;
 
 import com.azure.storage.file.datalake.DataLakeFileSystemClient;
@@ -92,14 +94,58 @@ public class DataLakeConsumer extends 
ScheduledBatchPollingConsumer {
         forceConsumerAsReady();
 
         final Queue<Exchange> exchanges = new LinkedList<>();
+        // the listed files that could not be got, other than those that no 
longer exist, by name
+        final Map<String, Exception> failures = new LinkedHashMap<>();
         for (PathItem pathItem : items) {
             if (!pathItem.isDirectory()) {
-                exchanges.add(createExchangeFromFile(pathItem.getName(), 
dataLakeFileSystemClient));
+                // a failure to get one listed file (access denied by its ACL, 
a key that cannot decrypt it) does not
+                // fail the poll as long as another listed file could be got: 
the file is skipped, the other listed
+                // files are consumed, and a later poll tries it again
+                try {
+                    exchanges.add(createExchangeFromFile(pathItem.getName(), 
dataLakeFileSystemClient));
+                } catch (DataLakeStorageException e) {
+                    if (NOT_FOUND == e.getStatusCode()) {
+                        // deleted (for example by another consumer) after it 
was listed
+                        LOG.debug("Skipping file {} as it no longer exists", 
pathItem.getName());
+                    } else {
+                        failures.put(pathItem.getName(), e);
+                    }
+                } catch (Exception e) {
+                    failures.put(pathItem.getName(), e);
+                }
+            }
+        }
+
+        if (!failures.isEmpty()) {
+            if (exchanges.isEmpty()) {
+                // none of the listed files could be got, so this is not about 
one file (expired credentials, no
+                // permission to read files, no connection): the poll fails, 
and the backoff of the scheduled consumer
+                // applies
+                final Map.Entry<String, Exception> first = 
failures.entrySet().iterator().next();
+                LOG.warn("None of the listed files of file system {} could be 
got ({} failed, the first is {}): the poll fails",
+                        getEndpoint().getConfiguration().getFileSystemName(), 
failures.size(), first.getKey());
+                rethrow(first.getValue());
+            }
+            for (Map.Entry<String, Exception> failure : failures.entrySet()) {
+                getExceptionHandler().handleException("Error getting file " + 
failure.getKey() + " from file system "
+                                                      + 
getEndpoint().getConfiguration().getFileSystemName()
+                                                      + ". The file is skipped 
for this poll.",
+                        failure.getValue());
             }
         }
         return exchanges;
     }
 
+    private static void rethrow(final Exception e) throws IOException {
+        if (e instanceof IOException ioe) {
+            throw ioe;
+        }
+        if (e instanceof RuntimeException re) {
+            throw re;
+        }
+        throw new IOException(e);
+    }
+
     private Exchange createExchangeFromFile(final String fileName, final 
DataLakeFileSystemClient dataLakeFileSystemClient)
             throws IOException {
         final DataLakeFileClientWrapper clientWrapper
diff --git 
a/components/camel-azure/camel-azure-storage-datalake/src/test/java/org/apache/camel/component/azure/storage/datalake/DataLakeConsumerFileThatCannotBeGotTest.java
 
b/components/camel-azure/camel-azure-storage-datalake/src/test/java/org/apache/camel/component/azure/storage/datalake/DataLakeConsumerFileThatCannotBeGotTest.java
new file mode 100644
index 000000000000..2456087ca2f3
--- /dev/null
+++ 
b/components/camel-azure/camel-azure-storage-datalake/src/test/java/org/apache/camel/component/azure/storage/datalake/DataLakeConsumerFileThatCannotBeGotTest.java
@@ -0,0 +1,221 @@
+/*
+ * 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.datalake;
+
+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.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import com.azure.core.http.HttpResponse;
+import com.azure.core.http.rest.PagedIterable;
+import com.azure.storage.file.datalake.DataLakeFileClient;
+import com.azure.storage.file.datalake.DataLakeFileSystemClient;
+import com.azure.storage.file.datalake.DataLakeServiceClient;
+import 
com.azure.storage.file.datalake.models.DataLakeFileOpenInputStreamResult;
+import com.azure.storage.file.datalake.models.DataLakeStorageException;
+import com.azure.storage.file.datalake.models.PathItem;
+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 static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * A listed file that cannot be got (access denied by its ACL, a file 
encrypted with a key the consumer does not have)
+ * must not stop the consumer: the other listed files are consumed, and the 
poll fails only when none of the listed
+ * files could be got. The consumer lists the whole file system at every poll, 
so before this one such file stopped the
+ * consumer from consuming any file.
+ */
+class DataLakeConsumerFileThatCannotBeGotTest extends CamelTestSupport {
+
+    private final Set<String> files = new ConcurrentSkipListSet<>();
+    private final Set<String> forbidden = ConcurrentHashMap.newKeySet();
+    private final Set<String> deleted = ConcurrentHashMap.newKeySet();
+    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();
+        context.getRegistry().bind("client", serviceClient());
+        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 fileThatCannotBeGotDoesNotBlockTheOtherFiles() throws Exception {
+        files.add("a.txt");
+        files.add("b.txt");
+        files.add("c.txt");
+        forbidden.add("b.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedMinimumMessageCount(2);
+
+        addConsumerRoute("&exceptionHandler=#recordErrors");
+
+        mock.assertIsSatisfied();
+        List<String> bodies = mock.getReceivedExchanges().stream().map(e -> 
e.getIn().getBody(String.class)).toList();
+        assertTrue(bodies.contains("a.txt") && bodies.contains("c.txt"), 
"a.txt and c.txt are consumed: " + bodies);
+        assertFalse(bodies.contains("b.txt"), "b.txt cannot be got: " + 
bodies);
+        assertTrue(skippedReported.await(20, TimeUnit.SECONDS), "the skipped 
file is reported: " + handled);
+    }
+
+    @Test
+    void fileDeletedAfterTheListingIsSkipped() throws Exception {
+        files.add("a.txt");
+        files.add("b.txt");
+        files.add("c.txt");
+        deleted.add("b.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedMinimumMessageCount(2);
+
+        addConsumerRoute("&exceptionHandler=#recordErrors");
+
+        // before, the 404 of b.txt ended every poll with no exchange at all
+        mock.assertIsSatisfied();
+        List<String> bodies = mock.getReceivedExchanges().stream().map(e -> 
e.getIn().getBody(String.class)).toList();
+        assertTrue(bodies.contains("a.txt") && bodies.contains("c.txt"), 
"a.txt and c.txt are consumed: " + bodies);
+        assertFalse(bodies.contains("b.txt"), "b.txt no longer exists: " + 
bodies);
+        assertTrue(handled.isEmpty(), "a file that no longer exists is not 
reported: " + handled);
+    }
+
+    @Test
+    void pollFailsWhenNoListedFileCanBeGot() throws Exception {
+        files.add("a.txt");
+        files.add("b.txt");
+        forbidden.add("a.txt");
+        forbidden.add("b.txt");
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedMessageCount(0);
+
+        addConsumerRoute("&pollStrategy=#countPolls");
+
+        // as for a failed listing (expired credentials, no permission to read 
files): 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();
+    }
+
+    private void addConsumerRoute(String options) throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("azure-storage-datalake:account/fs?serviceClient=#client&initialDelay=0&delay=10"
 + options)
+                        .convertBodyTo(String.class)
+                        .to("mock:result");
+            }
+        });
+    }
+
+    @SuppressWarnings("unchecked")
+    private DataLakeServiceClient serviceClient() {
+        DataLakeServiceClient serviceClient = 
mock(DataLakeServiceClient.class);
+        DataLakeFileSystemClient fileSystemClient = 
mock(DataLakeFileSystemClient.class);
+        
when(serviceClient.getFileSystemClient(anyString())).thenReturn(fileSystemClient);
+
+        PagedIterable<PathItem> listing = mock(PagedIterable.class);
+        when(listing.stream()).thenAnswer(i -> files.stream()
+                .map(name -> new PathItem(null, null, 0L, null, false, name, 
null, null))
+                .toList().stream());
+        when(fileSystemClient.listPaths(any(), any())).thenReturn(listing);
+
+        when(fileSystemClient.getFileClient(anyString())).thenAnswer(i -> 
fileClient(i.getArgument(0)));
+        return serviceClient;
+    }
+
+    private DataLakeFileClient fileClient(String name) {
+        DataLakeFileClient fileClient = mock(DataLakeFileClient.class);
+        when(fileClient.openInputStream()).thenAnswer(i -> {
+            if (forbidden.contains(name)) {
+                // what the service answers to a read that the ACL of the file 
does not allow
+                HttpResponse response = mock(HttpResponse.class);
+                when(response.getStatusCode()).thenReturn(403);
+                throw new DataLakeStorageException(
+                        "This request is not authorized to perform this 
operation.", response,
+                        null);
+            }
+            if (deleted.contains(name)) {
+                // what the service answers to a read of a file deleted after 
the listing
+                HttpResponse response = mock(HttpResponse.class);
+                when(response.getStatusCode()).thenReturn(404);
+                throw new DataLakeStorageException("The specified path does 
not exist.", response, null);
+            }
+            DataLakeFileOpenInputStreamResult result = 
mock(DataLakeFileOpenInputStreamResult.class);
+            when(result.getInputStream()).thenReturn(new 
ByteArrayInputStream(name.getBytes(StandardCharsets.UTF_8)));
+            return result;
+        });
+        return fileClient;
+    }
+}
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 44fc7afe6e36..2a293ca9a328 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
@@ -1773,6 +1773,16 @@ support it keep their existing projection. The `camel 
semantic audit --json` dis
 base64 and reports metadata instead. Use `--output=<new-file>` to export the 
full returned JSON,
 with `--include-input` for case/bulk requests that need captured input. 
Existing files are not overwritten.
 
+=== camel-azure-storage-datalake - a listed file that cannot be got is skipped
+
+When the consumer lists the file system, a failure to get one of the listed 
files (for example access denied by the ACL
+of the file) no longer fails the poll, as long as another listed file could be 
got: the file is skipped, the other
+listed files are consumed, and the skipped file is tried again by a later 
poll. The failure is given to the exception
+handler of the consumer, with the file name in the message (by default a 
`WARN` with the stack trace, at every poll that
+lists the file). A file deleted after the listing is skipped as well; before, 
it made the whole poll return no file.
+Before, any other failure failed the poll, and as every poll lists the file 
system again, one such file stopped the
+consumer from consuming any file. The poll still fails when none of the listed 
files could be got.
+
 === camel-milo - a Bad or Uncertain status is delivered by default
 
 A `milo-client` consumer now delivers a data change with a Bad or Uncertain 
status by default, even when its value is

Reply via email to