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 043ac9ec84ab CAMEL-25569: camel-aws2-kinesis - Keep the position in a
shard when its iterator expired or the stream is resharded (#27753)
043ac9ec84ab is described below
commit 043ac9ec84abc45869e644a538c598f1ae0ce1b0
Author: allthingssecurity <[email protected]>
AuthorDate: Tue Oct 13 12:24:41 2026 +0530
CAMEL-25569: camel-aws2-kinesis - Keep the position in a shard when its
iterator expired or the stream is resharded (#27753)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../component/aws2/kinesis/Kinesis2Consumer.java | 81 +++++-
.../aws2/kinesis/KinesisConsumerPositionTest.java | 286 +++++++++++++++++++++
2 files changed, 355 insertions(+), 12 deletions(-)
diff --git
a/components/camel-aws/camel-aws2-kinesis/src/main/java/org/apache/camel/component/aws2/kinesis/Kinesis2Consumer.java
b/components/camel-aws/camel-aws2-kinesis/src/main/java/org/apache/camel/component/aws2/kinesis/Kinesis2Consumer.java
index f85bcb7de6ce..fba634587d8d 100644
---
a/components/camel-aws/camel-aws2-kinesis/src/main/java/org/apache/camel/component/aws2/kinesis/Kinesis2Consumer.java
+++
b/components/camel-aws/camel-aws2-kinesis/src/main/java/org/apache/camel/component/aws2/kinesis/Kinesis2Consumer.java
@@ -20,6 +20,7 @@ import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Queue;
@@ -71,6 +72,14 @@ public class Kinesis2Consumer extends
ScheduledBatchPollingConsumer implements R
// Written concurrently from poll()'s parallelStream over the shard list,
so both must be thread-safe.
private final Map<String, String> currentShardIterators = new
ConcurrentHashMap<>();
private final Set<String> warnLogged = ConcurrentHashMap.newKeySet();
+ // The sequence number of the last record routed from each shard: the
position of a new shard iterator when the
+ // cached one has expired. Like the cached iterators it is not cleared in
doStop, and a route that is stopped and
+ // started gets a new consumer.
+ private final Map<String, String> lastSequenceNumbers = new
ConcurrentHashMap<>();
+ // Written by the shard monitor, read by poll(): the shards of the lists
seen so far, and those of them that
+ // appeared after the first list as the child of a listed shard
(resharding).
+ private final Set<String> knownShardIds = ConcurrentHashMap.newKeySet();
+ private final Set<String> childShardIds = ConcurrentHashMap.newKeySet();
private volatile List<Shard> currentShardList = List.of();
private static final String SHARD_MONITOR_EXECUTOR_NAME =
"Kinesis_shard_monitor";
@@ -181,10 +190,12 @@ public class Kinesis2Consumer extends
ScheduledBatchPollingConsumer implements R
throw new RuntimeException(e);
}
- // May cache the last successful sequence number, and pass it to the
- // getRecords request. That way, on the next poll, we start from where
- // we left off, however, I don't know what happens to subsequent
- // exchanges when an earlier exchange fails.
+ // remember the last record of the batch, to continue after it when
the next shard iterator has expired
+ // before it is used (an iterator is valid for 5 minutes)
+ List<Record> records = result.records();
+ if (!records.isEmpty()) {
+ lastSequenceNumbers.put(shard.shardId(),
records.get(records.size() - 1).sequenceNumber());
+ }
updateShardIterator(shard, result.nextShardIterator());
}
@@ -261,16 +272,29 @@ public class Kinesis2Consumer extends
ScheduledBatchPollingConsumer implements R
}
GetShardIteratorRequest.Builder request =
GetShardIteratorRequest.builder()
-
.streamName(getEndpoint().getConfiguration().getStreamName()).shardId(shardId)
-
.shardIteratorType(getEndpoint().getConfiguration().getIteratorType());
+
.streamName(getEndpoint().getConfiguration().getStreamName()).shardId(shardId);
+
+ String lastSequenceNumber = lastSequenceNumbers.get(shardId);
+ if (lastSequenceNumber != null) {
+ // the cached iterator of this shard has expired: continue
after the last record that was routed.
+ // The configured iterator type is where the consumer starts:
it would route the records of the
+ // shard again (TRIM_HORIZON, AT_TIMESTAMP, a sequence number)
or skip the new ones (LATEST)
+
request.shardIteratorType(ShardIteratorType.AFTER_SEQUENCE_NUMBER)
+ .startingSequenceNumber(lastSequenceNumber);
+ } else if (childShardIds.contains(shardId)) {
+ // a shard created by resharding while the consumer was
reading its parent: all its records are new
+ request.shardIteratorType(ShardIteratorType.TRIM_HORIZON);
+ } else {
+
request.shardIteratorType(getEndpoint().getConfiguration().getIteratorType());
- if (hasSequenceNumber()) {
-
request.startingSequenceNumber(getEndpoint().getConfiguration().getSequenceNumber());
- }
+ if (hasSequenceNumber()) {
+
request.startingSequenceNumber(getEndpoint().getConfiguration().getSequenceNumber());
+ }
- if (hasMessageTimestamp()) {
- String messageTimestamp =
getEndpoint().getConfiguration().getMessageTimestamp();
- request.timestamp(parseMessageTimestamp(messageTimestamp));
+ if (hasMessageTimestamp()) {
+ String messageTimestamp =
getEndpoint().getConfiguration().getMessageTimestamp();
+ request.timestamp(parseMessageTimestamp(messageTimestamp));
+ }
}
resume(shardId, request);
@@ -469,6 +493,8 @@ public class Kinesis2Consumer extends
ScheduledBatchPollingConsumer implements R
try {
List<Shard> latestShardList = getShardList(connection);
if (ObjectHelper.isNotEmpty(latestShardList)) {
+ // before the list is published, so that poll() knows the
child shards of the list it reads
+ trackChildShards(latestShardList);
setCurrentShardList(latestShardList);
}
} catch (Exception e) {
@@ -476,6 +502,37 @@ public class Kinesis2Consumer extends
ScheduledBatchPollingConsumer implements R
}
}
+ /**
+ * The shards of the first list are read from the configured iterator
type. A shard that appears in a later list
+ * and descends from a known shard was created by a split or a merge
while the consumer was reading the stream,
+ * so it is read from its first record.
+ */
+ private void trackChildShards(List<Shard> latestShardList) {
+ if (!knownShardIds.isEmpty()) {
+ Set<String> parents = new HashSet<>(knownShardIds);
+ boolean found = true;
+ // repeated, as the list is not required to have a new shard
before the shards created from it
+ while (found) {
+ found = false;
+ for (Shard shard : latestShardList) {
+ if (!parents.contains(shard.shardId()) &&
hasParentIn(shard, parents)) {
+ childShardIds.add(shard.shardId());
+ parents.add(shard.shardId());
+ found = true;
+ }
+ }
+ }
+ }
+ for (Shard shard : latestShardList) {
+ knownShardIds.add(shard.shardId());
+ }
+ }
+
+ private boolean hasParentIn(Shard shard, Set<String> parents) {
+ return shard.parentShardId() != null &&
parents.contains(shard.parentShardId())
+ || shard.adjacentParentShardId() != null &&
parents.contains(shard.adjacentParentShardId());
+ }
+
private List<Shard> getShardList(final KinesisConnection
kinesisConnection) {
List<Shard> allShards = new ArrayList<>();
String nextToken = null;
diff --git
a/components/camel-aws/camel-aws2-kinesis/src/test/java/org/apache/camel/component/aws2/kinesis/KinesisConsumerPositionTest.java
b/components/camel-aws/camel-aws2-kinesis/src/test/java/org/apache/camel/component/aws2/kinesis/KinesisConsumerPositionTest.java
new file mode 100644
index 000000000000..2181bfa0a4b7
--- /dev/null
+++
b/components/camel-aws/camel-aws2-kinesis/src/test/java/org/apache/camel/component/aws2/kinesis/KinesisConsumerPositionTest.java
@@ -0,0 +1,286 @@
+/*
+ * 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.kinesis;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.AsyncCallback;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.AsyncProcessorSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import software.amazon.awssdk.core.SdkBytes;
+import software.amazon.awssdk.services.kinesis.KinesisClient;
+import software.amazon.awssdk.services.kinesis.model.ExpiredIteratorException;
+import software.amazon.awssdk.services.kinesis.model.GetRecordsRequest;
+import software.amazon.awssdk.services.kinesis.model.GetRecordsResponse;
+import software.amazon.awssdk.services.kinesis.model.GetShardIteratorRequest;
+import software.amazon.awssdk.services.kinesis.model.GetShardIteratorResponse;
+import software.amazon.awssdk.services.kinesis.model.ListShardsRequest;
+import software.amazon.awssdk.services.kinesis.model.ListShardsResponse;
+import software.amazon.awssdk.services.kinesis.model.Record;
+import software.amazon.awssdk.services.kinesis.model.Shard;
+import software.amazon.awssdk.services.kinesis.model.ShardIteratorType;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/**
+ * The position of the consumer in a shard when it needs a new shard iterator:
after the cached iterator expired (they
+ * are valid for 5 minutes), and for the shards created by resharding. The
Kinesis client is a small in-memory stream.
+ */
+class KinesisConsumerPositionTest {
+
+ private final CamelContext context = new DefaultCamelContext();
+ private final FakeStream stream = new FakeStream();
+ private final List<String> routed = new CopyOnWriteArrayList<>();
+
+ private Kinesis2Consumer consumer;
+
+ @AfterEach
+ void stop() {
+ if (consumer != null) {
+ consumer.stop();
+ }
+ context.stop();
+ }
+
+ @Test
+ void expiredIteratorContinuesAfterTheLastRoutedRecord() throws Exception {
+ stream.addShard("shard-0", null);
+ stream.put("shard-0", "a");
+ stream.put("shard-0", "b");
+ startConsumer(ShardIteratorType.TRIM_HORIZON, 1);
+
+ assertEquals(2, consumer.poll());
+ assertEquals(List.of("a", "b"), routed);
+
+ // the route was suspended, or the delay is longer than the 5 minutes
an iterator is valid
+ stream.expireIterators();
+ stream.put("shard-0", "c");
+
+ assertEquals(1, consumer.poll(), "only the record that was not routed
yet");
+ assertEquals(List.of("a", "b", "c"), routed, "records routed over both
polls");
+ GetShardIteratorRequest last =
stream.iteratorRequests.get(stream.iteratorRequests.size() - 1);
+ assertEquals(ShardIteratorType.AFTER_SEQUENCE_NUMBER,
last.shardIteratorType());
+ assertEquals("2", last.startingSequenceNumber());
+ }
+
+ @Test
+ void expiredIteratorDoesNotSkipTheRecordsOfTheGapWithLatest() throws
Exception {
+ stream.addShard("shard-0", null);
+ stream.put("shard-0", "old");
+ startConsumer(ShardIteratorType.LATEST, 1);
+
+ assertEquals(0, consumer.poll());
+ stream.put("shard-0", "a");
+ assertEquals(1, consumer.poll());
+
+ stream.expireIterators();
+ stream.put("shard-0", "b");
+
+ assertEquals(1, consumer.poll(), "the record stored while the iterator
expired");
+ assertEquals(List.of("a", "b"), routed);
+ }
+
+ @Test
+ void expiredIteratorOfAShardWithoutRoutedRecordsUsesTheConfiguredType()
throws Exception {
+ stream.addShard("shard-0", null);
+ startConsumer(ShardIteratorType.TRIM_HORIZON, 1);
+
+ assertEquals(0, consumer.poll());
+ stream.expireIterators();
+ stream.put("shard-0", "a");
+
+ assertEquals(1, consumer.poll());
+ assertEquals(List.of("a"), routed);
+ GetShardIteratorRequest last =
stream.iteratorRequests.get(stream.iteratorRequests.size() - 1);
+ assertEquals(ShardIteratorType.TRIM_HORIZON, last.shardIteratorType());
+ }
+
+ @Test
+ void shardCreatedByReshardingIsReadFromItsStartWithLatest() throws
Exception {
+ stream.addShard("shard-0", null);
+ startConsumer(ShardIteratorType.LATEST, 1);
+
+ assertEquals(0, consumer.poll());
+ stream.put("shard-0", "a");
+ assertEquals(1, consumer.poll());
+
+ // the shard is split: it is closed, and the new records go to the
child shards
+ stream.close("shard-0");
+ stream.addShard("shard-1", "shard-0");
+ stream.addShard("shard-2", "shard-0");
+ stream.put("shard-1", "b");
+ stream.put("shard-2", "c");
+ await().atMost(20, TimeUnit.SECONDS).until(() ->
consumer.getCurrentShardList().size() == 3);
+
+ consumer.poll();
+ consumer.poll();
+ List<String> sorted = new ArrayList<>(routed);
+ Collections.sort(sorted);
+ assertEquals(List.of("a", "b", "c"), sorted,
+ "the records stored in the child shards before the consumer
saw these shards");
+ }
+
+ @Test
+ void shardsListedAtStartAreReadFromLatestAlsoWhenTheyAreChildShards()
throws Exception {
+ // an earlier resharding: parent and children are all in the first
shard list
+ stream.addShard("shard-0", null);
+ stream.put("shard-0", "old-0");
+ stream.close("shard-0");
+ stream.addShard("shard-1", "shard-0");
+ stream.put("shard-1", "old-1");
+ startConsumer(ShardIteratorType.LATEST, 2);
+
+ assertEquals(0, consumer.poll());
+ stream.put("shard-1", "new");
+ consumer.poll();
+ assertEquals(List.of("new"), routed);
+ }
+
+ private void startConsumer(ShardIteratorType type, int shards) throws
Exception {
+ Kinesis2Component component = new Kinesis2Component(context);
+ component.start();
+
+ Kinesis2Configuration configuration = new Kinesis2Configuration();
+ configuration.setAmazonKinesisClient(stream.client());
+ configuration.setIteratorType(type);
+ configuration.setStreamName("stream");
+ configuration.setMaxResultsPerRequest(10);
+ configuration.setShardMonitorInterval(50);
+
+ Kinesis2Endpoint endpoint = new
Kinesis2Endpoint("aws2-kinesis:stream", configuration, component);
+ endpoint.start();
+ consumer = new Kinesis2Consumer(endpoint, new AsyncProcessorSupport() {
+ @Override
+ public boolean process(Exchange exchange, AsyncCallback callback) {
+ routed.add(exchange.getMessage().getBody(String.class));
+ callback.done(true);
+ return true;
+ }
+ });
+ consumer.setConnection(component.getConnection());
+ // the test calls poll()
+ consumer.setStartScheduler(false);
+ consumer.start();
+ await().atMost(20, TimeUnit.SECONDS).until(() ->
consumer.getCurrentShardList().size() == shards);
+ }
+
+ /**
+ * Shards with records, and shard iterators that point between two records
of a shard and can expire.
+ */
+ private static final class FakeStream {
+ final List<GetShardIteratorRequest> iteratorRequests = new
CopyOnWriteArrayList<>();
+ private final List<Shard> shards = new ArrayList<>();
+ private final Map<String, List<Record>> records = new HashMap<>();
+ private final Set<String> closed = new HashSet<>();
+ private final Map<String, String> iteratorShard = new HashMap<>();
+ private final Map<String, Integer> iteratorPosition = new HashMap<>();
+ private final Set<String> expired = new HashSet<>();
+ private int iterators;
+
+ synchronized void addShard(String shardId, String parentShardId) {
+
shards.add(Shard.builder().shardId(shardId).parentShardId(parentShardId).build());
+ records.put(shardId, new ArrayList<>());
+ }
+
+ synchronized void put(String shardId, String body) {
+ List<Record> list = records.get(shardId);
+
list.add(Record.builder().sequenceNumber(String.valueOf(list.size() + 1))
+ .data(SdkBytes.fromString(body,
StandardCharsets.UTF_8)).build());
+ }
+
+ synchronized void close(String shardId) {
+ closed.add(shardId);
+ }
+
+ synchronized void expireIterators() {
+ expired.addAll(iteratorShard.keySet());
+ }
+
+ KinesisClient client() {
+ KinesisClient client = Mockito.mock(KinesisClient.class);
+ when(client.listShards(any(ListShardsRequest.class)))
+ .thenAnswer(inv -> listShards());
+ when(client.getShardIterator(any(GetShardIteratorRequest.class)))
+ .thenAnswer(inv -> getShardIterator(inv.getArgument(0)));
+ when(client.getRecords(any(GetRecordsRequest.class)))
+ .thenAnswer(inv -> getRecords(inv.getArgument(0)));
+ return client;
+ }
+
+ private synchronized ListShardsResponse listShards() {
+ return ListShardsResponse.builder().shards(new
ArrayList<>(shards)).build();
+ }
+
+ private synchronized GetShardIteratorResponse
getShardIterator(GetShardIteratorRequest request) {
+ iteratorRequests.add(request);
+ List<Record> list = records.get(request.shardId());
+ int position;
+ switch (request.shardIteratorType()) {
+ case TRIM_HORIZON:
+ position = 0;
+ break;
+ case LATEST:
+ position = list.size();
+ break;
+ case AFTER_SEQUENCE_NUMBER:
+ position =
Integer.parseInt(request.startingSequenceNumber());
+ break;
+ default:
+ throw new IllegalArgumentException("Not expected in this
test: " + request.shardIteratorType());
+ }
+ return
GetShardIteratorResponse.builder().shardIterator(newIterator(request.shardId(),
position)).build();
+ }
+
+ private synchronized GetRecordsResponse getRecords(GetRecordsRequest
request) {
+ String iterator = request.shardIterator();
+ if (expired.contains(iterator)) {
+ throw ExpiredIteratorException.builder().message("Iterator
expired").build();
+ }
+ String shardId = iteratorShard.get(iterator);
+ List<Record> list = records.get(shardId);
+ int from = iteratorPosition.get(iterator);
+ int to = Math.min(list.size(), from + request.limit());
+ String next = closed.contains(shardId) && to == list.size() ? null
: newIterator(shardId, to);
+ return GetRecordsResponse.builder().records(new
ArrayList<>(list.subList(from, to))).nextShardIterator(next)
+ .build();
+ }
+
+ private String newIterator(String shardId, int position) {
+ String iterator = "iterator-" + (++iterators);
+ iteratorShard.put(iterator, shardId);
+ iteratorPosition.put(iterator, position);
+ return iterator;
+ }
+ }
+}