This is an automated email from the ASF dual-hosted git repository.

oscerd pushed a commit to branch camel-4.18.x
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/camel-4.18.x by this push:
     new 52df8d8b5a0f [backport camel-4.18.x] CAMEL-24082: camel-aws-cloudtrail 
- fix consumer event loss (static cursor, no pagination, window skip) (#24798)
52df8d8b5a0f is described below

commit 52df8d8b5a0f161d8633ed4871ac215d25b9d5df
Author: Andrea Cosentino <[email protected]>
AuthorDate: Thu Jul 16 15:28:34 2026 +0200

    [backport camel-4.18.x] CAMEL-24082: camel-aws-cloudtrail - fix consumer 
event loss (static cursor, no pagination, window skip) (#24798)
    
    Backport of #24726.
    
    Co-authored-by: Claude Fable 5 <[email protected]>
---
 .../camel/catalog/components/aws-cloudtrail.json   |   4 +-
 components/camel-aws/camel-aws-cloudtrail/pom.xml  |   6 ++
 .../component/aws/cloudtrail/aws-cloudtrail.json   |   4 +-
 .../aws/cloudtrail/CloudtrailConfiguration.java    |   8 +-
 .../aws/cloudtrail/CloudtrailConsumer.java         |  87 ++++++++++++-----
 .../aws/cloudtrail/CloudtrailConsumerTest.java     | 106 +++++++++++++++++++++
 .../dsl/AwsCloudtrailComponentBuilderFactory.java  |   6 +-
 .../dsl/CloudtrailEndpointBuilderFactory.java      |  12 ++-
 8 files changed, 198 insertions(+), 35 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/aws-cloudtrail.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/aws-cloudtrail.json
index f8fddd7e50a9..af4a06d45445 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/aws-cloudtrail.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/aws-cloudtrail.json
@@ -27,7 +27,7 @@
     "bridgeErrorHandler": { "index": 0, "kind": "property", "displayName": 
"Bridge Error Handler", "group": "consumer", "label": "consumer", "required": 
false, "type": "boolean", "javaType": "boolean", "deprecated": false, 
"autowired": false, "secret": false, "defaultValue": false, "description": 
"Allows for bridging the consumer to the Camel routing Error Handler, which 
mean any exceptions (if possible) occurred while the Camel consumer is trying 
to pickup incoming messages, or the like [...]
     "configuration": { "index": 1, "kind": "property", "displayName": 
"Configuration", "group": "consumer", "label": "", "required": false, "type": 
"object", "javaType": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"deprecated": false, "autowired": false, "secret": false, "description": 
"Component configuration" },
     "eventSource": { "index": 2, "kind": "property", "displayName": "Event 
Source", "group": "consumer", "label": "", "required": false, "type": "string", 
"javaType": "java.lang.String", "deprecated": false, "autowired": false, 
"secret": false, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "Specify an event source 
to select events" },
-    "maxResults": { "index": 3, "kind": "property", "displayName": "Max 
Results", "group": "consumer", "label": "", "required": false, "type": 
"integer", "javaType": "int", "deprecated": false, "autowired": false, 
"secret": false, "defaultValue": 1, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "Maximum number of 
records that will be fetched in each poll" },
+    "maxResults": { "index": 3, "kind": "property", "displayName": "Max 
Results", "group": "consumer", "label": "", "required": false, "type": 
"integer", "javaType": "int", "deprecated": false, "autowired": false, 
"secret": false, "defaultValue": 50, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "Maximum number of 
records that will be fetched in each lookup page. Each poll drains all pages, 
[...]
     "overrideEndpoint": { "index": 4, "kind": "property", "displayName": 
"Override Endpoint", "group": "consumer", "label": "", "required": false, 
"type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": 
false, "secret": false, "defaultValue": false, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "Set the need for 
overriding the endpoint. This option needs to be used in co [...]
     "region": { "index": 5, "kind": "property", "displayName": "Region", 
"group": "consumer", "label": "", "required": false, "type": "enum", 
"javaType": "java.lang.String", "enum": [ "ap-south-2", "ap-south-1", 
"eu-south-1", "eu-south-2", "us-gov-east-1", "me-central-1", "il-central-1", 
"ca-central-1", "eu-central-1", "us-iso-west-1", "eu-central-2", 
"eu-isoe-west-1", "us-west-1", "us-west-2", "af-south-1", "eu-north-1", 
"eu-west-3", "eu-west-2", "eu-west-1", "ap-northeast-3", "ap-north [...]
     "uriEndpointOverride": { "index": 6, "kind": "property", "displayName": 
"Uri Endpoint Override", "group": "consumer", "label": "", "required": false, 
"type": "string", "javaType": "java.lang.String", "deprecated": false, 
"autowired": false, "secret": false, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "Set the overriding uri 
endpoint. This option needs to be used in combination with ov [...]
@@ -56,7 +56,7 @@
   "properties": {
     "label": { "index": 0, "kind": "path", "displayName": "Label", "group": 
"consumer", "label": "", "required": true, "type": "string", "javaType": 
"java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": 
false, "secret": false, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "A label for indexing 
cloudtrail endpoints" },
     "eventSource": { "index": 1, "kind": "parameter", "displayName": "Event 
Source", "group": "consumer", "label": "", "required": false, "type": "string", 
"javaType": "java.lang.String", "deprecated": false, "autowired": false, 
"secret": false, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "Specify an event source 
to select events" },
-    "maxResults": { "index": 2, "kind": "parameter", "displayName": "Max 
Results", "group": "consumer", "label": "", "required": false, "type": 
"integer", "javaType": "int", "deprecated": false, "autowired": false, 
"secret": false, "defaultValue": 1, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "Maximum number of 
records that will be fetched in each poll" },
+    "maxResults": { "index": 2, "kind": "parameter", "displayName": "Max 
Results", "group": "consumer", "label": "", "required": false, "type": 
"integer", "javaType": "int", "deprecated": false, "autowired": false, 
"secret": false, "defaultValue": 50, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "Maximum number of 
records that will be fetched in each lookup page. Each poll drains all pages 
[...]
     "overrideEndpoint": { "index": 3, "kind": "parameter", "displayName": 
"Override Endpoint", "group": "consumer", "label": "", "required": false, 
"type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": 
false, "secret": false, "defaultValue": false, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "Set the need for 
overriding the endpoint. This option needs to be used in c [...]
     "region": { "index": 4, "kind": "parameter", "displayName": "Region", 
"group": "consumer", "label": "", "required": false, "type": "enum", 
"javaType": "java.lang.String", "enum": [ "ap-south-2", "ap-south-1", 
"eu-south-1", "eu-south-2", "us-gov-east-1", "me-central-1", "il-central-1", 
"ca-central-1", "eu-central-1", "us-iso-west-1", "eu-central-2", 
"eu-isoe-west-1", "us-west-1", "us-west-2", "af-south-1", "eu-north-1", 
"eu-west-3", "eu-west-2", "eu-west-1", "ap-northeast-3", "ap-nort [...]
     "sendEmptyMessageWhenIdle": { "index": 5, "kind": "parameter", 
"displayName": "Send Empty Message When Idle", "group": "consumer", "label": 
"consumer", "required": false, "type": "boolean", "javaType": "boolean", 
"deprecated": false, "autowired": false, "secret": false, "defaultValue": 
false, "description": "If the polling consumer did not poll any files, you can 
enable this option to send an empty message (no body) instead." },
diff --git a/components/camel-aws/camel-aws-cloudtrail/pom.xml 
b/components/camel-aws/camel-aws-cloudtrail/pom.xml
index e8e1905c6ad2..f91039d46eb2 100644
--- a/components/camel-aws/camel-aws-cloudtrail/pom.xml
+++ b/components/camel-aws/camel-aws-cloudtrail/pom.xml
@@ -70,6 +70,12 @@
             <artifactId>camel-test-spring-junit5</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-junit-jupiter</artifactId>
+            <version>${mockito-version}</version>
+            <scope>test</scope>
+        </dependency>
 
         <!-- test infra -->
        <dependency>
diff --git 
a/components/camel-aws/camel-aws-cloudtrail/src/generated/resources/META-INF/org/apache/camel/component/aws/cloudtrail/aws-cloudtrail.json
 
b/components/camel-aws/camel-aws-cloudtrail/src/generated/resources/META-INF/org/apache/camel/component/aws/cloudtrail/aws-cloudtrail.json
index f8fddd7e50a9..af4a06d45445 100644
--- 
a/components/camel-aws/camel-aws-cloudtrail/src/generated/resources/META-INF/org/apache/camel/component/aws/cloudtrail/aws-cloudtrail.json
+++ 
b/components/camel-aws/camel-aws-cloudtrail/src/generated/resources/META-INF/org/apache/camel/component/aws/cloudtrail/aws-cloudtrail.json
@@ -27,7 +27,7 @@
     "bridgeErrorHandler": { "index": 0, "kind": "property", "displayName": 
"Bridge Error Handler", "group": "consumer", "label": "consumer", "required": 
false, "type": "boolean", "javaType": "boolean", "deprecated": false, 
"autowired": false, "secret": false, "defaultValue": false, "description": 
"Allows for bridging the consumer to the Camel routing Error Handler, which 
mean any exceptions (if possible) occurred while the Camel consumer is trying 
to pickup incoming messages, or the like [...]
     "configuration": { "index": 1, "kind": "property", "displayName": 
"Configuration", "group": "consumer", "label": "", "required": false, "type": 
"object", "javaType": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"deprecated": false, "autowired": false, "secret": false, "description": 
"Component configuration" },
     "eventSource": { "index": 2, "kind": "property", "displayName": "Event 
Source", "group": "consumer", "label": "", "required": false, "type": "string", 
"javaType": "java.lang.String", "deprecated": false, "autowired": false, 
"secret": false, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "Specify an event source 
to select events" },
-    "maxResults": { "index": 3, "kind": "property", "displayName": "Max 
Results", "group": "consumer", "label": "", "required": false, "type": 
"integer", "javaType": "int", "deprecated": false, "autowired": false, 
"secret": false, "defaultValue": 1, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "Maximum number of 
records that will be fetched in each poll" },
+    "maxResults": { "index": 3, "kind": "property", "displayName": "Max 
Results", "group": "consumer", "label": "", "required": false, "type": 
"integer", "javaType": "int", "deprecated": false, "autowired": false, 
"secret": false, "defaultValue": 50, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "Maximum number of 
records that will be fetched in each lookup page. Each poll drains all pages, 
[...]
     "overrideEndpoint": { "index": 4, "kind": "property", "displayName": 
"Override Endpoint", "group": "consumer", "label": "", "required": false, 
"type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": 
false, "secret": false, "defaultValue": false, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "Set the need for 
overriding the endpoint. This option needs to be used in co [...]
     "region": { "index": 5, "kind": "property", "displayName": "Region", 
"group": "consumer", "label": "", "required": false, "type": "enum", 
"javaType": "java.lang.String", "enum": [ "ap-south-2", "ap-south-1", 
"eu-south-1", "eu-south-2", "us-gov-east-1", "me-central-1", "il-central-1", 
"ca-central-1", "eu-central-1", "us-iso-west-1", "eu-central-2", 
"eu-isoe-west-1", "us-west-1", "us-west-2", "af-south-1", "eu-north-1", 
"eu-west-3", "eu-west-2", "eu-west-1", "ap-northeast-3", "ap-north [...]
     "uriEndpointOverride": { "index": 6, "kind": "property", "displayName": 
"Uri Endpoint Override", "group": "consumer", "label": "", "required": false, 
"type": "string", "javaType": "java.lang.String", "deprecated": false, 
"autowired": false, "secret": false, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "Set the overriding uri 
endpoint. This option needs to be used in combination with ov [...]
@@ -56,7 +56,7 @@
   "properties": {
     "label": { "index": 0, "kind": "path", "displayName": "Label", "group": 
"consumer", "label": "", "required": true, "type": "string", "javaType": 
"java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": 
false, "secret": false, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "A label for indexing 
cloudtrail endpoints" },
     "eventSource": { "index": 1, "kind": "parameter", "displayName": "Event 
Source", "group": "consumer", "label": "", "required": false, "type": "string", 
"javaType": "java.lang.String", "deprecated": false, "autowired": false, 
"secret": false, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "Specify an event source 
to select events" },
-    "maxResults": { "index": 2, "kind": "parameter", "displayName": "Max 
Results", "group": "consumer", "label": "", "required": false, "type": 
"integer", "javaType": "int", "deprecated": false, "autowired": false, 
"secret": false, "defaultValue": 1, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "Maximum number of 
records that will be fetched in each poll" },
+    "maxResults": { "index": 2, "kind": "parameter", "displayName": "Max 
Results", "group": "consumer", "label": "", "required": false, "type": 
"integer", "javaType": "int", "deprecated": false, "autowired": false, 
"secret": false, "defaultValue": 50, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "Maximum number of 
records that will be fetched in each lookup page. Each poll drains all pages 
[...]
     "overrideEndpoint": { "index": 3, "kind": "parameter", "displayName": 
"Override Endpoint", "group": "consumer", "label": "", "required": false, 
"type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": 
false, "secret": false, "defaultValue": false, "configurationClass": 
"org.apache.camel.component.aws.cloudtrail.CloudtrailConfiguration", 
"configurationField": "configuration", "description": "Set the need for 
overriding the endpoint. This option needs to be used in c [...]
     "region": { "index": 4, "kind": "parameter", "displayName": "Region", 
"group": "consumer", "label": "", "required": false, "type": "enum", 
"javaType": "java.lang.String", "enum": [ "ap-south-2", "ap-south-1", 
"eu-south-1", "eu-south-2", "us-gov-east-1", "me-central-1", "il-central-1", 
"ca-central-1", "eu-central-1", "us-iso-west-1", "eu-central-2", 
"eu-isoe-west-1", "us-west-1", "us-west-2", "af-south-1", "eu-north-1", 
"eu-west-3", "eu-west-2", "eu-west-1", "ap-northeast-3", "ap-nort [...]
     "sendEmptyMessageWhenIdle": { "index": 5, "kind": "parameter", 
"displayName": "Send Empty Message When Idle", "group": "consumer", "label": 
"consumer", "required": false, "type": "boolean", "javaType": "boolean", 
"deprecated": false, "autowired": false, "secret": false, "defaultValue": 
false, "description": "If the polling consumer did not poll any files, you can 
enable this option to send an empty message (no body) instead." },
diff --git 
a/components/camel-aws/camel-aws-cloudtrail/src/main/java/org/apache/camel/component/aws/cloudtrail/CloudtrailConfiguration.java
 
b/components/camel-aws/camel-aws-cloudtrail/src/main/java/org/apache/camel/component/aws/cloudtrail/CloudtrailConfiguration.java
index b705b743379b..5d02bcb3fb6c 100644
--- 
a/components/camel-aws/camel-aws-cloudtrail/src/main/java/org/apache/camel/component/aws/cloudtrail/CloudtrailConfiguration.java
+++ 
b/components/camel-aws/camel-aws-cloudtrail/src/main/java/org/apache/camel/component/aws/cloudtrail/CloudtrailConfiguration.java
@@ -47,9 +47,11 @@ public class CloudtrailConfiguration implements Cloneable, 
AwsCommonConfiguratio
     @UriParam(label = "advanced", description = "Amazon Cloudtrail client to 
use for all requests for this endpoint")
     @Metadata(autowired = true)
     private CloudTrailClient cloudTrailClient;
-    @UriParam(description = "Maximum number of records that will be fetched in 
each poll",
-              defaultValue = "1")
-    private int maxResults = 1;
+    @UriParam(description = "Maximum number of records that will be fetched in 
each lookup page. Each poll drains"
+                            + " all pages, so this controls the page size 
rather than the total per poll. AWS allows"
+                            + " up to 50.",
+              defaultValue = "50")
+    private int maxResults = 50;
     @UriParam(description = "Specify an event source to select events")
     private String eventSource;
     @UriParam(label = "proxy", enums = "HTTP,HTTPS", defaultValue = "HTTPS",
diff --git 
a/components/camel-aws/camel-aws-cloudtrail/src/main/java/org/apache/camel/component/aws/cloudtrail/CloudtrailConsumer.java
 
b/components/camel-aws/camel-aws-cloudtrail/src/main/java/org/apache/camel/component/aws/cloudtrail/CloudtrailConsumer.java
index 2bd5ba58cfbb..5516d85787be 100644
--- 
a/components/camel-aws/camel-aws-cloudtrail/src/main/java/org/apache/camel/component/aws/cloudtrail/CloudtrailConsumer.java
+++ 
b/components/camel-aws/camel-aws-cloudtrail/src/main/java/org/apache/camel/component/aws/cloudtrail/CloudtrailConsumer.java
@@ -20,8 +20,10 @@ import java.nio.charset.StandardCharsets;
 import java.time.Instant;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Queue;
+import java.util.Set;
 
 import org.apache.camel.AsyncCallback;
 import org.apache.camel.Exchange;
@@ -33,40 +35,89 @@ import 
software.amazon.awssdk.services.cloudtrail.CloudTrailClient;
 import software.amazon.awssdk.services.cloudtrail.model.*;
 
 public class CloudtrailConsumer extends ScheduledBatchPollingConsumer {
-    private static Instant lastTime;
+
+    // Cursor for the next lookup window. Kept per-consumer (not static) so 
multiple cloudtrail
+    // routes in the same JVM do not clobber each other's position.
+    private Instant lastTime;
+    // Event ids already delivered at exactly lastTime. startTime is 
inclusive, so these events are
+    // returned again on the next poll and must be filtered to avoid duplicate 
delivery.
+    private final Set<String> lastProcessedEventIds = new HashSet<>();
 
     public CloudtrailConsumer(CloudtrailEndpoint endpoint, Processor 
processor) {
         super(endpoint, processor);
     }
 
     @Override
-    protected int poll() throws Exception {
-        LookupEventsRequest.Builder eventsRequestBuilder
-                = 
LookupEventsRequest.builder().maxResults(getEndpoint().getConfiguration().getMaxResults());
+    protected void doStart() throws Exception {
+        super.doStart();
+        if (lastTime == null) {
+            // Start tailing from the consumer start time rather than 
replaying CloudTrail history.
+            lastTime = Instant.now();
+        }
+    }
 
+    @Override
+    protected int poll() throws Exception {
         List<LookupAttribute> attributes = new ArrayList<>();
         if 
(ObjectHelper.isNotEmpty(getEndpoint().getConfiguration().getEventSource())) {
             LookupAttribute eventSource = 
LookupAttribute.builder().attributeKey(LookupAttributeKey.EVENT_SOURCE)
                     
.attributeValue(getEndpoint().getConfiguration().getEventSource()).build();
             attributes.add(eventSource);
         }
-        if (!attributes.isEmpty()) {
-            eventsRequestBuilder.lookupAttributes(attributes);
-        }
-        if (ObjectHelper.isNotEmpty(lastTime)) {
-            eventsRequestBuilder.startTime(lastTime.plusMillis(1000));
-        }
 
-        LookupEventsResponse response = 
getClient().lookupEvents(eventsRequestBuilder.build());
+        // Drain every page of the current window so no event is silently left 
behind.
+        List<Event> events = new ArrayList<>();
+        String nextToken = null;
+        do {
+            LookupEventsRequest.Builder eventsRequestBuilder
+                    = 
LookupEventsRequest.builder().maxResults(getEndpoint().getConfiguration().getMaxResults());
+            if (!attributes.isEmpty()) {
+                eventsRequestBuilder.lookupAttributes(attributes);
+            }
+            if (ObjectHelper.isNotEmpty(lastTime)) {
+                eventsRequestBuilder.startTime(lastTime);
+            }
+            if (nextToken != null) {
+                eventsRequestBuilder.nextToken(nextToken);
+            }
+
+            LookupEventsResponse response = 
getClient().lookupEvents(eventsRequestBuilder.build());
+            events.addAll(response.events());
+            nextToken = response.nextToken();
+        } while (ObjectHelper.isNotEmpty(nextToken));
 
         // okay we have some response from aws so lets mark the consumer as 
ready
         forceConsumerAsReady();
 
-        if (!response.events().isEmpty()) {
-            lastTime = response.events().get(0).eventTime();
+        // CloudTrail returns events newest-first. Advance the cursor to the 
newest event time and
+        // track the ids seen at that instant, skipping any event already 
delivered on a prior poll.
+        Instant newest = lastTime;
+        Set<String> newestEventIds = new HashSet<>();
+        Queue<Exchange> exchanges = new ArrayDeque<>();
+        for (Event event : events) {
+            if (lastProcessedEventIds.contains(event.eventId())) {
+                continue;
+            }
+            exchanges.add(createExchange(event));
+
+            Instant eventTime = event.eventTime();
+            if (newest == null || eventTime.isAfter(newest)) {
+                newest = eventTime;
+                newestEventIds.clear();
+                newestEventIds.add(event.eventId());
+            } else if (eventTime.equals(newest)) {
+                newestEventIds.add(event.eventId());
+            }
         }
 
-        Queue<Exchange> exchanges = createExchanges(response.events());
+        if (newest != null && newest.equals(lastTime)) {
+            // No event newer than the previous boundary; keep accumulating 
ids at that instant.
+            newestEventIds.addAll(lastProcessedEventIds);
+        }
+        lastTime = newest;
+        lastProcessedEventIds.clear();
+        lastProcessedEventIds.addAll(newestEventIds);
+
         return processBatch(CastUtils.cast(exchanges));
     }
 
@@ -93,14 +144,6 @@ public class CloudtrailConsumer extends 
ScheduledBatchPollingConsumer {
         return (CloudtrailEndpoint) super.getEndpoint();
     }
 
-    private Queue<Exchange> createExchanges(List<Event> events) {
-        Queue<Exchange> exchanges = new ArrayDeque<>();
-        for (Event event : events) {
-            exchanges.add(createExchange(event));
-        }
-        return exchanges;
-    }
-
     protected Exchange createExchange(Event event) {
         Exchange exchange = createExchange(true);
         
exchange.getMessage().setBody(event.cloudTrailEvent().getBytes(StandardCharsets.UTF_8));
diff --git 
a/components/camel-aws/camel-aws-cloudtrail/src/test/java/org/apache/camel/component/aws/cloudtrail/CloudtrailConsumerTest.java
 
b/components/camel-aws/camel-aws-cloudtrail/src/test/java/org/apache/camel/component/aws/cloudtrail/CloudtrailConsumerTest.java
new file mode 100644
index 000000000000..414573afe44f
--- /dev/null
+++ 
b/components/camel-aws/camel-aws-cloudtrail/src/test/java/org/apache/camel/component/aws/cloudtrail/CloudtrailConsumerTest.java
@@ -0,0 +1,106 @@
+/*
+ * 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.aws.cloudtrail;
+
+import java.time.Instant;
+
+import org.apache.camel.AsyncProcessor;
+import org.apache.camel.CamelContext;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import software.amazon.awssdk.services.cloudtrail.CloudTrailClient;
+import software.amazon.awssdk.services.cloudtrail.model.Event;
+import software.amazon.awssdk.services.cloudtrail.model.LookupEventsRequest;
+import software.amazon.awssdk.services.cloudtrail.model.LookupEventsResponse;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+public class CloudtrailConsumerTest {
+
+    @Mock
+    private CloudTrailClient cloudTrailClient;
+    @Mock
+    private AsyncProcessor processor;
+
+    private final CamelContext context = new DefaultCamelContext();
+    private final CloudtrailComponent component = new 
CloudtrailComponent(context);
+
+    private CloudtrailConsumer underTest;
+
+    private static Event event(String id, Instant time) {
+        return 
Event.builder().eventId(id).eventName("name").eventSource("src").username("user")
+                .eventTime(time).cloudTrailEvent("{}").build();
+    }
+
+    @BeforeEach
+    public void setup() throws Exception {
+        component.start();
+        CloudtrailConfiguration configuration = new CloudtrailConfiguration();
+        configuration.setCloudTrailClient(cloudTrailClient);
+        CloudtrailEndpoint endpoint = new 
CloudtrailEndpoint("aws-cloudtrail://test", configuration, component);
+        endpoint.start();
+        underTest = new CloudtrailConsumer(endpoint, processor);
+        underTest.start();
+    }
+
+    @Test
+    public void pollDrainsEveryPage() throws Exception {
+        Instant t = Instant.parse("2099-01-01T00:00:00Z");
+        // Page 1 carries a nextToken; page 2 does not. Both pages must be 
delivered in a single poll.
+        var page1 = LookupEventsResponse.builder()
+                .events(event("e1", t.plusSeconds(3)), event("e2", 
t.plusSeconds(2)))
+                .nextToken("token")
+                .build();
+        var page2 = LookupEventsResponse.builder()
+                .events(event("e3", t.plusSeconds(1)))
+                .build();
+        
when(cloudTrailClient.lookupEvents(any(LookupEventsRequest.class))).thenReturn(page1,
 page2);
+
+        int processed = underTest.poll();
+
+        Assertions.assertEquals(3, processed, "poll() must deliver events from 
every page of the window");
+        verify(cloudTrailClient, 
times(2)).lookupEvents(any(LookupEventsRequest.class));
+    }
+
+    @Test
+    public void pollDoesNotRedeliverBoundaryEventsAcrossPolls() throws 
Exception {
+        Instant t = Instant.parse("2099-01-01T00:00:00Z");
+        // startTime is inclusive, so the newest event of poll #1 is returned 
again in poll #2 and must be skipped.
+        var firstPoll = LookupEventsResponse.builder()
+                .events(event("e2", t.plusSeconds(2)), event("e1", 
t.plusSeconds(1)))
+                .build();
+        var secondPoll = LookupEventsResponse.builder()
+                .events(event("e3", t.plusSeconds(3)), event("e2", 
t.plusSeconds(2)))
+                .build();
+        
when(cloudTrailClient.lookupEvents(any(LookupEventsRequest.class))).thenReturn(firstPoll,
 secondPoll);
+
+        Assertions.assertEquals(2, underTest.poll(), "first poll delivers both 
new events");
+        Assertions.assertEquals(1, underTest.poll(), "second poll must skip 
the already-delivered boundary event e2");
+    }
+}
diff --git 
a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/AwsCloudtrailComponentBuilderFactory.java
 
b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/AwsCloudtrailComponentBuilderFactory.java
index 3da9fff636ec..4f097f796cfb 100644
--- 
a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/AwsCloudtrailComponentBuilderFactory.java
+++ 
b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/AwsCloudtrailComponentBuilderFactory.java
@@ -111,11 +111,13 @@ public interface AwsCloudtrailComponentBuilderFactory {
     
         
         /**
-         * Maximum number of records that will be fetched in each poll.
+         * Maximum number of records that will be fetched in each lookup page.
+         * Each poll drains all pages, so this controls the page size rather
+         * than the total per poll. AWS allows up to 50.
          * 
          * The option is a: &lt;code&gt;int&lt;/code&gt; type.
          * 
-         * Default: 1
+         * Default: 50
          * Group: consumer
          * 
          * @param maxResults the value to set
diff --git 
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/CloudtrailEndpointBuilderFactory.java
 
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/CloudtrailEndpointBuilderFactory.java
index 5cc1666b3d63..3635fe4af48a 100644
--- 
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/CloudtrailEndpointBuilderFactory.java
+++ 
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/CloudtrailEndpointBuilderFactory.java
@@ -59,11 +59,13 @@ public interface CloudtrailEndpointBuilderFactory {
             return this;
         }
         /**
-         * Maximum number of records that will be fetched in each poll.
+         * Maximum number of records that will be fetched in each lookup page.
+         * Each poll drains all pages, so this controls the page size rather
+         * than the total per poll. AWS allows up to 50.
          * 
          * The option is a: <code>int</code> type.
          * 
-         * Default: 1
+         * Default: 50
          * Group: consumer
          * 
          * @param maxResults the value to set
@@ -74,11 +76,13 @@ public interface CloudtrailEndpointBuilderFactory {
             return this;
         }
         /**
-         * Maximum number of records that will be fetched in each poll.
+         * Maximum number of records that will be fetched in each lookup page.
+         * Each poll drains all pages, so this controls the page size rather
+         * than the total per poll. AWS allows up to 50.
          * 
          * The option will be converted to a <code>int</code> type.
          * 
-         * Default: 1
+         * Default: 50
          * Group: consumer
          * 
          * @param maxResults the value to set

Reply via email to