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 d5987d709488 CAMEL-26653: camel-minio - honor maxMessagesPerPoll per 
poll (#29656)
d5987d709488 is described below

commit d5987d709488491bcf7ebbd3ca5dae20e3b3b6af
Author: Pratik <[email protected]>
AuthorDate: Thu Nov 12 01:00:59 2026 -0800

    CAMEL-26653: camel-minio - honor maxMessagesPerPoll per poll (#29656)
    
    * CAMEL-26653: camel-minio - honor maxMessagesPerPoll per poll
    
    The consumer previously used maxMessagesPerPoll only as the listing page 
size, so one poll kept paging and routed every object in the bucket.
    
    It now stops listing at maxMessagesPerPoll (one page per poll), with the 
continuation token taken from what the poll actually listed.
    
    Adds MinioConsumerMaxMessagesPerPollTest and a 4.24 upgrade-guide note.
    
    * CAMEL-26653: Address review feedback on the maxMessagesPerPoll cap
    
    - Use the continuation token before the configured startAfter, so a
      configured startAfter no longer restarts every poll at the first page
    - Do not probe hasNext() at the cap; the listing counts as not ended
    - Take the continuation token from the largest object name listed
    - Remove the dead listing state and note the includeVersions behaviour
    - Add a regression test for startAfter with deleteAfterRead=false
---
 .../camel/component/minio/MinioConsumer.java       |  82 ++++++++---
 .../minio/MinioConsumerMaxMessagesPerPollTest.java | 160 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  12 ++
 3 files changed, 231 insertions(+), 23 deletions(-)

diff --git 
a/components/camel-minio/src/main/java/org/apache/camel/component/minio/MinioConsumer.java
 
b/components/camel-minio/src/main/java/org/apache/camel/component/minio/MinioConsumer.java
index a0e6cc460539..977e4473752b 100644
--- 
a/components/camel-minio/src/main/java/org/apache/camel/component/minio/MinioConsumer.java
+++ 
b/components/camel-minio/src/main/java/org/apache/camel/component/minio/MinioConsumer.java
@@ -69,10 +69,11 @@ public class MinioConsumer extends 
ScheduledBatchPollingConsumer {
     private final Set<String> inProgress = ConcurrentHashMap.newKeySet();
     private long totalCounter;
     private String continuationToken;
-    // the name of the last object listed by the poll, and the number of 
objects it listed (set once the listing is
-    // read to the end)
+    // the largest name of the objects listed by the last poll (set once its 
listing has been read)
     private String lastListedObjectName;
-    private int listedObjects;
+    // whether the listing of the last poll was read to its end (false when 
the poll stopped at the limit, and
+    // left false when a listing request failed before the end of the listing 
was known)
+    private boolean listingExhausted;
     private transient String minioConsumerToString;
 
     public MinioConsumer(MinioEndpoint endpoint, Processor processor) {
@@ -161,16 +162,15 @@ public class MinioConsumer extends 
ScheduledBatchPollingConsumer {
                 listObjectRequest.prefix(getConfiguration().getPrefix());
             }
 
-            if (isNotEmpty(getConfiguration().getStartAfter())) {
-                
listObjectRequest.startAfter(getConfiguration().getStartAfter());
-                continuationToken = null;
-            }
-
-            // if there was a marker from previous poll then use that to
-            // continue from where we left last time
+            // if there was a marker from previous poll then use that to 
continue from where we left last time;
+            // the configured startAfter only marks where a pass through the 
bucket begins, so it is used when
+            // there is no marker: if it took precedence on every poll, each 
poll would start over at the
+            // configured value and, with a poll capped at maxMessagesPerPoll, 
never get past its first page
             if (isNotEmpty(continuationToken)) {
                 LOG.trace("Resuming from marker: {}", continuationToken);
                 listObjectRequest.startAfter(continuationToken);
+            } else if (isNotEmpty(getConfiguration().getStartAfter())) {
+                
listObjectRequest.startAfter(getConfiguration().getStartAfter());
             }
 
             Iterator<Result<Item>> listObjects = 
getMinioClient().listObjects(listObjectRequest.build()).iterator();
@@ -180,19 +180,29 @@ public class MinioConsumer extends 
ScheduledBatchPollingConsumer {
 
             if (listObjects.hasNext()) {
                 lastListedObjectName = null;
-                listedObjects = 0;
+                // not known to have ended until the listing of this poll has 
been read, so a listing request
+                // that fails leaves the token of the previous poll in place
+                listingExhausted = false;
+                boolean listingEnded = false;
                 try {
                     exchanges = createExchanges(listObjects);
+                    listingEnded = listingExhausted;
                 } finally {
-                    // the next poll continues after the last listed object, 
also when objects of this poll are skipped
-                    // or none of them could be got: such objects must not 
hold the consumer back, and they are listed
-                    // again when the listing starts over
-                    if (lastListedObjectName != null) {
-                        if (maxMessagesPerPoll <= 0 || listedObjects < 
maxMessagesPerPoll) {
-                            continuationToken = null;
-                        } else {
-                            continuationToken = lastListedObjectName;
-                        }
+                    // the next poll continues after the largest name this 
poll listed, also when objects of this
+                    // poll are skipped or none of them could be got: such 
objects must not hold the consumer
+                    // back, and they are listed again when the listing starts 
over. The token is kept when the
+                    // listing has not ended, and cleared when it has, so the 
next poll starts a new pass through
+                    // the bucket (from the configured startAfter, when one is 
set). A poll that lists nothing
+                    // (the object it would continue after was deleted) clears 
the token in the empty listing
+                    // branch below and starts over, as before.
+                    // Note on includeVersions: the token is an object name, 
passed to the next listing as a key
+                    // marker without a version marker, so when a page ends 
partway through the versions of one
+                    // object, the remaining versions of that object are 
listed on the next pass through the
+                    // bucket, not by the next poll
+                    if (listingEnded) {
+                        continuationToken = null;
+                    } else if (lastListedObjectName != null) {
+                        continuationToken = lastListedObjectName;
                     }
                 }
                 if (LOG.isTraceEnabled()) {
@@ -222,6 +232,11 @@ public class MinioConsumer extends 
ScheduledBatchPollingConsumer {
         // whether the stat of at least one listed object succeeded
         boolean anyGot = false;
         String lastListed = null;
+        // whether the listing was read to its end: it is not when the poll 
stops at the limit (the listing is
+        // not probed there, see below). Below the limit the listing is read 
until the iterator has no more
+        // items, paging through pages the server capped below the limit, so 
its end is only reported once the
+        // iterator is exhausted
+        boolean exhausted = false;
         try {
             do {
                 messageCounter++;
@@ -238,10 +253,31 @@ public class MinioConsumer extends 
ScheduledBatchPollingConsumer {
                         failures.put(objectName, e);
                     }
                 }
-                lastListed = minioObjectSummary.objectName();
-            } while (minioObjectSummaries.hasNext());
+                // the token of the next poll is the largest name listed by 
this poll, not simply the last item
+                // the iterator returned: the items of a page are not all in 
name order (a page can end on a
+                // folder prefix that sorts before objects the page held), and 
continuing after a smaller name
+                // would list objects of this page again
+                String listedName = minioObjectSummary.objectName();
+                if (lastListed == null || listedName.compareTo(lastListed) > 
0) {
+                    lastListed = listedName;
+                }
+                // a limit above 0 caps the objects listed by a poll, so do 
not read further pages of the listing:
+                // the next poll continues after the last listed object. Do 
not probe hasNext() to find out
+                // whether the listing continues at the limit: the minio 
client iterator fetches the next page
+                // to answer, and that page would be discarded here and 
fetched again by the next poll (the
+                // listing response itself, with its truncated flag, is not 
exposed by the client). The listing
+                // is therefore treated as not ended at the limit; when it did 
end at a full page, the next poll
+                // lists nothing and clears the token, which costs one empty 
poll per pass through the bucket
+                if (maxMessagesPerPoll > 0 && messageCounter >= 
maxMessagesPerPoll) {
+                    break;
+                }
+                if (!minioObjectSummaries.hasNext()) {
+                    exhausted = true;
+                    break;
+                }
+            } while (true);
             lastListedObjectName = lastListed;
-            listedObjects = messageCounter;
+            listingExhausted = exhausted;
 
             if (LOG.isTraceEnabled()) {
                 LOG.trace("Received {} messages in this poll", messageCounter);
diff --git 
a/components/camel-minio/src/test/java/org/apache/camel/component/minio/MinioConsumerMaxMessagesPerPollTest.java
 
b/components/camel-minio/src/test/java/org/apache/camel/component/minio/MinioConsumerMaxMessagesPerPollTest.java
new file mode 100644
index 000000000000..d3e81b67b35b
--- /dev/null
+++ 
b/components/camel-minio/src/test/java/org/apache/camel/component/minio/MinioConsumerMaxMessagesPerPollTest.java
@@ -0,0 +1,160 @@
+/*
+ * 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.minio;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+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.mock.MockEndpoint;
+import org.apache.camel.support.DefaultPollingConsumerPollStrategy;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A poll lists and routes at most maxMessagesPerPoll objects: with more 
objects in the bucket, the following polls
+ * continue after the last listed object until the bucket is drained, and a 
later poll starts over. A configured
+ * startAfter marks where each pass through the bucket begins: the polls of a 
pass still continue after the last listed
+ * object, they do not start over at startAfter on every poll.
+ */
+class MinioConsumerMaxMessagesPerPollTest extends CamelTestSupport {
+
+    private static final int OBJECTS = 30;
+    private static final int MAX_PER_POLL = 10;
+    // the objects after the configured startAfter of the second route: 
name(6) to name(30)
+    private static final String START_AFTER = "o005.txt";
+    private static final int AFTER_START_AFTER = OBJECTS - 5;
+
+    private final List<Integer> polledCounts = new CopyOnWriteArrayList<>();
+    private final List<Integer> polledCountsStartAfter = new 
CopyOnWriteArrayList<>();
+    private FakeS3Server s3;
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        s3 = new FakeS3Server("bkt");
+        for (int i = 1; i <= OBJECTS; i++) {
+            s3.put(name(i), "content-" + i);
+        }
+        s3.start();
+        CamelContext context = super.createCamelContext();
+        context.getRegistry().bind("recordPolls", new 
DefaultPollingConsumerPollStrategy() {
+            @Override
+            public void commit(Consumer consumer, Endpoint endpoint, int 
polledMessages) {
+                if (endpoint.getEndpointUri().contains("startAfter")) {
+                    polledCountsStartAfter.add(polledMessages);
+                } else {
+                    polledCounts.add(polledMessages);
+                }
+            }
+        });
+        return context;
+    }
+
+    @AfterEach
+    void stopServer() {
+        if (s3 != null) {
+            s3.stop();
+        }
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("minio://bkt?accessKey=xxx&secretKey=yyy&region=us-east-1&delay=10&pollStrategy=#recordPolls"
+                     + "&maxMessagesPerPoll=" + MAX_PER_POLL + 
"&deleteAfterRead=false&endpoint=" + s3.endpoint())
+                        .to("mock:result");
+                
from("minio://bkt?accessKey=xxx&secretKey=yyy&region=us-east-1&delay=10&pollStrategy=#recordPolls"
+                     + "&maxMessagesPerPoll=" + MAX_PER_POLL + 
"&deleteAfterRead=false&startAfter=" + START_AFTER
+                     + "&endpoint=" + s3.endpoint())
+                        .to("mock:resultStartAfter");
+            }
+        };
+    }
+
+    @Test
+    void pollRoutesAtMostMaxMessagesPerPoll() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        // a minimum, not an exact count: with deleteAfterRead=false the polls 
keep cycling through the bucket
+        // after it has been drained, so more messages can arrive while the 
assertions run
+        mock.expectedMinimumMessageCount(OBJECTS);
+        mock.assertIsSatisfied();
+
+        // the first OBJECTS messages are the whole bucket, in listing order
+        List<String> bodies = mock.getExchanges().stream()
+                .limit(OBJECTS)
+                .map(e -> e.getIn().getBody(String.class))
+                .toList();
+        List<String> expected = new ArrayList<>();
+        for (int i = 1; i <= OBJECTS; i++) {
+            expected.add("content-" + i);
+        }
+        assertEquals(expected, bodies);
+
+        // the bucket was drained by three polls of one full page each, 
continuing after the last listed object,
+        // and no poll ever routed more than the limit (a poll that listed 
nothing is committed with 0)
+        List<Integer> nonEmpty = polledCounts.stream().filter(n -> n > 
0).toList();
+        assertTrue(nonEmpty.size() >= 3, "expected at least three non-empty 
polls: " + polledCounts);
+        assertEquals(List.of(MAX_PER_POLL, MAX_PER_POLL, MAX_PER_POLL), 
nonEmpty.subList(0, 3),
+                "the first polls should each route one full page: " + 
polledCounts);
+        assertTrue(nonEmpty.stream().allMatch(n -> n <= MAX_PER_POLL),
+                "no poll should route more than maxMessagesPerPoll objects: " 
+ polledCounts);
+    }
+
+    @Test
+    void pollWithStartAfterProgressesPastTheFirstPage() throws Exception {
+        MockEndpoint mock = getMockEndpoint("mock:resultStartAfter");
+        // a minimum, not an exact count: with deleteAfterRead=false the polls 
keep cycling through the objects
+        // after startAfter once a pass has covered them, so more messages can 
arrive while the assertions run
+        mock.expectedMinimumMessageCount(AFTER_START_AFTER);
+        mock.assertIsSatisfied();
+
+        // the first messages after startAfter are all the objects beyond it, 
in listing order: the polls of the
+        // pass continued after the last listed object instead of listing the 
first page after startAfter again
+        List<String> bodies = mock.getExchanges().stream()
+                .limit(AFTER_START_AFTER)
+                .map(e -> e.getIn().getBody(String.class))
+                .toList();
+        List<String> expected = new ArrayList<>();
+        for (int i = 6; i <= OBJECTS; i++) {
+            expected.add("content-" + i);
+        }
+        assertEquals(expected, bodies);
+
+        // the pass was two full pages and a last page with the remaining 
objects
+        List<Integer> nonEmpty = polledCountsStartAfter.stream().filter(n -> n 
> 0).toList();
+        assertTrue(nonEmpty.size() >= 3, "expected at least three non-empty 
polls: " + polledCountsStartAfter);
+        assertEquals(List.of(MAX_PER_POLL, MAX_PER_POLL, AFTER_START_AFTER - 2 
* MAX_PER_POLL),
+                nonEmpty.subList(0, 3),
+                "the first polls should progress through the objects after 
startAfter: " + polledCountsStartAfter);
+        assertTrue(nonEmpty.stream().allMatch(n -> n <= MAX_PER_POLL),
+                "no poll should route more than maxMessagesPerPoll objects: " 
+ polledCountsStartAfter);
+    }
+
+    private static String name(int i) {
+        return String.format("o%03d.txt", i);
+    }
+}
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 a99af27d8f8c..d16e069d9cd8 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
@@ -5189,6 +5189,18 @@ bucket. The poll still fails when none of the listed 
objects could be got, and w
 lists `maxMessagesPerPoll` objects, the next poll now continues after the last 
listed object (before, after the last
 object that was consumed), so an object that cannot be got at the end of a 
page does not hold the listing back.
 
+=== camel-minio - maxMessagesPerPoll now limits a poll to one page
+
+The consumer used `maxMessagesPerPoll` only as the page size of the bucket 
listing, so one poll listed every page of
+the bucket, got the metadata of every object, and routed all of them. A poll 
now lists at most `maxMessagesPerPoll`
+objects (one page, as camel-aws2-s3 does), and the next poll continues after 
the last object the previous poll
+listed. Draining a large bucket therefore takes more polls than before. 
Setting `maxMessagesPerPoll` to 0 or a
+negative number keeps the previous behaviour of listing the whole bucket in 
one poll. Some listed objects are not
+routed (folders when `includeFolders` is false, objects still being uploaded, 
objects that cannot be got), so a poll
+can route fewer objects than the limit, and the next poll continues after 
them. The `startAfter` option now marks
+where each pass through the bucket begins: the polls of a pass continue after 
the last listed object, and a new pass
+starts after `startAfter` again once the listing has been read to its end.
+
 === camel-minio - Server-side encryption options now apply to MinioProducer
 
 The `serverSideEncryption` and `serverSideEncryptionCustomerKey` endpoint 
options now apply to producer operations:

Reply via email to