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 847ee9affb4c CAMEL-25942: camel-aws2-kinesis - resume each shard from
its own entry of the resume cache (#28461)
847ee9affb4c is described below
commit 847ee9affb4cb40326694c0c1da3d2fd6b3367bd
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Oct 26 13:31:22 2026 +0530
CAMEL-25942: camel-aws2-kinesis - resume each shard from its own entry of
the resume cache (#28461)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../camel/catalog/docs/aws2-kinesis-component.adoc | 5 +
.../src/main/docs/aws2-kinesis-component.adoc | 5 +
.../component/aws2/kinesis/Kinesis2Consumer.java | 14 +-
.../aws2/kinesis/consumer/KinesisResumeAction.java | 20 +-
.../aws2/kinesis/KinesisConsumerPositionTest.java | 215 ++++++++++++++++++++-
5 files changed, 254 insertions(+), 5 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/aws2-kinesis-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/aws2-kinesis-component.adoc
index 8edb94b26a2f..2c81d405ee68 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/aws2-kinesis-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/aws2-kinesis-component.adoc
@@ -77,6 +77,11 @@ multiple shards are processed in parallel. Because of this,
custom subclasses *m
public no-arg constructor. Per-shard state (`builder`, `shardId`,
`streamName`) is injected via
setters after construction.
+When a shard needs a new shard iterator, `evalEntry` is called for every entry
of the resume cache, with the
+shard id as key and the sequence number (a `String`, or the `KinesisOffset`
stored by `KinesisResumeStrategy`) as
+value. The default action starts the shard after the sequence number of its
own entry and ignores the entries of
+the other shards; a subclass should compare the key with `getShardId()` the
same way.
+
=== Batch Producer
This component implements the Batch Producer.
diff --git
a/components/camel-aws/camel-aws2-kinesis/src/main/docs/aws2-kinesis-component.adoc
b/components/camel-aws/camel-aws2-kinesis/src/main/docs/aws2-kinesis-component.adoc
index 8edb94b26a2f..2c81d405ee68 100644
---
a/components/camel-aws/camel-aws2-kinesis/src/main/docs/aws2-kinesis-component.adoc
+++
b/components/camel-aws/camel-aws2-kinesis/src/main/docs/aws2-kinesis-component.adoc
@@ -77,6 +77,11 @@ multiple shards are processed in parallel. Because of this,
custom subclasses *m
public no-arg constructor. Per-shard state (`builder`, `shardId`,
`streamName`) is injected via
setters after construction.
+When a shard needs a new shard iterator, `evalEntry` is called for every entry
of the resume cache, with the
+shard id as key and the sequence number (a `String`, or the `KinesisOffset`
stored by `KinesisResumeStrategy`) as
+value. The default action starts the shard after the sequence number of its
own entry and ignores the entries of
+the other shards; a subclass should compare the key with `getShardId()` the
same way.
+
=== Batch Producer
This component implements the Batch Producer.
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 fba634587d8d..472b3587bf99 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
@@ -30,6 +30,8 @@ import java.util.concurrent.ExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
import org.apache.camel.AsyncCallback;
import org.apache.camel.Exchange;
@@ -65,6 +67,9 @@ public class Kinesis2Consumer extends
ScheduledBatchPollingConsumer implements R
// 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 = "";
+ // The adapter of a resume strategy is one instance, shared by poll()'s
parallel shards and by the routes that use
+ // the same strategy bean, so one lock for all consumers. It is held only
while a shard gets a new iterator.
+ private static final Lock RESUME_LOCK = new ReentrantLock();
private KinesisConnection connection;
private ResumeStrategy resumeStrategy;
@@ -360,8 +365,13 @@ public class Kinesis2Consumer extends
ScheduledBatchPollingConsumer implements R
}
final ResumeAction action = resolveResumeAction(shardId, req);
- adapter.setResumeAction(action);
- adapter.resume();
+ RESUME_LOCK.lock();
+ try {
+ adapter.setResumeAction(action);
+ adapter.resume();
+ } finally {
+ RESUME_LOCK.unlock();
+ }
}
private KinesisResumeAction resolveResumeAction(String shardId,
GetShardIteratorRequest.Builder req) {
diff --git
a/components/camel-aws/camel-aws2-kinesis/src/main/java/org/apache/camel/component/aws2/kinesis/consumer/KinesisResumeAction.java
b/components/camel-aws/camel-aws2-kinesis/src/main/java/org/apache/camel/component/aws2/kinesis/consumer/KinesisResumeAction.java
index 5501dbd17e72..1fce770da603 100644
---
a/components/camel-aws/camel-aws2-kinesis/src/main/java/org/apache/camel/component/aws2/kinesis/consumer/KinesisResumeAction.java
+++
b/components/camel-aws/camel-aws2-kinesis/src/main/java/org/apache/camel/component/aws2/kinesis/consumer/KinesisResumeAction.java
@@ -17,6 +17,7 @@
package org.apache.camel.component.aws2.kinesis.consumer;
+import org.apache.camel.resume.Offset;
import org.apache.camel.resume.ResumeAction;
import software.amazon.awssdk.services.kinesis.model.GetShardIteratorRequest;
import software.amazon.awssdk.services.kinesis.model.ShardIteratorType;
@@ -61,11 +62,26 @@ public class KinesisResumeAction implements ResumeAction {
this.shardId = shardId;
}
+ /**
+ * Starts the shard of this action after the sequence number stored for it
in the resume cache. The adapter calls
+ * this for every entry of the cache, so the entries of the other shards
are left alone. The value is the sequence
+ * number as a String, or the {@link KinesisOffset} that {@link
KinesisResumeStrategy} stores.
+ *
+ * @return false, the entry is not invalidated: it is the position of the
shard when it needs a new shard iterator
+ * again
+ */
@Override
public boolean evalEntry(Object shardId, Object sequenceNumber) {
- builder.shardId((String) shardId);
+ if (this.shardId != null && !this.shardId.equals(shardId)) {
+ return false;
+ }
+ Object value = sequenceNumber instanceof Offset<?> offset ?
offset.getValue() : sequenceNumber;
+ if (value == null) {
+ return false;
+ }
+ builder.shardId(shardId.toString());
builder.shardIteratorType(ShardIteratorType.AFTER_SEQUENCE_NUMBER);
- builder.startingSequenceNumber((String) sequenceNumber);
+ builder.startingSequenceNumber(value.toString());
return false;
}
}
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
index 2181bfa0a4b7..ab5f299e61f8 100644
---
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
@@ -24,14 +24,30 @@ import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
+import java.util.function.BiFunction;
+import java.util.function.Function;
import org.apache.camel.AsyncCallback;
import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
+import
org.apache.camel.component.aws2.kinesis.consumer.KinesisConsumerOffsetProcessor;
+import org.apache.camel.component.aws2.kinesis.consumer.KinesisResumeStrategy;
+import
org.apache.camel.component.aws2.kinesis.consumer.KinesisResumeStrategyConfiguration;
import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.resume.Resumable;
+import org.apache.camel.resume.ResumeAction;
+import org.apache.camel.resume.ResumeActionAware;
+import org.apache.camel.resume.ResumeStrategy;
+import org.apache.camel.resume.cache.ResumeCache;
import org.apache.camel.support.AsyncProcessorSupport;
+import org.apache.camel.support.resume.ResumeActionAwareAdapter;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
@@ -50,18 +66,21 @@ 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.junit.jupiter.api.Assertions.assertTrue;
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.
+ * are valid for 5 minutes), for the shards created by resharding, and from
the offsets of a resume strategy. 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 final MapResumeCache resumeCache = new MapResumeCache();
private Kinesis2Consumer consumer;
@@ -167,7 +186,133 @@ class KinesisConsumerPositionTest {
assertEquals(List.of("new"), routed);
}
+ @Test
+ void resumeStrategyStartsEachShardAfterItsOwnSequenceNumber() throws
Exception {
+ stream.addShard("shard-0", null);
+ stream.addShard("shard-1", null);
+ for (String body : List.of("a0", "b0", "c0")) {
+ stream.put("shard-0", body);
+ }
+ for (String body : List.of("a1", "b1", "c1")) {
+ stream.put("shard-1", body);
+ }
+ // the positions stored by an earlier run
+ resumeCache.add("shard-0", "1");
+ resumeCache.add("shard-1", "2");
+ startConsumer(ShardIteratorType.TRIM_HORIZON, 2, resumeStrategy());
+
+ consumer.poll();
+ List<String> sorted = new ArrayList<>(routed);
+ Collections.sort(sorted);
+ assertEquals(List.of("b0", "c0", "c1"), sorted, "each shard continues
after its own sequence number");
+ }
+
+ @Test
+ void resumeStrategyStartsANewConsumerAfterTheOffsetsOfTheResumableEip()
throws Exception {
+ stream.addShard("shard-0", null);
+ stream.put("shard-0", "a");
+ stream.put("shard-0", "b");
+ ResumeStrategy resumeStrategy = resumeStrategy();
+ startConsumer(ShardIteratorType.TRIM_HORIZON, 1, resumeStrategy);
+ assertEquals(2, consumer.poll());
+
+ // the route is started again: a new consumer, the offsets of the
routed records are in the resume cache
+ consumer.stop();
+ stream.put("shard-0", "c");
+ startConsumer(ShardIteratorType.TRIM_HORIZON, 1, resumeStrategy);
+ assertEquals(1, consumer.poll());
+ assertEquals(List.of("a", "b", "c"), routed);
+
+ // the next new iterator of the shard resumes from the offset stored
by this consumer
+ stream.expireIterators();
+ stream.put("shard-0", "d");
+ assertEquals(1, consumer.poll());
+ assertEquals(List.of("a", "b", "c", "d"), routed);
+ }
+
+ @Test
+ void consumersOfOneResumeStrategyResumeOneAfterTheOther() throws Exception
{
+ // two routes with the same resume strategy bean share its adapter
+ stream.addShard("shard-0", null);
+ stream.put("shard-0", "a");
+ BlockingAdapter adapter = new BlockingAdapter();
+ KinesisResumeStrategy resumeStrategy = new KinesisResumeStrategy();
+ resumeStrategy.setResumeStrategyConfiguration(
+
KinesisResumeStrategyConfiguration.builder().withResumeCache(resumeCache).build());
+ resumeStrategy.setAdapter(adapter);
+ resumeStrategy.start();
+ startConsumer(ShardIteratorType.TRIM_HORIZON, 1, resumeStrategy);
+ Kinesis2Consumer first = consumer;
+ startConsumer(ShardIteratorType.TRIM_HORIZON, 1, resumeStrategy);
+ Kinesis2Consumer second = consumer;
+
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ try {
+ adapter.blockFirstResume = true;
+ Future<Integer> firstPoll = executor.submit(first::poll);
+ assertTrue(adapter.firstResumeEntered.await(10, TimeUnit.SECONDS),
"the first consumer resumes");
+ Future<Integer> secondPoll = executor.submit(second::poll);
+
+ assertEquals(1, firstPoll.get(10, TimeUnit.SECONDS));
+ assertEquals(1, secondPoll.get(10, TimeUnit.SECONDS));
+ assertEquals(1, adapter.actionSetDuringResume.getCount(),
+ "the second consumer set its resume action while the first
one resumed with its own");
+ } finally {
+ executor.shutdownNow();
+ first.stop();
+ }
+ }
+
+ /**
+ * An adapter that keeps its first resume running until another resume
action is set, at most one second: the time
+ * the second consumer has to set its action on the shared adapter if it
does not wait for the first one.
+ */
+ private static final class BlockingAdapter implements ResumeActionAware {
+ private final CountDownLatch firstResumeEntered = new
CountDownLatch(1);
+ private final CountDownLatch actionSetDuringResume = new
CountDownLatch(1);
+ private volatile boolean blockFirstResume;
+ private volatile boolean resuming;
+
+ @Override
+ public void setResumeAction(ResumeAction resumeAction) {
+ if (resuming) {
+ actionSetDuringResume.countDown();
+ }
+ }
+
+ @Override
+ public void resume() {
+ if (blockFirstResume && firstResumeEntered.getCount() > 0) {
+ resuming = true;
+ firstResumeEntered.countDown();
+ try {
+ actionSetDuringResume.await(1, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ resuming = false;
+ }
+ }
+ }
+ }
+
+ private ResumeStrategy resumeStrategy() {
+ // as the resumable EIP and the route set it up: the strategy and the
adapter of the consumer share the cache
+ KinesisResumeStrategy resumeStrategy = new KinesisResumeStrategy();
+ resumeStrategy.setResumeStrategyConfiguration(
+
KinesisResumeStrategyConfiguration.builder().withResumeCache(resumeCache).build());
+ ResumeActionAwareAdapter adapter = new ResumeActionAwareAdapter();
+ adapter.setCache(resumeCache);
+ resumeStrategy.setAdapter(adapter);
+ resumeStrategy.start();
+ return resumeStrategy;
+ }
+
private void startConsumer(ShardIteratorType type, int shards) throws
Exception {
+ startConsumer(type, shards, null);
+ }
+
+ private void startConsumer(ShardIteratorType type, int shards,
ResumeStrategy resumeStrategy) throws Exception {
Kinesis2Component component = new Kinesis2Component(context);
component.start();
@@ -184,17 +329,85 @@ class KinesisConsumerPositionTest {
@Override
public boolean process(Exchange exchange, AsyncCallback callback) {
routed.add(exchange.getMessage().getBody(String.class));
+ if (resumeStrategy != null) {
+ // what the resumable EIP does when the exchange
completes, with the offset of
+ // KinesisConsumerOffsetProcessor
+ try {
+ new KinesisConsumerOffsetProcessor().process(exchange);
+
resumeStrategy.updateLastOffset(exchange.getMessage().getHeader(Exchange.OFFSET,
Resumable.class));
+ } catch (Exception e) {
+ exchange.setException(e);
+ }
+ }
callback.done(true);
return true;
}
});
consumer.setConnection(component.getConnection());
+ consumer.setResumeStrategy(resumeStrategy);
// the test calls poll()
consumer.setStartScheduler(false);
consumer.start();
await().atMost(20, TimeUnit.SECONDS).until(() ->
consumer.getCurrentShardList().size() == shards);
}
+ /**
+ * A resume cache that follows the contract of {@link ResumeCache#forEach}
like camel-caffeine's CaffeineCache: the
+ * entry is invalidated when the action returns true.
+ */
+ private static final class MapResumeCache implements ResumeCache<Object> {
+ private final Map<Object, Object> entries = new ConcurrentHashMap<>();
+
+ @Override
+ public Object computeIfAbsent(Object key, Function<? super Object, ?
super Object> mapping) {
+ return entries.computeIfAbsent(key, mapping);
+ }
+
+ @Override
+ public Object computeIfPresent(Object key, BiFunction<? super Object,
? super Object, ? super Object> remapping) {
+ return entries.computeIfPresent(key, remapping);
+ }
+
+ @Override
+ public boolean contains(Object key, Object entry) {
+ return entry.equals(entries.get(key));
+ }
+
+ @Override
+ public void add(Object key, Object offsetValue) {
+ entries.put(key, offsetValue);
+ }
+
+ @Override
+ public boolean isFull() {
+ return false;
+ }
+
+ @Override
+ public long capacity() {
+ return Integer.MAX_VALUE;
+ }
+
+ @Override
+ public <T> T get(Object key, Class<T> clazz) {
+ return clazz.cast(entries.get(key));
+ }
+
+ @Override
+ public Object get(Object key) {
+ return entries.get(key);
+ }
+
+ @Override
+ public void forEach(BiFunction<? super Object, ? super Object,
Boolean> action) {
+ for (Map.Entry<Object, Object> entry : entries.entrySet()) {
+ if (action.apply(entry.getKey(), entry.getValue())) {
+ entries.remove(entry.getKey());
+ }
+ }
+ }
+ }
+
/**
* Shards with records, and shard iterators that point between two records
of a shard and can expire.
*/