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 eb8ecb1b4543 CAMEL-25741: camel-aws2-ddb - aws2-ddbstream reads every 
shard to its end and starts from streamIteratorType only once (#28068)
eb8ecb1b4543 is described below

commit eb8ecb1b45434e0a88acd453083516543101c9c1
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Oct 19 17:00:46 2026 +0530

    CAMEL-25741: camel-aws2-ddb - aws2-ddbstream reads every shard to its end 
and starts from streamIteratorType only once (#28068)
    
    * CAMEL-25741: camel-aws2-ddb - aws2-ddbstream reads every shard to its end 
and starts from streamIteratorType only once
    * CAMEL-25741: camel-aws2-ddb - simpler wording of the upgrade-guide entry
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../aws2/ddbstream/ShardIteratorHandler.java       |  45 ++-
 .../Ddb2StreamConsumerShardRotationTest.java       | 326 +++++++++++++++++++++
 .../aws2/ddbstream/ShardIteratorHandlerTest.java   |   5 +
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |   7 +
 4 files changed, 359 insertions(+), 24 deletions(-)

diff --git 
a/components/camel-aws/camel-aws2-ddb/src/main/java/org/apache/camel/component/aws2/ddbstream/ShardIteratorHandler.java
 
b/components/camel-aws/camel-aws2-ddb/src/main/java/org/apache/camel/component/aws2/ddbstream/ShardIteratorHandler.java
index cb32f9398fc0..265fcfb3d175 100644
--- 
a/components/camel-aws/camel-aws2-ddb/src/main/java/org/apache/camel/component/aws2/ddbstream/ShardIteratorHandler.java
+++ 
b/components/camel-aws/camel-aws2-ddb/src/main/java/org/apache/camel/component/aws2/ddbstream/ShardIteratorHandler.java
@@ -21,7 +21,6 @@ import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.Set;
 
 import 
org.apache.camel.component.aws2.ddbstream.Ddb2StreamConfiguration.StreamIteratorType;
@@ -44,6 +43,7 @@ class ShardIteratorHandler {
     private final Set<String> pendingClosedShards = new HashSet<>();
 
     private String streamArn;
+    private boolean initialized;
     private Map<String, String> currentShardIterators = new HashMap<>();
 
     ShardIteratorHandler(Ddb2StreamEndpoint endpoint) {
@@ -54,41 +54,38 @@ class ShardIteratorHandler {
         if (streamArn == null) {
             streamArn = getStreamArn();
         }
-        // Either return cached ones or get new ones via GetShardIterator 
requests.
-        if (currentShardIterators.isEmpty()) {
+        // The configured iterator type is the start position of the consumer: 
it is used once, and not again when
+        // every shard that was being read has closed (that would read the 
stream again from its start, or skip the
+        // records of the new shards with FROM_LATEST)
+        if (!initialized) {
             shardTree.populate(describeStreamPaginated());
 
             StreamIteratorType streamIteratorType = 
getEndpoint().getConfiguration().getStreamIteratorType();
             currentShardIterators = 
getCurrentShardIterators(streamIteratorType);
-        } else {
-            // Refresh the shard tree when any tracked shard has closed, so 
that
-            // children created by DynamoDB's shard rotation become visible.
-            if (!pendingClosedShards.isEmpty()) {
-                shardTree.populate(describeStreamPaginated());
-            }
-
-            Map<String, String> childShardIterators = new HashMap<>();
-            for (Entry<String, String> currentShardIterator : 
currentShardIterators.entrySet()) {
-                List<Shard> children = 
shardTree.getChildren(currentShardIterator.getKey());
-                if (children.isEmpty()) { // This is still an active leaf 
shard, reuse it.
-                    childShardIterators.put(currentShardIterator.getKey(), 
currentShardIterator.getValue());
-                } else {
-                    for (Shard child : children) { // Inactive shard, move 
down to its children.
-                        String shardIterator = 
getShardIterator(child.shardId(), ShardIteratorType.TRIM_HORIZON);
-                        childShardIterators.put(child.shardId(), 
shardIterator);
-                    }
-                }
-            }
+            // no shard to read yet: try again at the next poll
+            initialized = !currentShardIterators.isEmpty();
+        } else if (!pendingClosedShards.isEmpty()) {
+            // A shard is left only when it was read to its end 
(updateShardIterator got no next iterator): a closed
+            // shard, which already has children, can still hold records that 
were not read. Refresh the shard tree,
+            // so that children created by DynamoDB's shard rotation become 
visible, and read them from their start.
+            shardTree.populate(describeStreamPaginated());
 
+            Map<String, String> childShardIterators = new 
HashMap<>(currentShardIterators);
+            Set<String> followed = new HashSet<>();
             for (String closedShardId : pendingClosedShards) {
-                for (Shard child : shardTree.getChildren(closedShardId)) {
+                List<Shard> children = shardTree.getChildren(closedShardId);
+                for (Shard child : children) {
                     if (!childShardIterators.containsKey(child.shardId())) {
                         String shardIterator = 
getShardIterator(child.shardId(), ShardIteratorType.TRIM_HORIZON);
                         childShardIterators.put(child.shardId(), 
shardIterator);
                     }
                 }
+                if (!children.isEmpty()) {
+                    followed.add(closedShardId);
+                }
             }
-            pendingClosedShards.clear();
+            // a shard whose children are not listed yet is looked up again at 
the next poll
+            pendingClosedShards.removeAll(followed);
 
             currentShardIterators = childShardIterators;
         }
diff --git 
a/components/camel-aws/camel-aws2-ddb/src/test/java/org/apache/camel/component/aws2/ddbstream/Ddb2StreamConsumerShardRotationTest.java
 
b/components/camel-aws/camel-aws2-ddb/src/test/java/org/apache/camel/component/aws2/ddbstream/Ddb2StreamConsumerShardRotationTest.java
new file mode 100644
index 000000000000..a420b9583c49
--- /dev/null
+++ 
b/components/camel-aws/camel-aws2-ddb/src/test/java/org/apache/camel/component/aws2/ddbstream/Ddb2StreamConsumerShardRotationTest.java
@@ -0,0 +1,326 @@
+/*
+ * 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.aws2.ddbstream;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.services.dynamodb.model.DescribeStreamRequest;
+import software.amazon.awssdk.services.dynamodb.model.DescribeStreamResponse;
+import software.amazon.awssdk.services.dynamodb.model.GetRecordsRequest;
+import software.amazon.awssdk.services.dynamodb.model.GetRecordsResponse;
+import software.amazon.awssdk.services.dynamodb.model.GetShardIteratorRequest;
+import software.amazon.awssdk.services.dynamodb.model.GetShardIteratorResponse;
+import software.amazon.awssdk.services.dynamodb.model.ListStreamsRequest;
+import software.amazon.awssdk.services.dynamodb.model.ListStreamsResponse;
+import software.amazon.awssdk.services.dynamodb.model.OperationType;
+import software.amazon.awssdk.services.dynamodb.model.Record;
+import software.amazon.awssdk.services.dynamodb.model.SequenceNumberRange;
+import software.amazon.awssdk.services.dynamodb.model.Shard;
+import software.amazon.awssdk.services.dynamodb.model.Stream;
+import software.amazon.awssdk.services.dynamodb.model.StreamDescription;
+import software.amazon.awssdk.services.dynamodb.model.StreamRecord;
+import software.amazon.awssdk.services.dynamodb.streams.DynamoDbStreamsClient;
+import 
software.amazon.awssdk.services.dynamodb.streams.DynamoDbStreamsServiceClientConfiguration;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * DynamoDB closes the shards of a stream and creates child shards (shard 
rotation, about every four hours, and splits).
+ * A closed shard keeps its records until they are read: the consumer must 
read every shard to its end before it moves
+ * to the children, and the configured iterator type is only the start 
position of the consumer.
+ */
+class Ddb2StreamConsumerShardRotationTest extends CamelTestSupport {
+
+    @BindToRegistry("ddbStreams")
+    final FakeStreamsClient client = new FakeStreamsClient();
+
+    final List<String> routed = new CopyOnWriteArrayList<>();
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                
from("aws2-ddbstream://table?amazonDynamoDbStreamsClient=#ddbStreams&streamIteratorType=FROM_START"
+                     + "&maxResultsPerRequest=2&initialDelay=0&delay=10")
+                        .routeId("start").autoStartup(false)
+                        .to("direct:records");
+
+                
from("aws2-ddbstream://table?amazonDynamoDbStreamsClient=#ddbStreams&streamIteratorType=FROM_LATEST"
+                     + "&maxResultsPerRequest=2&initialDelay=0&delay=10")
+                        .routeId("latest").autoStartup(false)
+                        .to("direct:records");
+
+                from("direct:records")
+                        .process(exchange -> routed.add(
+                                
exchange.getMessage().getHeader(Ddb2StreamConstants.SEQUENCE_NUMBER, 
String.class)));
+            }
+        };
+    }
+
+    @Test
+    void closedShardWithChildrenIsReadToItsEndFromStart() throws Exception {
+        // a stream of a few hours: shard-0 (5 records) rotated into shard-1, 
which is open
+        client.shard("shard-0", null).put("1", "2", "3", "4", "5").close();
+        client.shard("shard-1", "shard-0").put("6");
+
+        context.getRouteController().startRoute("start");
+
+        awaitRoutedAndNothingMore(List.of("1", "2", "3", "4", "5", "6"));
+    }
+
+    @Test
+    void streamIsNotReadAgainWhenItsOnlyShardIsReadToItsEnd() throws Exception 
{
+        // one partition: the only root shard is closed, its child is open
+        client.shard("shard-0", null).put("1", "2").close();
+        client.shard("shard-1", "shard-0").put("3");
+
+        context.getRouteController().startRoute("start");
+
+        awaitRoutedAndNothingMore(List.of("1", "2", "3"));
+    }
+
+    @Test
+    void recordsOfTheChildOfTheOnlyShardAreRoutedFromLatest() throws Exception 
{
+        FakeShard parent = client.shard("shard-0", null).put("1");
+
+        context.getRouteController().startRoute("latest");
+        // the consumer has its LATEST position in shard-0
+        await().atMost(20, TimeUnit.SECONDS).until(() -> 
client.recordRequests() >= 1);
+
+        // shard rotation: shard-0 gets one more record and closes; the next 
writes go to the new shard-1, and the
+        // first of them is written before the consumer has read shard-0 to 
its end
+        FakeShard child = client.shard("shard-1", "shard-0");
+        client.onEndOfShard = closed -> {
+            if (closed == parent) {
+                child.put("4");
+            }
+        };
+        parent.put("2").close();
+
+        awaitRoutedAndNothingMore(List.of("2", "4"));
+        child.put("5");
+        awaitRoutedAndNothingMore(List.of("2", "4", "5"));
+    }
+
+    @Test
+    void childThatIsListedAfterItsParentWasReadToItsEndIsRead() throws 
Exception {
+        FakeShard parent = client.shard("shard-0", null).put("1");
+
+        context.getRouteController().startRoute("latest");
+        await().atMost(20, TimeUnit.SECONDS).until(() -> 
client.recordRequests() >= 1);
+
+        // the shard closes, and DescribeStream does not list its child yet 
when the consumer reads its end
+        parent.put("2").close();
+        await().atMost(20, TimeUnit.SECONDS).until(() -> client.endsRead() >= 
1);
+        int describes = client.describeCalls();
+        await().atMost(20, TimeUnit.SECONDS).until(() -> 
client.describeCalls() >= describes + 2);
+        client.shard("shard-1", "shard-0").put("3", "4");
+
+        awaitRoutedAndNothingMore(List.of("2", "3", "4"));
+    }
+
+    @Test
+    void shardThatClosedWhileAnotherShardIsReadToItsEndIsReadToItsEnd() throws 
Exception {
+        // two partitions
+        FakeShard a = client.shard("shard-a", null);
+        FakeShard b = client.shard("shard-b", null);
+
+        context.getRouteController().startRoute("latest");
+        await().atMost(20, TimeUnit.SECONDS).until(() -> 
client.recordRequests() >= 2);
+
+        // both shards rotate (the children are listed before their parents 
close, so that the consumer finds them
+        // whenever it reads the end of a parent); shard-b has more records 
than one GetRecords response returns
+        client.shard("shard-a1", "shard-a").put("8");
+        client.shard("shard-b1", "shard-b").put("9");
+        a.put("1").close();
+        b.put("2", "3", "4", "5", "6", "7").close();
+
+        awaitRoutedAndNothingMore(List.of("1", "2", "3", "4", "5", "6", "7", 
"8", "9"));
+    }
+
+    private void awaitRoutedAndNothingMore(List<String> expected) {
+        await().atMost(20, TimeUnit.SECONDS)
+                .untilAsserted(() -> assertEquals(expected.size(), 
routed.size(), "routed so far: " + shown()));
+        // a few more polls of every shard: nothing is routed twice
+        int requests = client.recordRequests();
+        await().atMost(20, TimeUnit.SECONDS).until(() -> 
client.recordRequests() >= requests + 10);
+        List<String> sorted = new ArrayList<>(routed);
+        sorted.sort((x, y) -> Integer.compare(Integer.parseInt(x), 
Integer.parseInt(y)));
+        assertEquals(expected, sorted, "each record routed once, in any order 
across shards: " + shown());
+    }
+
+    private List<String> shown() {
+        List<String> copy = new ArrayList<>(routed);
+        return copy.size() > 20 ? copy.subList(0, 20) : copy;
+    }
+
+    final class FakeShard {
+        final String id;
+        final String parentId;
+        final List<Record> records = new ArrayList<>();
+        volatile boolean closed;
+
+        FakeShard(String id, String parentId) {
+            this.id = id;
+            this.parentId = parentId;
+        }
+
+        FakeShard put(String... sequenceNumbers) {
+            synchronized (client) {
+                for (String sequenceNumber : sequenceNumbers) {
+                    records.add(Record.builder().eventID("event-" + 
sequenceNumber).eventName(OperationType.INSERT)
+                            
.dynamodb(StreamRecord.builder().sequenceNumber(sequenceNumber).build()).build());
+                }
+            }
+            return this;
+        }
+
+        FakeShard close() {
+            closed = true;
+            return this;
+        }
+    }
+
+    /**
+     * A stream whose shards have records; an iterator is a position in a 
shard. GetRecords on a closed shard that
+     * reaches its end returns no next iterator.
+     */
+    final class FakeStreamsClient implements DynamoDbStreamsClient {
+        private final Map<String, FakeShard> shards = new LinkedHashMap<>();
+        private final Map<String, Object[]> iterators = new HashMap<>();
+        volatile Consumer<FakeShard> onEndOfShard;
+        private int recordRequests;
+        private int describeCalls;
+        private int endsRead;
+
+        synchronized FakeShard shard(String id, String parentId) {
+            FakeShard shard = new FakeShard(id, parentId);
+            shards.put(id, shard);
+            return shard;
+        }
+
+        synchronized int recordRequests() {
+            return recordRequests;
+        }
+
+        synchronized int describeCalls() {
+            return describeCalls;
+        }
+
+        synchronized int endsRead() {
+            return endsRead;
+        }
+
+        @Override
+        public ListStreamsResponse listStreams(ListStreamsRequest request) {
+            return 
ListStreamsResponse.builder().streams(Stream.builder().streamArn("arn:stream").build()).build();
+        }
+
+        @Override
+        public synchronized DescribeStreamResponse 
describeStream(DescribeStreamRequest request) {
+            describeCalls++;
+            List<Shard> answer = new ArrayList<>();
+            for (FakeShard shard : shards.values()) {
+                
answer.add(Shard.builder().shardId(shard.id).parentShardId(shard.parentId)
+                        
.sequenceNumberRange(SequenceNumberRange.builder().startingSequenceNumber("1")
+                                // a closed shard has an ending sequence number
+                                .endingSequenceNumber(shard.closed ? "1000" : 
null).build())
+                        .build());
+            }
+            return 
DescribeStreamResponse.builder().streamDescription(StreamDescription.builder().shards(answer).build())
+                    .build();
+        }
+
+        @Override
+        public synchronized GetShardIteratorResponse 
getShardIterator(GetShardIteratorRequest request) {
+            FakeShard shard = shards.get(request.shardId());
+            int position;
+            switch (request.shardIteratorType()) {
+                case TRIM_HORIZON:
+                    position = 0;
+                    break;
+                case LATEST:
+                    position = shard.records.size();
+                    break;
+                default:
+                    throw new IllegalArgumentException("Not expected in this 
test: " + request.shardIteratorType());
+            }
+            return 
GetShardIteratorResponse.builder().shardIterator(newIterator(shard, 
position)).build();
+        }
+
+        @Override
+        public GetRecordsResponse getRecords(GetRecordsRequest request) {
+            FakeShard endOfShard = null;
+            GetRecordsResponse response;
+            synchronized (this) {
+                recordRequests++;
+                Object[] iterator = iterators.get(request.shardIterator());
+                FakeShard shard = (FakeShard) iterator[0];
+                int from = (Integer) iterator[1];
+                int to = Math.min(shard.records.size(), from + 
request.limit());
+                String next = null;
+                if (shard.closed && to == shard.records.size()) {
+                    endOfShard = shard;
+                    endsRead++;
+                } else {
+                    next = newIterator(shard, to);
+                }
+                response = GetRecordsResponse.builder().records(new 
ArrayList<>(shard.records.subList(from, to)))
+                        .nextShardIterator(next).build();
+            }
+            Consumer<FakeShard> hook = onEndOfShard;
+            if (endOfShard != null && hook != null) {
+                hook.accept(endOfShard);
+            }
+            return response;
+        }
+
+        private String newIterator(FakeShard shard, int position) {
+            String iterator = "iterator-" + (iterators.size() + 1);
+            iterators.put(iterator, new Object[] { shard, position });
+            return iterator;
+        }
+
+        @Override
+        public DynamoDbStreamsServiceClientConfiguration 
serviceClientConfiguration() {
+            return null;
+        }
+
+        @Override
+        public String serviceName() {
+            return DynamoDbStreamsClient.SERVICE_NAME;
+        }
+
+        @Override
+        public void close() {
+            // nothing to close
+        }
+    }
+}
diff --git 
a/components/camel-aws/camel-aws2-ddb/src/test/java/org/apache/camel/component/aws2/ddbstream/ShardIteratorHandlerTest.java
 
b/components/camel-aws/camel-aws2-ddb/src/test/java/org/apache/camel/component/aws2/ddbstream/ShardIteratorHandlerTest.java
index ef370fbc76eb..adeec23b96e1 100644
--- 
a/components/camel-aws/camel-aws2-ddb/src/test/java/org/apache/camel/component/aws2/ddbstream/ShardIteratorHandlerTest.java
+++ 
b/components/camel-aws/camel-aws2-ddb/src/test/java/org/apache/camel/component/aws2/ddbstream/ShardIteratorHandlerTest.java
@@ -94,10 +94,15 @@ class ShardIteratorHandlerTest extends CamelTestSupport {
         endpoint.doStart();
 
         assertEquals(Collections.singletonMap(SHARD_0.shardId(), 
SHARD_ITERATOR_0), underTest.getShardIterators());
+        // a closed shard is left for its children once it was read to its 
end, not before
+        assertEquals(Collections.singletonMap(SHARD_0.shardId(), 
SHARD_ITERATOR_0), underTest.getShardIterators());
+        underTest.updateShardIterator(SHARD_0.shardId(), null);
         Map<String, String> expectedShardIterators1 = new HashMap<>();
         expectedShardIterators1.put(SHARD_1.shardId(), SHARD_ITERATOR_1);
         expectedShardIterators1.put(SHARD_2.shardId(), SHARD_ITERATOR_2);
         assertEquals(expectedShardIterators1, underTest.getShardIterators());
+        underTest.updateShardIterator(SHARD_1.shardId(), null);
+        underTest.updateShardIterator(SHARD_2.shardId(), null);
         Map<String, String> expectedShardIterators2 = new HashMap<>();
         expectedShardIterators2.put(SHARD_3.shardId(), SHARD_ITERATOR_3);
         expectedShardIterators2.put(SHARD_4.shardId(), SHARD_ITERATOR_4);
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 efc53903f469..08ed3d96753b 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
@@ -828,6 +828,13 @@ now resolves to no value, as it already did without a 
field, so its default valu
 (`{{?hashicorp:secret:missing#field}}`) applies. Before, without a default 
value, it failed with a
 `NullPointerException`.
 
+=== camel-aws2-ddb - aws2-ddbstream reads every shard to its end
+
+The `aws2-ddbstream` consumer now reads a closed shard to its end before it 
reads its child shards, and uses
+`streamIteratorType` only for its start position. Before, records left in a 
closed shard could be skipped, and after a
+shard rotation the consumer started again from `streamIteratorType`: with 
`FROM_START` it routed records of the stream
+again, and with `FROM_LATEST` it skipped records already written to the new 
shards.
+
 === camel-bindy - a number field without precision keeps all its decimals when 
marshalled
 
 A `BigDecimal`, `Double` or `Float` field whose `@DataField` (or 
`@KeyValuePairField`) has no `precision` and no

Reply via email to