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

oscerd 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 75695dfe2418 CAMEL-24080: camel-aws2-kinesis - make consumer shard 
state thread-safe for multi-shard streams (#24717)
75695dfe2418 is described below

commit 75695dfe24182b6b5ffcf23a0d4545e67ff89801
Author: Andrea Cosentino <[email protected]>
AuthorDate: Wed Jul 15 15:36:53 2026 +0200

    CAMEL-24080: camel-aws2-kinesis - make consumer shard state thread-safe for 
multi-shard streams (#24717)
    
    Use ConcurrentHashMap / ConcurrentHashMap.newKeySet() for the shard state 
written from poll()'s parallelStream, and accumulate the poll count with 
addAndGet(). A sentinel replaces the null closed-shard marker 
(ConcurrentHashMap forbids null values). Adds KinesisConsumerMultiShardTest.
    
    Co-authored-by: Claude Fable 5 <[email protected]>
---
 .../component/aws2/kinesis/Kinesis2Consumer.java   |  24 +++--
 .../kinesis/KinesisConsumerMultiShardTest.java     | 119 +++++++++++++++++++++
 2 files changed, 134 insertions(+), 9 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 6f836492ecef..c08263fd6b13 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
@@ -19,12 +19,11 @@ package org.apache.camel.component.aws2.kinesis;
 import java.math.BigDecimal;
 import java.time.Instant;
 import java.util.ArrayDeque;
-import java.util.HashMap;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Queue;
 import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
@@ -60,11 +59,17 @@ public class Kinesis2Consumer extends 
ScheduledBatchPollingConsumer implements R
     private static final String UNIX_TIMESTAMP_MILLIS_REGEX = "^\\d{1,13}$";
     private static final String UNIX_TIMESTAMP_DOUBLE_REGEX = 
"^[-+]?\\d+(\\.\\d+)?([eE][-+]?\\d+)?$";
 
+    // Sentinel stored in currentShardIterators to mark a shard as closed. A 
closed shard has no next
+    // shard iterator (Kinesis returns null); ConcurrentHashMap forbids null 
values, so an empty
+    // string is used instead and is treated as "no iterator" by the 
ObjectHelper.isEmpty checks.
+    private static final String CLOSED_SHARD_ITERATOR = "";
+
     private KinesisConnection connection;
     private ResumeStrategy resumeStrategy;
 
-    private final Map<String, String> currentShardIterators = new HashMap<>();
-    private final Set<String> warnLogged = new HashSet<>();
+    // 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();
 
     private volatile List<Shard> currentShardList = List.of();
     private static final String SHARD_MONITOR_EXECUTOR_NAME = 
"Kinesis_shard_monitor";
@@ -84,7 +89,7 @@ public class Kinesis2Consumer extends 
ScheduledBatchPollingConsumer implements R
     }
 
     public boolean isShardClosed(String shardId) {
-        return currentShardIterators.get(shardId) == null && 
currentShardIterators.containsKey(shardId);
+        return ObjectHelper.isEmpty(currentShardIterators.get(shardId)) && 
currentShardIterators.containsKey(shardId);
     }
 
     @Override
@@ -164,8 +169,7 @@ public class Kinesis2Consumer extends 
ScheduledBatchPollingConsumer implements R
         }
 
         if (ObjectHelper.isEmpty(shardIterator)) {
-            // Unable to get an interator so shard must be closed
-            processedExchangeCount.set(0);
+            // Unable to get an iterator so shard must be closed; contribute 
nothing to the poll count
             return;
         }
 
@@ -198,7 +202,7 @@ public class Kinesis2Consumer extends 
ScheduledBatchPollingConsumer implements R
 
         try {
             Queue<Exchange> exchanges = createExchanges(shard, 
result.records());
-            
processedExchangeCount.getAndSet(processBatch(CastUtils.cast(exchanges)));
+            
processedExchangeCount.addAndGet(processBatch(CastUtils.cast(exchanges)));
         } catch (Exception e) {
             throw new RuntimeException(e);
         }
@@ -211,7 +215,9 @@ public class Kinesis2Consumer extends 
ScheduledBatchPollingConsumer implements R
     }
 
     private void updateShardIterator(Shard shard, String nextShardIterator) {
-        currentShardIterators.put(shard.shardId(), nextShardIterator);
+        // A null next shard iterator means the shard is now closed; store the 
sentinel rather than
+        // null (ConcurrentHashMap forbids null values) so isShardClosed keeps 
detecting it.
+        currentShardIterators.put(shard.shardId(), nextShardIterator == null ? 
CLOSED_SHARD_ITERATOR : nextShardIterator);
     }
 
     @Override
diff --git 
a/components/camel-aws/camel-aws2-kinesis/src/test/java/org/apache/camel/component/aws2/kinesis/KinesisConsumerMultiShardTest.java
 
b/components/camel-aws/camel-aws2-kinesis/src/test/java/org/apache/camel/component/aws2/kinesis/KinesisConsumerMultiShardTest.java
new file mode 100644
index 000000000000..64184b641ad1
--- /dev/null
+++ 
b/components/camel-aws/camel-aws2-kinesis/src/test/java/org/apache/camel/component/aws2/kinesis/KinesisConsumerMultiShardTest.java
@@ -0,0 +1,119 @@
+/*
+ * 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.Charset;
+import java.util.ArrayList;
+import java.util.concurrent.TimeUnit;
+
+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.core.SdkBytes;
+import software.amazon.awssdk.services.kinesis.KinesisClient;
+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.SequenceNumberRange;
+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.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies that polling a stream with more than one open shard is handled 
correctly: every shard is fetched and the
+ * number of processed exchanges returned by {@link Kinesis2Consumer#poll()} 
is the sum across all shards rather than
+ * the count of whichever shard happened to finish last.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+public class KinesisConsumerMultiShardTest {
+    @Mock
+    private KinesisClient kinesisClient;
+    @Mock
+    private AsyncProcessor processor;
+
+    private final CamelContext context = new DefaultCamelContext();
+    private final Kinesis2Component component = new Kinesis2Component(context);
+
+    private Kinesis2Consumer underTest;
+
+    @BeforeEach
+    public void setup() {
+        SequenceNumberRange range = 
SequenceNumberRange.builder().endingSequenceNumber("2").build();
+        ArrayList<Shard> shardList = new ArrayList<>();
+        
shardList.add(Shard.builder().shardId("shardId1").sequenceNumberRange(range).build());
+        
shardList.add(Shard.builder().shardId("shardId2").sequenceNumberRange(range).build());
+
+        // Two records per getRecords call, with a non-null next iterator so 
both shards stay open across the poll.
+        var records = GetRecordsResponse.builder()
+                .nextShardIterator("nextIterator")
+                .records(
+                        Record.builder().sequenceNumber("1")
+                                .data(SdkBytes.fromString("Hello", 
Charset.defaultCharset()))
+                                .build(),
+                        Record.builder().sequenceNumber("2")
+                                .data(SdkBytes.fromString("Bye", 
Charset.defaultCharset()))
+                                .build())
+                .build();
+        
when(kinesisClient.getRecords(any(GetRecordsRequest.class))).thenReturn(records);
+        
when(kinesisClient.getShardIterator(any(GetShardIteratorRequest.class)))
+                
.thenReturn(GetShardIteratorResponse.builder().shardIterator("shardIterator").build());
+        when(kinesisClient.listShards(any(ListShardsRequest.class)))
+                
.thenReturn(ListShardsResponse.builder().shards(shardList).build());
+
+        component.start();
+
+        Kinesis2Configuration configuration = new Kinesis2Configuration();
+        configuration.setAmazonKinesisClient(kinesisClient);
+        configuration.setIteratorType(ShardIteratorType.LATEST);
+        configuration.setStreamName("streamName");
+
+        Kinesis2Endpoint endpoint = new Kinesis2Endpoint("aws2-kinesis:foo", 
configuration, component);
+        endpoint.start();
+        underTest = new Kinesis2Consumer(endpoint, processor);
+        underTest.setConnection(component.getConnection());
+        underTest.start();
+        await().atMost(10, TimeUnit.SECONDS)
+                .until(() -> underTest.getCurrentShardList().size() == 2);
+    }
+
+    @Test
+    public void pollAccumulatesTheRecordCountAcrossAllShards() throws 
Exception {
+        // Two shards, two records each -> poll must report all four, not just 
the last shard's two.
+        int processed = underTest.poll();
+
+        Assertions.assertEquals(4, processed, "poll() must sum the processed 
records across every shard");
+        verify(kinesisClient, 
times(2)).getRecords(any(GetRecordsRequest.class));
+    }
+}

Reply via email to