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 7024148c09c3 CAMEL-25570: camel-aws2-ddb - aws2-ddbstream consumer
must not skip the records it did not route when the route is suspended (#27754)
7024148c09c3 is described below
commit 7024148c09c3c5bf6a1d45b193ccbd178e0eef8b
Author: allthingssecurity <[email protected]>
AuthorDate: Tue Oct 13 15:02:24 2026 +0530
CAMEL-25570: camel-aws2-ddb - aws2-ddbstream consumer must not skip the
records it did not route when the route is suspended (#27754)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../aws2/ddbstream/Ddb2StreamConsumer.java | 53 ++-
.../aws2/ddbstream/ShardIteratorHandler.java | 9 +
...amConsumerSuspendedBatchPooledExchangeTest.java | 73 +++++
.../Ddb2StreamConsumerSuspendedBatchTest.java | 357 +++++++++++++++++++++
4 files changed, 489 insertions(+), 3 deletions(-)
diff --git
a/components/camel-aws/camel-aws2-ddb/src/main/java/org/apache/camel/component/aws2/ddbstream/Ddb2StreamConsumer.java
b/components/camel-aws/camel-aws2-ddb/src/main/java/org/apache/camel/component/aws2/ddbstream/Ddb2StreamConsumer.java
index 9ab926d98b76..a0ac9df98c1c 100644
---
a/components/camel-aws/camel-aws2-ddb/src/main/java/org/apache/camel/component/aws2/ddbstream/Ddb2StreamConsumer.java
+++
b/components/camel-aws/camel-aws2-ddb/src/main/java/org/apache/camel/component/aws2/ddbstream/Ddb2StreamConsumer.java
@@ -18,10 +18,12 @@ package org.apache.camel.component.aws2.ddbstream;
import java.util.ArrayDeque;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Queue;
+import java.util.Set;
import org.apache.camel.AsyncCallback;
import org.apache.camel.Exchange;
@@ -43,6 +45,12 @@ public class Ddb2StreamConsumer extends
ScheduledBatchPollingConsumer {
private final ShardIteratorHandler shardIteratorHandler;
private final Map<String, String> lastSeenSequenceNumbers = new
HashMap<>();
+ // Shards of which only the first records of a response were routed, as
the consumer was being suspended or
+ // stopped: the next poll continues after the last routed record instead
of after the response
+ private final Set<String> shardsOfUnfinishedBatch = new HashSet<>();
+ // Shards of which no record was routed yet, and whose first response with
records was not routed at all for the
+ // same reason: the sequence number of the first record of that response
+ private final Map<String, String> firstNotRoutedSequenceNumbers = new
HashMap<>();
public Ddb2StreamConsumer(Ddb2StreamEndpoint endpoint, Processor
processor) {
this(endpoint, processor, new ShardIteratorHandler(endpoint));
@@ -67,6 +75,11 @@ public class Ddb2StreamConsumer extends
ScheduledBatchPollingConsumer {
getEndpoint().getConfiguration().getMaxResultsPerRequest()
/ shardIterators.size());
String shardId = shardIteratorEntry.getKey();
String shardIterator = shardIteratorEntry.getValue();
+ if (shardsOfUnfinishedBatch.contains(shardId)) {
+ // not requested when the batch was cut short: that thread may
have been interrupted by the stop
+ shardIterator =
shardIteratorHandler.requestFreshShardIterator(shardId,
lastSeenSequenceNumbers.get(shardId));
+ shardsOfUnfinishedBatch.remove(shardId);
+ }
GetRecordsResponse result;
try {
GetRecordsRequest req = GetRecordsRequest.builder()
@@ -76,9 +89,21 @@ public class Ddb2StreamConsumer extends
ScheduledBatchPollingConsumer {
result = getEndpoint().getClient().getRecords(req);
} catch (ExpiredIteratorException e) {
String lastSeenSequenceNumber =
lastSeenSequenceNumbers.get(shardId);
- LOG.warn("Expired Shard Iterator, attempting to resume from
{}", lastSeenSequenceNumber, e);
+ String firstNotRoutedSequenceNumber
+ = lastSeenSequenceNumber == null ?
firstNotRoutedSequenceNumbers.get(shardId) : null;
+ String freshShardIterator;
+ if (firstNotRoutedSequenceNumber != null) {
+ // the iterator that was kept for the records that were
not routed has expired, and there is no
+ // routed record to continue after: start at the first of
them, not at the start of the shard
+ LOG.warn("Expired Shard Iterator, attempting to resume at
{}", firstNotRoutedSequenceNumber, e);
+ freshShardIterator
+ =
shardIteratorHandler.requestFreshShardIteratorAt(shardId,
firstNotRoutedSequenceNumber);
+ } else {
+ LOG.warn("Expired Shard Iterator, attempting to resume
from {}", lastSeenSequenceNumber, e);
+ freshShardIterator =
shardIteratorHandler.requestFreshShardIterator(shardId, lastSeenSequenceNumber);
+ }
GetRecordsRequest req = GetRecordsRequest.builder()
-
.shardIterator(shardIteratorHandler.requestFreshShardIterator(shardId,
lastSeenSequenceNumber))
+ .shardIterator(freshShardIterator)
.limit(limitPerRecordsRequest)
.build();
result = getEndpoint().getClient().getRecords(req);
@@ -88,11 +113,33 @@ public class Ddb2StreamConsumer extends
ScheduledBatchPollingConsumer {
for (Record polledRecord : records) {
exchanges.add(createExchange(polledRecord));
}
- processedExchangeCount += processBatch(CastUtils.cast(exchanges));
+ int routed = processBatch(CastUtils.cast(exchanges));
+ processedExchangeCount += routed;
+
+ if (routed < records.size()) {
+ // the route is being suspended or stopped, and processBatch
did not route the rest of the records:
+ // do not move past them, so that they are routed when the
consumer runs again. When nothing was
+ // routed, the shard iterator of this request is kept and used
again.
+ if (routed > 0) {
+ lastSeenSequenceNumbers.put(shardId, records.get(routed -
1).dynamodb().sequenceNumber());
+ firstNotRoutedSequenceNumbers.remove(shardId);
+ shardsOfUnfinishedBatch.add(shardId);
+ } else if (!lastSeenSequenceNumbers.containsKey(shardId)) {
+ // only needed when the kept iterator has expired before
the consumer runs again
+ firstNotRoutedSequenceNumbers.put(shardId,
records.get(0).dynamodb().sequenceNumber());
+ }
+ // processBatch removes the exchanges it routes: those left
are never routed, and nothing else gives
+ // them back to the exchange factory
+ for (Exchange notRouted : exchanges) {
+ releaseExchange(notRouted, true);
+ }
+ break;
+ }
shardIteratorHandler.updateShardIterator(shardId,
result.nextShardIterator());
if (!records.isEmpty()) {
lastSeenSequenceNumbers.put(shardId,
records.get(records.size() - 1).dynamodb().sequenceNumber());
+ firstNotRoutedSequenceNumbers.remove(shardId);
}
}
return processedExchangeCount;
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 e90a1330daa8..cb32f9398fc0 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
@@ -114,6 +114,15 @@ class ShardIteratorHandler {
return shardIterator;
}
+ /**
+ * Requests a shard iterator that starts at the record with the given
sequence number (and not after it).
+ */
+ String requestFreshShardIteratorAt(String shardId, String sequenceNumber) {
+ String shardIterator = getShardIterator(shardId,
ShardIteratorType.AT_SEQUENCE_NUMBER, sequenceNumber);
+ currentShardIterators.put(shardId, shardIterator);
+ return shardIterator;
+ }
+
Ddb2StreamEndpoint getEndpoint() {
return endpoint;
}
diff --git
a/components/camel-aws/camel-aws2-ddb/src/test/java/org/apache/camel/component/aws2/ddbstream/Ddb2StreamConsumerSuspendedBatchPooledExchangeTest.java
b/components/camel-aws/camel-aws2-ddb/src/test/java/org/apache/camel/component/aws2/ddbstream/Ddb2StreamConsumerSuspendedBatchPooledExchangeTest.java
new file mode 100644
index 000000000000..8bd5721dce10
--- /dev/null
+++
b/components/camel-aws/camel-aws2-ddb/src/test/java/org/apache/camel/component/aws2/ddbstream/Ddb2StreamConsumerSuspendedBatchPooledExchangeTest.java
@@ -0,0 +1,73 @@
+/*
+ * 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.List;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.impl.engine.PooledExchangeFactory;
+import org.apache.camel.spi.ExchangeFactory;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * The same with the pooled exchange factory: the exchanges created for the
records that a suspending consumer does not
+ * route are given back to the pool, once, so the records routed after the
resume do not share an exchange.
+ */
+class Ddb2StreamConsumerSuspendedBatchPooledExchangeTest extends
Ddb2StreamConsumerSuspendedBatchTest {
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+ PooledExchangeFactory factory = new PooledExchangeFactory();
+ factory.setStatisticsEnabled(true);
+ context.getCamelContextExtension().setExchangeFactory(factory);
+ return context;
+ }
+
+ @Test
+ void exchangesOfTheRecordsThatWereNotRoutedAreGivenBackToThePool() throws
Exception {
+ client.put("1", "2", "3", "4");
+ suspendAtRecord = "2";
+
+
context.getCamelContextExtension().getExchangeFactoryManager().setStatisticsEnabled(true);
+ context.getRouteController().startRoute(ROUTE_ID);
+ awaitRouteSuspended();
+ assertEquals(List.of("1", "2"), routed);
+ // one exchange per record of the response: two were routed, and the
two that were not are given back as well
+ awaitExchangesTakenAndReleased(4);
+
+ context.getRouteController().resumeRoute(ROUTE_ID);
+ await().atMost(20, TimeUnit.SECONDS).untilAsserted(
+ () -> assertEquals(List.of("1", "2", "3", "4"), routed,
"records routed after the resume"));
+ // and only once: the pool does not hold an exchange twice
+ awaitExchangesTakenAndReleased(6);
+ }
+
+ private void awaitExchangesTakenAndReleased(int expected) {
+ await().atMost(20, TimeUnit.SECONDS).untilAsserted(() -> {
+ ExchangeFactory.Statistics stats
+ =
context.getCamelContextExtension().getExchangeFactoryManager().getStatistics();
+ assertEquals(expected, stats.getCreatedCounter() +
stats.getAcquiredCounter(), "exchanges taken");
+ assertEquals(expected, stats.getReleasedCounter(), "exchanges
given back to the pool");
+ assertEquals(0, stats.getDiscardedCounter(), "exchanges
discarded");
+ });
+ }
+}
diff --git
a/components/camel-aws/camel-aws2-ddb/src/test/java/org/apache/camel/component/aws2/ddbstream/Ddb2StreamConsumerSuspendedBatchTest.java
b/components/camel-aws/camel-aws2-ddb/src/test/java/org/apache/camel/component/aws2/ddbstream/Ddb2StreamConsumerSuspendedBatchTest.java
new file mode 100644
index 000000000000..a6e8b18c9288
--- /dev/null
+++
b/components/camel-aws/camel-aws2-ddb/src/test/java/org/apache/camel/component/aws2/ddbstream/Ddb2StreamConsumerSuspendedBatchTest.java
@@ -0,0 +1,357 @@
+/*
+ * 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.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.ServiceStatus;
+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.ExpiredIteratorException;
+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.ShardIteratorType;
+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;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * A route that is suspended while the consumer routes the records of a
GetRecords response, and resumed: the records of
+ * that response that were not routed must be routed after the resume. (A
route that is stopped and started gets a new
+ * consumer, which starts from the configured iterator type.)
+ */
+class Ddb2StreamConsumerSuspendedBatchTest extends CamelTestSupport {
+
+ static final String ROUTE_ID = "ddbstream";
+ static final String LATEST_ROUTE_ID = "ddbstream-latest";
+
+ @BindToRegistry("ddbStreams")
+ final FakeStreamsClient client = new FakeStreamsClient();
+
+ final List<String> routed = new CopyOnWriteArrayList<>();
+ // the sequence number of the record whose processor suspends the route
(once)
+ volatile String suspendAtRecord;
+ private final AtomicBoolean suspendRequested = new AtomicBoolean();
+ private final AtomicReference<Exception> suspendFailure = new
AtomicReference<>();
+ private volatile Thread suspender;
+ // the route that the test starts, suspends and resumes
+ private volatile String routeId = ROUTE_ID;
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("aws2-ddbstream://table?amazonDynamoDbStreamsClient=#ddbStreams&streamIteratorType=FROM_START"
+ + "&initialDelay=0&delay=10")
+ .routeId(ROUTE_ID).autoStartup(false)
+ .to("direct:records");
+
+
from("aws2-ddbstream://table?amazonDynamoDbStreamsClient=#ddbStreams&streamIteratorType=FROM_LATEST"
+ + "&initialDelay=0&delay=10")
+ .routeId(LATEST_ROUTE_ID).autoStartup(false)
+ .to("direct:records");
+
+ from("direct:records")
+ .process(exchange -> {
+ String sequenceNumber
+ =
exchange.getMessage().getHeader(Ddb2StreamConstants.SEQUENCE_NUMBER,
String.class);
+ if (sequenceNumber.equals(suspendAtRecord)) {
+ suspendRouteDuringThePoll();
+ }
+ routed.add(sequenceNumber);
+ });
+ }
+ };
+ }
+
+ @Test
+ void recordsNotRoutedByASuspendingConsumerAreRoutedAfterTheResume() throws
Exception {
+ client.put("1", "2", "3", "4");
+ suspendAtRecord = "2";
+
+ context.getRouteController().startRoute(ROUTE_ID);
+ awaitRouteSuspended();
+ assertEquals(List.of("1", "2"), routed, "the suspend ends the batch
after the record that is being routed");
+
+ context.getRouteController().resumeRoute(ROUTE_ID);
+ await().atMost(20, TimeUnit.SECONDS).untilAsserted(
+ () -> assertEquals(List.of("1", "2", "3", "4"), routed,
"records routed after the resume"));
+
+ // the consumer continues after the last record it routed
+ GetShardIteratorRequest last =
client.iteratorRequests.get(client.iteratorRequests.size() - 1);
+ assertEquals(ShardIteratorType.AFTER_SEQUENCE_NUMBER,
last.shardIteratorType());
+ assertEquals("2", last.sequenceNumber());
+ }
+
+ @Test
+ void recordsOfAResponseThatArrivesWhileSuspendingAreRoutedAfterTheResume()
throws Exception {
+ client.put("1", "2");
+ // the route is suspended while the GetRecords request is in flight:
none of its records is routed
+ client.onGetRecords = this::suspendRouteDuringThePoll;
+
+ context.getRouteController().startRoute(ROUTE_ID);
+ awaitRouteSuspended();
+ assertEquals(List.of(), routed, "nothing is routed by the suspending
consumer");
+
+ context.getRouteController().resumeRoute(ROUTE_ID);
+ await().atMost(20, TimeUnit.SECONDS).untilAsserted(
+ () -> assertEquals(List.of("1", "2"), routed, "records routed
after the resume"));
+ assertEquals(1, client.iteratorRequests.size(), "the shard iterator of
the request is used again");
+ }
+
+ @Test
+ void laterRecordsAreRoutedOnceAfterABatchWasCutShort() throws Exception {
+ client.put("1", "2", "3");
+ suspendAtRecord = "1";
+
+ context.getRouteController().startRoute(ROUTE_ID);
+ awaitRouteSuspended();
+ client.put("4");
+
+ context.getRouteController().resumeRoute(ROUTE_ID);
+ await().atMost(20, TimeUnit.SECONDS).untilAsserted(
+ () -> assertEquals(List.of("1", "2", "3", "4"), routed,
"records routed after the resume"));
+ // a few more polls: nothing is routed twice
+ int requests = client.recordRequests();
+ await().atMost(20, TimeUnit.SECONDS).until(() ->
client.recordRequests() >= requests + 3);
+ assertEquals(List.of("1", "2", "3", "4"), routed);
+ }
+
+ @Test
+ void
firstRecordsOfAShardThatWereNotRoutedAreNotReplacedByTheWholeShardWhenTheIteratorExpires()
throws Exception {
+ routeId = LATEST_ROUTE_ID;
+ // stored before the consumer starts: a FROM_LATEST consumer does not
route them
+ client.put("1", "2");
+
+ context.getRouteController().startRoute(LATEST_ROUTE_ID);
+ await().atMost(20, TimeUnit.SECONDS).until(() ->
client.recordRequests() >= 1);
+ // the first response with records arrives while the route is
suspended: none of its records is routed, and
+ // no record of the shard was routed before
+ client.onRecordsReturned = this::suspendRouteDuringThePoll;
+ client.put("3", "4");
+ awaitRouteSuspended();
+ assertEquals(List.of(), routed, "nothing is routed by the suspending
consumer");
+
+ // the route stays suspended for longer than a shard iterator is valid
+ client.expireIterators();
+ context.getRouteController().resumeRoute(LATEST_ROUTE_ID);
+ await().atMost(20, TimeUnit.SECONDS).until(() -> routed.size() >= 2);
+ // a few more polls: nothing else is routed
+ int requests = client.recordRequests();
+ await().atMost(20, TimeUnit.SECONDS).until(() ->
client.recordRequests() >= requests + 3);
+ assertEquals(List.of("3", "4"), routed, "the records that were not
routed, and not the shard from its start");
+
+ GetShardIteratorRequest last =
client.iteratorRequests.get(client.iteratorRequests.size() - 1);
+ assertEquals(ShardIteratorType.AT_SEQUENCE_NUMBER,
last.shardIteratorType());
+ assertEquals("3", last.sequenceNumber());
+ }
+
+ /**
+ * Called by the thread that polls: suspends the route from another
thread, as a route must not suspend itself, and
+ * returns when the suspend has reached the consumer (it no longer routes
the following records of the batch).
+ */
+ private void suspendRouteDuringThePoll() {
+ if (!suspendRequested.compareAndSet(false, true)) {
+ return;
+ }
+ String id = routeId;
+ Ddb2StreamConsumer consumer = (Ddb2StreamConsumer)
context.getRoute(id).getConsumer();
+ suspender = new Thread(() -> {
+ try {
+ context.getRouteController().suspendRoute(id);
+ } catch (Exception e) {
+ suspendFailure.set(e);
+ }
+ }, "suspend-route");
+ suspender.start();
+
+ CountDownLatch batchEnded = new CountDownLatch(1);
+ Thread watcher = new Thread(() -> {
+ await().atMost(20, TimeUnit.SECONDS).until(() ->
!consumer.isBatchAllowed());
+ batchEnded.countDown();
+ }, "watch-consumer");
+ watcher.start();
+
+ // an interrupt of the thread that polls must not end this wait
+ boolean interrupted = false;
+ boolean ended = false;
+ long remaining = TimeUnit.SECONDS.toNanos(30);
+ long deadline = System.nanoTime() + remaining;
+ while (!ended && remaining > 0) {
+ try {
+ ended = batchEnded.await(remaining, TimeUnit.NANOSECONDS);
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ remaining = deadline - System.nanoTime();
+ }
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ void awaitRouteSuspended() throws InterruptedException {
+ await().atMost(20, TimeUnit.SECONDS).until(() -> suspender != null);
+ suspender.join(TimeUnit.SECONDS.toMillis(60));
+ assertFalse(suspender.isAlive(), "the route suspend has returned");
+ assertNull(suspendFailure.get());
+ assertEquals(ServiceStatus.Suspended,
context.getRouteController().getRouteStatus(routeId));
+ }
+
+ /**
+ * A stream with one shard. A shard iterator is a position between two
records.
+ */
+ static final class FakeStreamsClient implements DynamoDbStreamsClient {
+ final List<GetShardIteratorRequest> iteratorRequests = new
CopyOnWriteArrayList<>();
+ volatile Runnable onGetRecords;
+ // as onGetRecords, for a response that has records
+ volatile Runnable onRecordsReturned;
+ private final List<Record> records = new ArrayList<>();
+ private final Map<String, Integer> iterators = new HashMap<>();
+ private final Set<String> expiredIterators = new HashSet<>();
+ private int recordRequests;
+
+ synchronized void put(String... sequenceNumbers) {
+ for (String sequenceNumber : sequenceNumbers) {
+ records.add(Record.builder().eventID("event-" +
sequenceNumber).eventName(OperationType.INSERT)
+
.dynamodb(StreamRecord.builder().sequenceNumber(sequenceNumber).build()).build());
+ }
+ }
+
+ synchronized int recordRequests() {
+ return recordRequests;
+ }
+
+ /** The shard iterators given out so far are no longer valid. */
+ synchronized void expireIterators() {
+ expiredIterators.addAll(iterators.keySet());
+ }
+
+ @Override
+ public ListStreamsResponse listStreams(ListStreamsRequest request) {
+ return
ListStreamsResponse.builder().streams(Stream.builder().streamArn("arn:stream").build()).build();
+ }
+
+ @Override
+ public DescribeStreamResponse describeStream(DescribeStreamRequest
request) {
+ return DescribeStreamResponse.builder().streamDescription(
+
StreamDescription.builder().shards(Shard.builder().shardId("shard-0")
+ // an open shard: no ending sequence number
+
.sequenceNumberRange(SequenceNumberRange.builder().startingSequenceNumber("1").build())
+ .build()).build())
+ .build();
+ }
+
+ @Override
+ public synchronized GetShardIteratorResponse
getShardIterator(GetShardIteratorRequest request) {
+ iteratorRequests.add(request);
+ int position;
+ switch (request.shardIteratorType()) {
+ case TRIM_HORIZON:
+ position = 0;
+ break;
+ case LATEST:
+ position = records.size();
+ break;
+ case AT_SEQUENCE_NUMBER:
+ position = Integer.parseInt(request.sequenceNumber()) - 1;
+ break;
+ case AFTER_SEQUENCE_NUMBER:
+ position = Integer.parseInt(request.sequenceNumber());
+ break;
+ default:
+ throw new IllegalArgumentException("Not expected in this
test: " + request.shardIteratorType());
+ }
+ return
GetShardIteratorResponse.builder().shardIterator(newIterator(position)).build();
+ }
+
+ @Override
+ public GetRecordsResponse getRecords(GetRecordsRequest request) {
+ GetRecordsResponse response;
+ synchronized (this) {
+ recordRequests++;
+ if (expiredIterators.contains(request.shardIterator())) {
+ throw ExpiredIteratorException.builder().message("Iterator
expired").build();
+ }
+ int from = iterators.get(request.shardIterator());
+ int to = Math.min(records.size(), from + request.limit());
+ response = GetRecordsResponse.builder().records(new
ArrayList<>(records.subList(from, to)))
+ .nextShardIterator(newIterator(to)).build();
+ }
+ Runnable hook = onGetRecords;
+ if (hook != null) {
+ hook.run();
+ }
+ hook = onRecordsReturned;
+ if (hook != null && response.hasRecords() &&
!response.records().isEmpty()) {
+ hook.run();
+ }
+ return response;
+ }
+
+ private String newIterator(int position) {
+ String iterator = "iterator-" + (iterators.size() + 1);
+ iterators.put(iterator, position);
+ return iterator;
+ }
+
+ @Override
+ public DynamoDbStreamsServiceClientConfiguration
serviceClientConfiguration() {
+ return null;
+ }
+
+ @Override
+ public String serviceName() {
+ return DynamoDbStreamsClient.SERVICE_NAME;
+ }
+
+ @Override
+ public void close() {
+ // nothing to close
+ }
+ }
+}