GH-22: Add KinesisMessageDrivenChannelAdapter

Fixes GH-22 (https://github.com/spring-projects/spring-integration-aws/issues/22)

Rework logic to the `dispatching` and tasks

* Add `LimitExceededException` and configurable retry logic for the `describeStream`
* Skip `CLOSED` shards which has been read before according stored `checkpoint`. Will be useful for `after resharding` algorithm
* Add `adapt to resharding` logic
* Add `KinesisMessageDrivenChannelAdapterTests` based on mocks
* Add Thread Affinity for `ShardConsumer`s via `concurrency` option
* Introduce `concurrency` option and `ConsumerInvoker`.
  `ShardConsumer`s are now distributed between `ConsumerInvoker`s if `concurrency > 0`
  `ConsumerInvoker`s are scheduled to the `ConsumerExecutor` as a `isLongLived` tasks
  The concurrency is adjusted if there are no more `ShardConsumer`s to process (`STOP` state because of closed shard).
  At the same time newly populated `ShardConsumer`s (e.g. after resharding) is distributed evenly between existing `ConsumerExecutor`s if `maxConcurrency` is exceeded.
Otherwise fresh `ConsumerExecutor` is started for new `ShardConsumer`
This commit is contained in:
Artem Bilan
2016-10-19 17:47:55 -04:00
committed by Marius Bogoevici
parent 0e31261545
commit ef2f5f7aef
12 changed files with 1669 additions and 1 deletions

View File

@@ -91,6 +91,8 @@ dependencies {
compile("com.amazonaws:aws-java-sdk-kinesis:$awsSdkVersion", optional)
compile("com.amazonaws:amazon-kinesis-client:1.7.0", optional)
compile("javax.servlet:javax.servlet-api:$servletApiVersion", provided)
testCompile "org.springframework.integration:spring-integration-test:$springIntegrationVersion"

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed 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.springframework.integration.aws.inbound.kinesis;
/**
* The listener mode, record or batch.
*
* @author Artem Bilan
*
* @since 1.1
*/
public enum CheckpointMode {
/**
* Checkpoint after each processed record.
* Makes sense only if {@link ListenerMode#record} is used.
*/
record,
/**
* Checkpoint after each processed batch of records.
*/
batch,
/**
* Checkpoint on demand via provided to the message {@link Checkpointer} callback.
*/
manual
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed 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.springframework.integration.aws.inbound.kinesis;
/**
* A callback for target record process to perform checkpoint on the related shard.
*
* @author Artem Bilan
* @since 1.1
*/
public interface Checkpointer {
void checkpoint();
void checkpoint(String sequenceNumber);
}

View File

@@ -0,0 +1,893 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed 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.springframework.integration.aws.inbound.kinesis;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.core.convert.converter.Converter;
import org.springframework.core.serializer.support.DeserializingConverter;
import org.springframework.integration.aws.support.AwsHeaders;
import org.springframework.integration.endpoint.MessageProducerSupport;
import org.springframework.integration.metadata.MetadataStore;
import org.springframework.integration.metadata.SimpleMetadataStore;
import org.springframework.integration.support.AbstractIntegrationMessageBuilder;
import org.springframework.integration.support.management.IntegrationManagedResource;
import org.springframework.jmx.export.annotation.ManagedOperation;
import org.springframework.jmx.export.annotation.ManagedResource;
import org.springframework.scheduling.SchedulingAwareRunnable;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import com.amazonaws.services.kinesis.AmazonKinesis;
import com.amazonaws.services.kinesis.clientlibrary.utils.NamedThreadFactory;
import com.amazonaws.services.kinesis.model.DescribeStreamRequest;
import com.amazonaws.services.kinesis.model.DescribeStreamResult;
import com.amazonaws.services.kinesis.model.GetRecordsRequest;
import com.amazonaws.services.kinesis.model.GetRecordsResult;
import com.amazonaws.services.kinesis.model.GetShardIteratorRequest;
import com.amazonaws.services.kinesis.model.LimitExceededException;
import com.amazonaws.services.kinesis.model.Record;
import com.amazonaws.services.kinesis.model.ResourceNotFoundException;
import com.amazonaws.services.kinesis.model.Shard;
import com.amazonaws.services.kinesis.model.ShardIteratorType;
import com.amazonaws.services.kinesis.model.StreamStatus;
/**
* The {@link MessageProducerSupport} implementation for receiving data from Amazon Kinesis stream(s).
*
* @author Artem Bilan
*
* @since 1.1
*/
@ManagedResource
@IntegrationManagedResource
public class KinesisMessageDrivenChannelAdapter extends MessageProducerSupport implements DisposableBean {
private final AmazonKinesis amazonKinesis;
private final String[] streams;
private final Set<KinesisShardOffset> shardOffsets = new HashSet<>();
private final Map<KinesisShardOffset, ShardConsumer> shardConsumers = new ConcurrentHashMap<>();
private final Set<String> inResharding = new ConcurrentSkipListSet<>();
private final List<ConsumerInvoker> consumerInvokers = new ArrayList<>();
private String consumerGroup = "SpringIntegration";
private MetadataStore checkpointStore = new SimpleMetadataStore();
private Executor dispatcherExecutor;
private boolean dispatcherExecutorExplicitlySet;
private Executor consumerExecutor;
private boolean consumerExecutorExplicitlySet;
private int maxConcurrency;
private int concurrency;
private KinesisShardOffset streamInitialSequence = KinesisShardOffset.latest();
private Converter<byte[], Object> converter = new DeserializingConverter();
private ListenerMode listenerMode = ListenerMode.record;
private CheckpointMode checkpointMode = CheckpointMode.batch;
private int recordsLimit = 25;
private int consumerBackoff = 1000;
private int startTimeout = 60 * 1000;
private int describeStreamBackoff = 1000;
private int describeStreamRetries = 50;
private boolean resetCheckpoints;
private volatile boolean active;
private volatile int consumerInvokerMaxCapacity;
public KinesisMessageDrivenChannelAdapter(AmazonKinesis amazonKinesis, String... streams) {
Assert.notNull(amazonKinesis, "'amazonKinesis' must not be null.");
Assert.notEmpty(streams, "'streams' must not be null.");
this.amazonKinesis = amazonKinesis;
this.streams = Arrays.copyOf(streams, streams.length);
}
public KinesisMessageDrivenChannelAdapter(AmazonKinesis amazonKinesis,
KinesisShardOffset... shardOffsets) {
Assert.notNull(amazonKinesis, "'amazonKinesis' must not be null.");
Assert.notEmpty(shardOffsets, "'shardOffsets' must not be null.");
Assert.noNullElements(shardOffsets, "'shardOffsets' must not contain null elements.");
for (KinesisShardOffset shardOffset : shardOffsets) {
Assert.isTrue(StringUtils.hasText(shardOffset.getStream()) && StringUtils.hasText(shardOffset.getShard()),
"The 'shardOffsets' must be provided with particular 'stream' and 'shard' values.");
this.shardOffsets.add(new KinesisShardOffset(shardOffset));
}
this.amazonKinesis = amazonKinesis;
this.streams = null;
}
public void setConsumerGroup(String consumerGroup) {
Assert.hasText(consumerGroup, "'consumerGroup' must not be empty");
this.consumerGroup = consumerGroup;
}
public void setCheckpointStore(MetadataStore checkpointStore) {
Assert.notNull(checkpointStore, "'checkpointStore' must not be null");
this.checkpointStore = checkpointStore;
}
public void setConsumerExecutor(Executor executor) {
Assert.notNull(executor, "'executor' must not be null");
this.consumerExecutor = executor;
this.consumerExecutorExplicitlySet = true;
}
public void setDispatcherExecutor(Executor dispatcherExecutor) {
this.dispatcherExecutor = dispatcherExecutor;
this.dispatcherExecutorExplicitlySet = true;
}
public void setStreamInitialSequence(KinesisShardOffset streamInitialSequence) {
Assert.notNull(streamInitialSequence, "'streamInitialSequence' must not be null");
this.streamInitialSequence = streamInitialSequence;
}
public void setConverter(Converter<byte[], Object> converter) {
Assert.notNull(converter, "'converter' must not be null");
this.converter = converter;
}
public void setListenerMode(ListenerMode listenerMode) {
Assert.notNull(listenerMode, "'listenerMode' must not be null");
this.listenerMode = listenerMode;
}
public void setCheckpointMode(CheckpointMode checkpointMode) {
Assert.notNull(checkpointMode, "'checkpointMode' must not be null");
this.checkpointMode = checkpointMode;
}
public void setRecordsLimit(int recordsLimit) {
Assert.isTrue(recordsLimit > 0, "'recordsLimit' must be more than 0");
this.recordsLimit = recordsLimit;
}
public void setConsumerBackoff(int consumerBackoff) {
this.consumerBackoff = Math.max(1000, consumerBackoff);
}
public void setDescribeStreamBackoff(int describeStreamBackoff) {
this.describeStreamBackoff = Math.max(1000, describeStreamBackoff);
}
public void setDescribeStreamRetries(int describeStreamRetries) {
Assert.isTrue(describeStreamRetries > 0, "'describeStreamRetries' must be more than 0");
this.describeStreamRetries = describeStreamRetries;
}
public void setStartTimeout(int startTimeout) {
Assert.isTrue(startTimeout > 0, "'startTimeout' must be more than 0");
this.startTimeout = startTimeout;
}
/**
* The maximum number of concurrent {@link ConsumerInvoker}s running.
* The {@link ShardConsumer}s are evenly distributed between {@link ConsumerInvoker}s.
* Messages from within the same shard will be processed sequentially.
* In other words each shard is tied with the particular thread.
* By default the concurrency is unlimited and shard
* is processed in the {@link #consumerExecutor} directly.
* @param concurrency the concurrency maximum number
*/
public void setConcurrency(int concurrency) {
this.maxConcurrency = concurrency;
}
@Override
protected void onInit() {
super.onInit();
if (this.consumerExecutor == null) {
this.consumerExecutor = Executors.newCachedThreadPool(
new NamedThreadFactory((getComponentName() == null
? ""
: getComponentName())
+ "-kinesis-consumer-"));
}
if (this.dispatcherExecutor == null) {
this.dispatcherExecutor =
Executors.newCachedThreadPool(
new NamedThreadFactory((getComponentName() == null
? ""
: getComponentName())
+ "-kinesis-dispatcher-"));
}
}
@Override
public void destroy() throws Exception {
if (!this.consumerExecutorExplicitlySet) {
((ExecutorService) this.consumerExecutor).shutdownNow();
}
if (!this.dispatcherExecutorExplicitlySet) {
((ExecutorService) this.dispatcherExecutor).shutdownNow();
}
}
@ManagedOperation
public void stopConsumer(String stream, String shard) {
ShardConsumer shardConsumer = this.shardConsumers.remove(KinesisShardOffset.latest(stream, shard));
if (shardConsumer != null) {
shardConsumer.stop();
}
else {
if (this.logger.isDebugEnabled()) {
this.logger.debug("There is no ShardConsumer for shard [" + shard + "] in stream [" + shard
+ "] to stop.");
}
}
}
@ManagedOperation
public void startConsumer(String stream, String shard) {
KinesisShardOffset shardOffsetForSearch = KinesisShardOffset.latest(stream, shard);
ShardConsumer shardConsumer = this.shardConsumers.get(shardOffsetForSearch);
if (shardConsumer != null) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("The [" + shardConsumer + "] has been started before.");
}
}
else {
synchronized (this.shardOffsets) {
for (KinesisShardOffset shardOffset : this.shardOffsets) {
if (shardOffsetForSearch.equals(shardOffset)) {
populateConsumer(shardOffset);
break;
}
}
}
}
}
@ManagedOperation
public void resetCheckpointForShardToLatest(String stream, String shard) {
restartShardConsumerForOffset(KinesisShardOffset.latest(stream, shard));
}
@ManagedOperation
public void resetCheckpointForShardToTrimHorizon(String stream, String shard) {
restartShardConsumerForOffset(KinesisShardOffset.trimHorizon(stream, shard));
}
@ManagedOperation
public void resetCheckpointForShardToSequenceNumber(String stream, String shard, String sequenceNumber) {
restartShardConsumerForOffset(KinesisShardOffset.atSequenceNumber(stream, shard, sequenceNumber));
}
@ManagedOperation
public void resetCheckpointForShardAtTimestamp(String stream, String shard, long timestamp) {
restartShardConsumerForOffset(KinesisShardOffset.atTimestamp(stream, shard, new Date(timestamp)));
}
private void restartShardConsumerForOffset(KinesisShardOffset shardOffset) {
Assert.isTrue(this.shardOffsets.contains(shardOffset),
"The [" + this +
"] doesn't operate shard [" + shardOffset.getShard() +
"] for stream [" + shardOffset.getStream() + "]");
if (logger.isDebugEnabled()) {
logger.debug("Resetting consumer for [" + shardOffset + "]...");
}
shardOffset.reset();
synchronized (this.shardOffsets) {
this.shardOffsets.remove(shardOffset);
this.shardOffsets.add(shardOffset);
}
if (this.active) {
ShardConsumer oldShardConsumer = this.shardConsumers.remove(shardOffset);
if (oldShardConsumer != null) {
oldShardConsumer.close();
}
shardOffset.setReset(true);
populateConsumer(shardOffset);
}
}
@ManagedOperation
public void resetCheckpoints() {
this.resetCheckpoints = true;
if (this.active) {
stopConsumers();
populateConsumers();
}
}
@Override
protected void doStart() {
super.doStart();
if (ListenerMode.batch.equals(this.listenerMode) && CheckpointMode.record.equals(this.checkpointMode)) {
this.checkpointMode = CheckpointMode.batch;
logger.warn("The 'checkpointMode' is overridden from [CheckpointMode.record] to [CheckpointMode.batch] " +
"because it does not make sense in case of [ListenerMode.batch].");
}
if (this.streams != null) {
populateShardsForStreams();
}
populateConsumers();
this.active = true;
this.concurrency = Math.min(this.maxConcurrency, this.shardOffsets.size());
for (int i = 0; i < this.concurrency; i++) {
Collection<ShardConsumer> shardConsumers = shardConsumerSubset(i);
this.consumerInvokerMaxCapacity = Math.max(this.consumerInvokerMaxCapacity, shardConsumers.size());
ConsumerInvoker consumerInvoker = new ConsumerInvoker(shardConsumers);
this.consumerInvokers.add(consumerInvoker);
this.consumerExecutor.execute(consumerInvoker);
}
this.dispatcherExecutor.execute(new ConsumerDispatcher());
}
private Collection<ShardConsumer> shardConsumerSubset(int i) {
List<ShardConsumer> shardConsumers = new ArrayList<>(this.shardConsumers.values());
if (this.concurrency == 1) {
return shardConsumers;
}
else {
int numConsumers = shardConsumers.size();
if (numConsumers == this.concurrency) {
return Collections.singleton(shardConsumers.get(i));
}
else {
int perInvoker = numConsumers / this.concurrency;
List<ShardConsumer> subset;
if (i == this.concurrency - 1) {
subset = shardConsumers.subList(i * perInvoker, numConsumers);
}
else {
subset = shardConsumers.subList(i * perInvoker, (i + 1) * perInvoker);
}
return subset;
}
}
}
private void populateShardsForStreams() {
this.shardOffsets.clear();
final CountDownLatch shardsGatherLatch = new CountDownLatch(this.streams.length);
for (final String stream : this.streams) {
populateShardsForStream(stream, shardsGatherLatch);
}
try {
if (!shardsGatherLatch.await(this.startTimeout, TimeUnit.MILLISECONDS)) {
throw new IllegalStateException("The [ "
+ this +
"] could not start during timeout: " + this.startTimeout);
}
}
catch (InterruptedException e) {
throw new IllegalStateException("The [ " + this + "] has been interrupted from start.");
}
}
private void populateShardsForStream(final String stream, final CountDownLatch shardsGatherLatch) {
this.dispatcherExecutor.execute(new Runnable() {
@Override
public void run() {
try {
int describeStreamRetries = 0;
List<Shard> shards = new ArrayList<>();
String exclusiveStartShardId = null;
while (true) {
DescribeStreamRequest describeStreamRequest = new DescribeStreamRequest()
.withStreamName(stream)
.withExclusiveStartShardId(exclusiveStartShardId);
DescribeStreamResult describeStreamResult = null;
// Call DescribeStream, with backoff and retries (if we get LimitExceededException).
try {
describeStreamResult = KinesisMessageDrivenChannelAdapter.this.amazonKinesis
.describeStream(describeStreamRequest);
}
catch (LimitExceededException e) {
logger.info("Got LimitExceededException when describing stream [" + stream + "]. " +
"Backing off for [" +
KinesisMessageDrivenChannelAdapter.this.describeStreamBackoff + "] millis.");
}
if (describeStreamResult == null || !StreamStatus.ACTIVE.toString()
.equals(describeStreamResult.getStreamDescription().getStreamStatus())) {
if (describeStreamRetries++ >
KinesisMessageDrivenChannelAdapter.this.describeStreamRetries) {
ResourceNotFoundException resourceNotFoundException =
new ResourceNotFoundException("The stream [" + stream +
"] isn't ACTIVE or doesn't exist.");
resourceNotFoundException.setServiceName("Kinesis");
throw resourceNotFoundException;
}
try {
Thread.sleep(KinesisMessageDrivenChannelAdapter.this.describeStreamBackoff);
continue;
}
catch (InterruptedException e) {
Thread.interrupted();
throw new IllegalStateException("The [describeStream] thread for the stream ["
+ stream + "] has been interrupted.", e);
}
}
for (Shard shard : describeStreamResult.getStreamDescription().getShards()) {
String endingSequenceNumber = shard.getSequenceNumberRange().getEndingSequenceNumber();
if (endingSequenceNumber != null) {
String key = KinesisMessageDrivenChannelAdapter.this.consumerGroup +
":" + stream +
":" + shard.getShardId();
String checkpoint =
KinesisMessageDrivenChannelAdapter.this.checkpointStore.get(key);
if (checkpoint != null &&
new BigInteger(endingSequenceNumber)
.compareTo(new BigInteger(checkpoint)) <= 0) {
// Skip CLOSED shard which has been read before according a checkpoint
continue;
}
}
shards.add(shard);
}
if (describeStreamResult.getStreamDescription().getHasMoreShards()) {
exclusiveStartShardId = shards.get(shards.size() - 1).getShardId();
}
else {
break;
}
}
for (Shard shard : shards) {
KinesisShardOffset shardOffset =
new KinesisShardOffset(KinesisMessageDrivenChannelAdapter.this.streamInitialSequence);
shardOffset.setShard(shard.getShardId());
shardOffset.setStream(stream);
boolean addedOffset;
synchronized (KinesisMessageDrivenChannelAdapter.this.shardOffsets) {
addedOffset = KinesisMessageDrivenChannelAdapter.this.shardOffsets.add(shardOffset);
}
if (addedOffset
&& shardsGatherLatch == null
&& KinesisMessageDrivenChannelAdapter.this.active) {
populateConsumer(shardOffset);
}
}
}
finally {
if (shardsGatherLatch != null) {
shardsGatherLatch.countDown();
}
KinesisMessageDrivenChannelAdapter.this.inResharding.remove(stream);
}
}
});
}
private void populateConsumers() {
synchronized (this.shardOffsets) {
for (KinesisShardOffset shardOffset : this.shardOffsets) {
shardOffset.setReset(this.resetCheckpoints);
populateConsumer(shardOffset);
}
}
this.resetCheckpoints = false;
}
private void populateConsumer(KinesisShardOffset shardOffset) {
ShardConsumer shardConsumer = new ShardConsumer(shardOffset);
this.shardConsumers.put(shardOffset, shardConsumer);
if (this.active) {
synchronized (this.consumerInvokers) {
if (this.consumerInvokers.size() < this.maxConcurrency) {
ConsumerInvoker consumerInvoker = new ConsumerInvoker(Collections.singleton(shardConsumer));
this.consumerInvokers.add(consumerInvoker);
this.consumerExecutor.execute(consumerInvoker);
}
else {
for (ConsumerInvoker consumerInvoker : this.consumerInvokers) {
if (consumerInvoker.consumers.size() < this.consumerInvokerMaxCapacity) {
consumerInvoker.addConsumer(shardConsumer);
return;
}
}
ConsumerInvoker firstConsumerInvoker = this.consumerInvokers.get(0);
firstConsumerInvoker.addConsumer(shardConsumer);
this.consumerInvokerMaxCapacity = firstConsumerInvoker.consumers.size();
}
}
}
}
@Override
protected void doStop() {
this.active = false;
super.doStop();
stopConsumers();
}
private void stopConsumers() {
for (ShardConsumer shardConsumer : this.shardConsumers.values()) {
shardConsumer.stop();
}
this.shardConsumers.clear();
this.consumerInvokers.clear();
}
@Override
public String toString() {
return "KinesisMessageDrivenChannelAdapter{" +
"shardOffsets=" + this.shardOffsets +
", consumerGroup='" + this.consumerGroup + '\'' +
'}';
}
private final class ConsumerDispatcher implements SchedulingAwareRunnable {
private final Set<String> inReshardingProcess = new HashSet<>();
@Override
public void run() {
// We can't rely on the 'isRunning()' because of race condition,
// when 'running' is set after submitting this task
while (KinesisMessageDrivenChannelAdapter.this.active) {
for (String stream : KinesisMessageDrivenChannelAdapter.this.inResharding) {
// Local store to avoid several tasks for the same 'stream'
if (this.inReshardingProcess.add(stream)) {
if (logger.isDebugEnabled()) {
logger.debug("Resharding has happened for stream [" + stream + "]. Rebalancing...");
}
populateShardsForStream(stream, null);
}
}
for (Iterator<ShardConsumer> iterator =
KinesisMessageDrivenChannelAdapter.this.shardConsumers
.values()
.iterator();
iterator.hasNext(); ) {
ShardConsumer shardConsumer = iterator.next();
shardConsumer.execute();
if (ConsumerState.STOP == shardConsumer.state) {
iterator.remove();
if (KinesisMessageDrivenChannelAdapter.this.streams != null
&& shardConsumer.shardIterator == null) {
// Shard is CLOSED and we are capable for resharding
KinesisShardOffset shardOffset = shardConsumer.shardOffset;
String stream = shardOffset.getStream();
if (KinesisMessageDrivenChannelAdapter.this.inResharding.add(stream)) {
this.inReshardingProcess.remove(stream);
synchronized (KinesisMessageDrivenChannelAdapter.this.shardOffsets) {
KinesisMessageDrivenChannelAdapter.this.shardOffsets.remove(shardOffset);
}
}
}
}
}
}
}
@Override
public boolean isLongLived() {
return true;
}
}
private final class ShardConsumer {
private final KinesisShardOffset shardOffset;
private final ShardCheckpointer checkpointer;
private final Runnable processTask = processTask();
private volatile ConsumerState state = ConsumerState.NEW;
private volatile Runnable task;
private volatile String shardIterator;
private volatile long sleepUntil;
ShardConsumer(KinesisShardOffset shardOffset) {
this.shardOffset = new KinesisShardOffset(shardOffset);
String key = KinesisMessageDrivenChannelAdapter.this.consumerGroup +
":" + shardOffset.getStream() +
":" + shardOffset.getShard();
this.checkpointer = new ShardCheckpointer(KinesisMessageDrivenChannelAdapter.this.checkpointStore, key);
}
void stop() {
this.state = ConsumerState.STOP;
}
void close() {
stop();
this.checkpointer.close();
}
void execute() {
if (this.task == null) {
switch (this.state) {
case NEW:
this.task = new Runnable() {
@Override
public void run() {
if (logger.isInfoEnabled()) {
logger.info("The [" + this + "] has been started.");
}
if (ShardConsumer.this.shardOffset.isReset()) {
ShardConsumer.this.checkpointer.remove();
}
else {
String checkpoint = ShardConsumer.this.checkpointer.getCheckpoint();
if (checkpoint != null) {
ShardConsumer.this.shardOffset.setSequenceNumber(checkpoint);
ShardConsumer.this.shardOffset
.setIteratorType(ShardIteratorType.AFTER_SEQUENCE_NUMBER);
}
}
GetShardIteratorRequest shardIteratorRequest =
ShardConsumer.this.shardOffset.toShardIteratorRequest();
ShardConsumer.this.shardIterator =
KinesisMessageDrivenChannelAdapter.this.amazonKinesis
.getShardIterator(shardIteratorRequest)
.getShardIterator();
if (ConsumerState.STOP != ShardConsumer.this.state) {
ShardConsumer.this.state = ConsumerState.CONSUME;
}
ShardConsumer.this.task = null;
}
};
break;
case CONSUME:
this.task = this.processTask;
break;
case SLEEP:
if (System.currentTimeMillis() >= this.sleepUntil) {
this.state = ConsumerState.CONSUME;
}
this.task = null;
break;
case STOP:
if (this.shardIterator == null) {
if (logger.isInfoEnabled()) {
logger.info("Stopping the [" + this +
"] because the shard has been CLOSED and exhausted.");
}
}
else {
if (logger.isInfoEnabled()) {
logger.info("Stopping the [" + this + "].");
}
}
this.task = null;
break;
}
if (this.task != null && KinesisMessageDrivenChannelAdapter.this.concurrency == 0) {
KinesisMessageDrivenChannelAdapter.this.consumerExecutor.execute(this.task);
}
}
}
private Runnable processTask() {
return new Runnable() {
@Override
public void run() {
GetRecordsRequest getRecordsRequest = new GetRecordsRequest();
getRecordsRequest.setShardIterator(ShardConsumer.this.shardIterator);
getRecordsRequest.setLimit(KinesisMessageDrivenChannelAdapter.this.recordsLimit);
GetRecordsResult result = KinesisMessageDrivenChannelAdapter.this.amazonKinesis
.getRecords(getRecordsRequest);
List<Record> records = result.getRecords();
if (!records.isEmpty()) {
processRecords(records);
}
ShardConsumer.this.shardIterator = result.getNextShardIterator();
if (ShardConsumer.this.shardIterator == null) {
// Shard is closed: nothing to consume any more.
// Resharding is possible.
ShardConsumer.this.state = ConsumerState.STOP;
}
if (ConsumerState.STOP != ShardConsumer.this.state && records.isEmpty()) {
if (logger.isDebugEnabled()) {
logger.debug("No records for [" + ShardConsumer.this +
"] on sequenceNumber [" +
ShardConsumer.this.checkpointer.getLastCheckpointValue() +
"]. Suspend consuming for [" +
KinesisMessageDrivenChannelAdapter.this.consumerBackoff + "] milliseconds.");
}
ShardConsumer.this.sleepUntil = System.currentTimeMillis() +
KinesisMessageDrivenChannelAdapter.this.consumerBackoff;
ShardConsumer.this.state = ConsumerState.SLEEP;
}
ShardConsumer.this.task = null;
}
};
}
private void processRecords(List<Record> records) {
records = this.checkpointer.filterRecords(records);
if (!records.isEmpty()) {
if (logger.isDebugEnabled()) {
logger.debug("Processing records: " + records + " for [" + this + "]");
}
switch (KinesisMessageDrivenChannelAdapter.this.listenerMode) {
case record:
for (Record record : records) {
Object payload =
KinesisMessageDrivenChannelAdapter.this.converter.convert(record.getData().array());
AbstractIntegrationMessageBuilder<Object> messageBuilder = getMessageBuilderFactory()
.withPayload(payload)
.setHeader(AwsHeaders.STREAM, this.shardOffset.getStream())
.setHeader(AwsHeaders.SHARD, this.shardOffset.getShard())
.setHeader(AwsHeaders.PARTITION_KEY, record.getPartitionKey())
.setHeader(AwsHeaders.SEQUENCE_NUMBER, record.getSequenceNumber());
if (CheckpointMode.manual.equals(KinesisMessageDrivenChannelAdapter.this.checkpointMode)) {
messageBuilder.setHeader(AwsHeaders.CHECKPOINTER, this.checkpointer);
}
sendMessage(messageBuilder.build());
if (CheckpointMode.record.equals(KinesisMessageDrivenChannelAdapter.this.checkpointMode)) {
this.checkpointer.checkpoint(record.getSequenceNumber());
}
}
break;
case batch:
AbstractIntegrationMessageBuilder<?> messageBuilder = getMessageBuilderFactory()
.withPayload(records)
.setHeader(AwsHeaders.STREAM, this.shardOffset.getStream())
.setHeader(AwsHeaders.SHARD, this.shardOffset.getShard());
if (CheckpointMode.manual.equals(KinesisMessageDrivenChannelAdapter.this.checkpointMode)) {
messageBuilder.setHeader(AwsHeaders.CHECKPOINTER, this.checkpointer);
}
sendMessage(messageBuilder.build());
break;
}
if (CheckpointMode.batch.equals(KinesisMessageDrivenChannelAdapter.this.checkpointMode)) {
this.checkpointer.checkpoint();
}
}
}
@Override
public String toString() {
return "ShardConsumer{" +
"shardOffset=" + this.shardOffset +
", state=" + this.state +
'}';
}
}
private enum ConsumerState {
NEW, CONSUME, SLEEP, STOP
}
private final class ConsumerInvoker implements SchedulingAwareRunnable {
private final List<ShardConsumer> consumers = new ArrayList<>();
ConsumerInvoker(Collection<ShardConsumer> shardConsumers) {
this.consumers.addAll(shardConsumers);
}
void addConsumer(ShardConsumer shardConsumer) {
this.consumers.add(shardConsumer);
}
@Override
public void run() {
while (KinesisMessageDrivenChannelAdapter.this.active && !this.consumers.isEmpty()) {
for (Iterator<ShardConsumer> iterator = this.consumers.iterator(); iterator.hasNext(); ) {
ShardConsumer shardConsumer = iterator.next();
if (ConsumerState.STOP == shardConsumer.state) {
iterator.remove();
}
else {
if (shardConsumer.task != null) {
shardConsumer.task.run();
}
}
}
synchronized (KinesisMessageDrivenChannelAdapter.this.consumerInvokers) {
// The attempt to survive if ShardConsumer has been added during synchronization
if (this.consumers.isEmpty()) {
KinesisMessageDrivenChannelAdapter.this.consumerInvokers.remove(this);
break;
}
}
}
}
@Override
public boolean isLongLived() {
return true;
}
}
}

View File

@@ -0,0 +1,215 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed 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.springframework.integration.aws.inbound.kinesis;
import java.util.Date;
import java.util.Objects;
import org.springframework.util.Assert;
import com.amazonaws.services.kinesis.model.GetShardIteratorRequest;
import com.amazonaws.services.kinesis.model.ShardIteratorType;
/**
* A model to represent a sequence in the shard for particular {@link ShardIteratorType}.
*
* @author Artem Bilan
* @since 1.1
*/
public class KinesisShardOffset {
private ShardIteratorType iteratorType;
private String sequenceNumber;
private Date timestamp;
private String stream;
private String shard;
private boolean reset;
public KinesisShardOffset(ShardIteratorType iteratorType) {
Assert.notNull(iteratorType, "'iteratorType' must not be null.");
this.iteratorType = iteratorType;
}
public KinesisShardOffset(KinesisShardOffset other) {
this.iteratorType = other.getIteratorType();
this.stream = other.getStream();
this.shard = other.getShard();
this.sequenceNumber = other.getSequenceNumber();
this.timestamp = other.getTimestamp();
this.reset = other.isReset();
}
public void setIteratorType(ShardIteratorType iteratorType) {
this.iteratorType = iteratorType;
}
public ShardIteratorType getIteratorType() {
return this.iteratorType;
}
public void setSequenceNumber(String sequenceNumber) {
this.sequenceNumber = sequenceNumber;
}
public void setTimestamp(Date timestamp) {
this.timestamp = timestamp;
}
public void setStream(String stream) {
this.stream = stream;
}
public void setShard(String shard) {
this.shard = shard;
}
public void setReset(boolean reset) {
this.reset = reset;
}
public String getSequenceNumber() {
return this.sequenceNumber;
}
public Date getTimestamp() {
return this.timestamp;
}
public String getStream() {
return this.stream;
}
public String getShard() {
return this.shard;
}
public boolean isReset() {
return this.reset;
}
public KinesisShardOffset reset() {
this.reset = true;
return this;
}
public GetShardIteratorRequest toShardIteratorRequest() {
Assert.state(this.stream != null && this.shard != null,
"'stream' and 'shard' must not be null for conversion to the GetShardIteratorRequest.");
return new GetShardIteratorRequest()
.withStreamName(this.stream)
.withShardId(this.shard)
.withShardIteratorType(this.iteratorType)
.withStartingSequenceNumber(this.sequenceNumber)
.withTimestamp(this.timestamp);
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
KinesisShardOffset that = (KinesisShardOffset) o;
return Objects.equals(this.stream, that.stream) &&
Objects.equals(this.shard, that.shard);
}
@Override
public int hashCode() {
return Objects.hash(this.stream, this.shard);
}
@Override
public String toString() {
return "KinesisShardOffset{" +
"iteratorType=" + this.iteratorType +
", sequenceNumber='" + this.sequenceNumber + '\'' +
", timestamp=" + this.timestamp +
", stream='" + this.stream + '\'' +
", shard='" + this.shard + '\'' +
", reset=" + this.reset +
'}';
}
public static KinesisShardOffset latest() {
return latest(null, null);
}
public static KinesisShardOffset latest(String stream, String shard) {
KinesisShardOffset kinesisShardOffset = new KinesisShardOffset(ShardIteratorType.LATEST);
kinesisShardOffset.stream = stream;
kinesisShardOffset.shard = shard;
return kinesisShardOffset;
}
public static KinesisShardOffset trimHorizon() {
return trimHorizon(null, null);
}
public static KinesisShardOffset trimHorizon(String stream, String shard) {
KinesisShardOffset kinesisShardOffset = new KinesisShardOffset(ShardIteratorType.TRIM_HORIZON);
kinesisShardOffset.stream = stream;
kinesisShardOffset.shard = shard;
return kinesisShardOffset;
}
public static KinesisShardOffset atSequenceNumber(String sequenceNumber) {
return atSequenceNumber(null, null, sequenceNumber);
}
public static KinesisShardOffset atSequenceNumber(String stream, String shard, String sequenceNumber) {
KinesisShardOffset kinesisShardOffset = new KinesisShardOffset(ShardIteratorType.AT_SEQUENCE_NUMBER);
kinesisShardOffset.stream = stream;
kinesisShardOffset.shard = shard;
kinesisShardOffset.sequenceNumber = sequenceNumber;
return kinesisShardOffset;
}
public static KinesisShardOffset afterSequenceNumber(String sequenceNumber) {
return afterSequenceNumber(null, null, sequenceNumber);
}
public static KinesisShardOffset afterSequenceNumber(String stream, String shard, String sequenceNumber) {
KinesisShardOffset kinesisShardOffset = new KinesisShardOffset(ShardIteratorType.AFTER_SEQUENCE_NUMBER);
kinesisShardOffset.stream = stream;
kinesisShardOffset.shard = shard;
kinesisShardOffset.sequenceNumber = sequenceNumber;
return kinesisShardOffset;
}
public static KinesisShardOffset atTimestamp(Date timestamp) {
return atTimestamp(null, null, timestamp);
}
public static KinesisShardOffset atTimestamp(String stream, String shard, Date timestamp) {
KinesisShardOffset kinesisShardOffset = new KinesisShardOffset(ShardIteratorType.AT_TIMESTAMP);
kinesisShardOffset.stream = stream;
kinesisShardOffset.shard = shard;
kinesisShardOffset.timestamp = timestamp;
return kinesisShardOffset;
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed 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.springframework.integration.aws.inbound.kinesis;
import org.springframework.messaging.Message;
/**
* The listener mode, record or batch.
*
* @author Artem Bilan
*
* @since 1.1
*/
public enum ListenerMode {
/**
* Each {@link Message} will be converted from a single {@code Record}.
*/
record,
/**
* Each {@link Message} will contains {@code List<Record>} if not empty.
*/
batch
}

View File

@@ -0,0 +1,130 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed 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.springframework.integration.aws.inbound.kinesis;
import java.math.BigInteger;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.integration.metadata.MetadataStore;
import com.amazonaws.services.kinesis.model.Record;
/**
* An internal {@link Checkpointer} implementation based on
* provided {@link MetadataStore} and unikey {@code key} for shard.
* <p>
* The instances of this class is created by the {@link KinesisMessageDrivenChannelAdapter}
* for each {@code ShardConsumer}.
*
* @author Artem Bilan
*
* @since 1.1
*/
class ShardCheckpointer implements Checkpointer {
private static final Log logger = LogFactory.getLog(ShardCheckpointer.class);
private final MetadataStore checkpointStore;
private final String key;
private volatile String lastCheckpointValue;
private volatile boolean active = true;
ShardCheckpointer(MetadataStore checkpointStore, String key) {
this.checkpointStore = checkpointStore;
this.key = key;
}
@Override
public void checkpoint() {
checkpoint(this.lastCheckpointValue);
}
@Override
public void checkpoint(String sequenceNumber) {
if (this.active) {
String existingSequence = this.checkpointStore.get(this.key);
if (existingSequence == null ||
new BigInteger(existingSequence).compareTo(new BigInteger(sequenceNumber)) <= 0) {
this.checkpointStore.put(this.key, sequenceNumber);
}
}
else {
if (logger.isInfoEnabled()) {
logger.info("The [" + this + "] has been closed. Checkpoints aren't accepted anymore.");
}
}
}
List<Record> filterRecords(List<Record> records) {
List<Record> recordsToProcess = new LinkedList<>(records);
this.lastCheckpointValue = this.checkpointStore.get(this.key);
if (this.lastCheckpointValue != null) {
for (Iterator<Record> iterator = recordsToProcess.iterator(); iterator.hasNext(); ) {
Record record = iterator.next();
String sequenceNumber = record.getSequenceNumber();
if (new BigInteger(sequenceNumber).compareTo(new BigInteger(this.lastCheckpointValue)) <= 0) {
if (logger.isDebugEnabled()) {
logger.debug("Removing record with sequenceNumber " + sequenceNumber +
" because the sequenceNumber is <= checkpoint(" + this.lastCheckpointValue + ")");
}
iterator.remove();
}
else {
this.lastCheckpointValue = sequenceNumber;
}
}
}
else {
this.lastCheckpointValue = recordsToProcess.get(recordsToProcess.size() - 1).getSequenceNumber();
}
return recordsToProcess;
}
String getCheckpoint() {
return this.checkpointStore.get(this.key);
}
String getLastCheckpointValue() {
return this.lastCheckpointValue;
}
void remove() {
this.checkpointStore.remove(this.key);
}
void close() {
this.active = false;
}
@Override
public String toString() {
return "ShardCheckpointer{" +
"key='" + this.key + '\'' +
", lastCheckpointValue='" + this.lastCheckpointValue + '\'' +
'}';
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2016 the original author or authors.
*
* Licensed 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.
*/
/**
* Provides components for AWS Kinesis.
*/
package org.springframework.integration.aws.inbound.kinesis;

View File

@@ -77,6 +77,7 @@ public class KinesisMessageHandler extends AbstractMessageHandler {
private Expression sendTimeoutExpression = new ValueExpression<>(DEFAULT_SEND_TIMEOUT);
public KinesisMessageHandler(AmazonKinesisAsync amazonKinesis) {
Assert.notNull(amazonKinesis, "'amazonKinesis' must not be null.");
this.amazonKinesis = amazonKinesis;
}

View File

@@ -70,6 +70,11 @@ public abstract class AwsHeaders {
*/
public static final String STREAM = PREFIX + "stream";
/**
* The {@value SHARD} header to represent Kinesis shardId.
*/
public static final String SHARD = PREFIX + "shard";
/**
* The {@value PARTITION_KEY} header for sending/receiving data over Kinesis.
*/
@@ -80,4 +85,9 @@ public abstract class AwsHeaders {
*/
public static final String SEQUENCE_NUMBER = PREFIX + "sequenceNumber";
/**
* The {@value CHECKPOINTER} header for checkpoint the shard sequenceNumber.
*/
public static final String CHECKPOINTER = PREFIX + "checkpointer";
}

View File

@@ -0,0 +1,281 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed 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.springframework.integration.aws.inbound;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import java.nio.ByteBuffer;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.DirectFieldAccessor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.serializer.support.DeserializingConverter;
import org.springframework.core.serializer.support.SerializingConverter;
import org.springframework.integration.aws.inbound.kinesis.CheckpointMode;
import org.springframework.integration.aws.inbound.kinesis.Checkpointer;
import org.springframework.integration.aws.inbound.kinesis.KinesisMessageDrivenChannelAdapter;
import org.springframework.integration.aws.inbound.kinesis.KinesisShardOffset;
import org.springframework.integration.aws.inbound.kinesis.ListenerMode;
import org.springframework.integration.aws.support.AwsHeaders;
import org.springframework.integration.channel.QueueChannel;
import org.springframework.integration.config.EnableIntegration;
import org.springframework.integration.metadata.MetadataStore;
import org.springframework.integration.metadata.SimpleMetadataStore;
import org.springframework.integration.test.util.TestUtils;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageHeaders;
import org.springframework.messaging.PollableChannel;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.junit4.SpringRunner;
import com.amazonaws.services.kinesis.AmazonKinesis;
import com.amazonaws.services.kinesis.model.DescribeStreamRequest;
import com.amazonaws.services.kinesis.model.DescribeStreamResult;
import com.amazonaws.services.kinesis.model.GetRecordsRequest;
import com.amazonaws.services.kinesis.model.GetRecordsResult;
import com.amazonaws.services.kinesis.model.GetShardIteratorResult;
import com.amazonaws.services.kinesis.model.Record;
import com.amazonaws.services.kinesis.model.SequenceNumberRange;
import com.amazonaws.services.kinesis.model.Shard;
import com.amazonaws.services.kinesis.model.StreamDescription;
import com.amazonaws.services.kinesis.model.StreamStatus;
/**
* @author Artem Bilan
* @since 1.1
*/
@RunWith(SpringRunner.class)
@DirtiesContext
public class KinesisMessageDrivenChannelAdapterTests {
private static final String STREAM1 = "stream1";
@Autowired
private PollableChannel kinesisChannel;
@Autowired
private KinesisMessageDrivenChannelAdapter kinesisMessageDrivenChannelAdapter;
@Autowired
private MetadataStore checkpointStore;
@Test
@SuppressWarnings({ "unchecked", "rawtypes" })
public void testKinesisMessageDrivenChannelAdapter() throws InterruptedException {
final Set<KinesisShardOffset> shardOffsets =
TestUtils.getPropertyValue(this.kinesisMessageDrivenChannelAdapter, "shardOffsets", Set.class);
KinesisShardOffset testOffset1 = KinesisShardOffset.latest(STREAM1, "1");
KinesisShardOffset testOffset2 = KinesisShardOffset.latest(STREAM1, "2");
synchronized (shardOffsets) {
assertThat(shardOffsets).contains(testOffset1, testOffset2);
assertThat(shardOffsets).doesNotContain(KinesisShardOffset.latest(STREAM1, "3"));
}
Map shardConsumers =
TestUtils.getPropertyValue(this.kinesisMessageDrivenChannelAdapter, "shardConsumers", Map.class);
assertThat(shardConsumers).containsKeys(testOffset1, testOffset2);
Message<?> message = this.kinesisChannel.receive(10000);
assertThat(message).isNotNull();
assertThat(message.getPayload()).isEqualTo("foo");
MessageHeaders headers = message.getHeaders();
assertThat(headers.get(AwsHeaders.PARTITION_KEY)).isEqualTo("partition1");
assertThat(headers.get(AwsHeaders.SHARD)).isEqualTo("1");
assertThat(headers.get(AwsHeaders.SEQUENCE_NUMBER)).isEqualTo("1");
assertThat(headers.get(AwsHeaders.STREAM)).isEqualTo(STREAM1);
Checkpointer checkpointer = headers.get(AwsHeaders.CHECKPOINTER, Checkpointer.class);
assertThat(checkpointer).isNotNull();
checkpointer.checkpoint();
message = this.kinesisChannel.receive(10000);
assertThat(message).isNotNull();
assertThat(message.getPayload()).isEqualTo("bar");
headers = message.getHeaders();
assertThat(headers.get(AwsHeaders.PARTITION_KEY)).isEqualTo("partition1");
assertThat(headers.get(AwsHeaders.SHARD)).isEqualTo("1");
assertThat(headers.get(AwsHeaders.SEQUENCE_NUMBER)).isEqualTo("2");
assertThat(headers.get(AwsHeaders.STREAM)).isEqualTo(STREAM1);
assertThat(this.kinesisChannel.receive(10)).isNull();
assertThat(this.checkpointStore.get("SpringIntegration" + ":" + STREAM1 + ":" + "1")).isEqualTo("2");
this.kinesisMessageDrivenChannelAdapter.stop();
this.kinesisMessageDrivenChannelAdapter.setListenerMode(ListenerMode.batch);
this.kinesisMessageDrivenChannelAdapter
.setCheckpointMode(CheckpointMode.record);
this.checkpointStore.put("SpringIntegration" + ":" + STREAM1 + ":" + "1", "1");
this.kinesisMessageDrivenChannelAdapter.start();
message = this.kinesisChannel.receive(10000);
assertThat(message).isNotNull();
assertThat(message.getPayload()).isInstanceOf(List.class);
List<Record> payload = (List<Record>) message.getPayload();
assertThat(payload).size().isEqualTo(1);
Record record = payload.get(0);
assertThat(record.getPartitionKey()).isEqualTo("partition1");
assertThat(record.getSequenceNumber()).isEqualTo("2");
DeserializingConverter deserializingConverter = new DeserializingConverter();
assertThat(deserializingConverter.convert(record.getData().array())).isEqualTo("bar");
int n = 0;
while (n++ < 100) {
if (!this.checkpointStore.get("SpringIntegration" + ":" + STREAM1 + ":" + "1").equals("2")) {
Thread.sleep(100);
}
else {
break;
}
}
assertThat(n).isLessThan(100);
assertThat(this.checkpointStore.get("SpringIntegration" + ":" + STREAM1 + ":" + "1")).isEqualTo("2");
List consumerInvoker =
TestUtils.getPropertyValue(this.kinesisMessageDrivenChannelAdapter, "consumerInvokers", List.class);
assertThat(consumerInvoker.size()).isEqualTo(2);
}
@Configuration
@EnableIntegration
public static class Config {
@Bean
public AmazonKinesis amazonKinesis() {
AmazonKinesis amazonKinesis = mock(AmazonKinesis.class);
given(amazonKinesis.describeStream(new DescribeStreamRequest().withStreamName(STREAM1)))
.willReturn(new DescribeStreamResult()
.withStreamDescription(new StreamDescription()
.withStreamName(STREAM1)
.withStreamStatus(StreamStatus.UPDATING)),
new DescribeStreamResult()
.withStreamDescription(new StreamDescription()
.withStreamName(STREAM1)
.withStreamStatus(StreamStatus.ACTIVE)
.withHasMoreShards(false)
.withShards(new Shard()
.withShardId("1")
.withSequenceNumberRange(new SequenceNumberRange()),
new Shard()
.withShardId("2")
.withSequenceNumberRange(new SequenceNumberRange()),
new Shard()
.withShardId("3")
.withSequenceNumberRange(new SequenceNumberRange()
.withEndingSequenceNumber("1")))));
String shardIterator1 = "shardIterator1";
given(amazonKinesis.getShardIterator(KinesisShardOffset.latest(STREAM1, "1").toShardIteratorRequest()))
.willReturn(new GetShardIteratorResult()
.withShardIterator(shardIterator1));
String shardIterator2 = "shardIterator2";
given(amazonKinesis.getShardIterator(KinesisShardOffset.latest(STREAM1, "2").toShardIteratorRequest()))
.willReturn(new GetShardIteratorResult()
.withShardIterator(shardIterator2));
SerializingConverter serializingConverter = new SerializingConverter();
String shardIterator11 = "shardIterator11";
given(amazonKinesis.getRecords(new GetRecordsRequest()
.withShardIterator(shardIterator1)
.withLimit(25)))
.willReturn(new GetRecordsResult()
.withNextShardIterator(shardIterator11)
.withRecords(new Record()
.withPartitionKey("partition1")
.withSequenceNumber("1")
.withData(ByteBuffer.wrap(serializingConverter.convert("foo"))),
new Record()
.withPartitionKey("partition1")
.withSequenceNumber("2")
.withData(ByteBuffer.wrap(serializingConverter.convert("bar")))));
given(amazonKinesis.getRecords(new GetRecordsRequest()
.withShardIterator(shardIterator2)
.withLimit(25)))
.willReturn(new GetRecordsResult()
.withNextShardIterator(shardIterator2));
given(amazonKinesis.getRecords(new GetRecordsRequest()
.withShardIterator(shardIterator11)
.withLimit(25)))
.willReturn(new GetRecordsResult()
.withNextShardIterator(shardIterator11));
given(amazonKinesis.getShardIterator(KinesisShardOffset.afterSequenceNumber(STREAM1, "1", "1")
.toShardIteratorRequest()))
.willReturn(new GetShardIteratorResult()
.withShardIterator(shardIterator1));
return amazonKinesis;
}
@Bean
public MetadataStore checkpointStore() {
SimpleMetadataStore simpleMetadataStore = new SimpleMetadataStore();
String testKey = "SpringIntegration" + ":" + STREAM1 + ":" + "3";
simpleMetadataStore.put(testKey, "1");
return simpleMetadataStore;
}
@Bean
public KinesisMessageDrivenChannelAdapter kinesisMessageDrivenChannelAdapter() {
KinesisMessageDrivenChannelAdapter adapter =
new KinesisMessageDrivenChannelAdapter(amazonKinesis(), STREAM1);
adapter.setOutputChannel(kinesisChannel());
adapter.setCheckpointStore(checkpointStore());
adapter.setCheckpointMode(CheckpointMode.manual);
adapter.setStartTimeout(10000);
adapter.setDescribeStreamRetries(1);
adapter.setConcurrency(10);
DirectFieldAccessor dfa = new DirectFieldAccessor(adapter);
dfa.setPropertyValue("describeStreamBackoff", 10);
return adapter;
}
@Bean
public PollableChannel kinesisChannel() {
return new QueueChannel();
}
}
}

View File

@@ -2,7 +2,8 @@ log4j.rootCategory=WARN, stdout
log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern=%d %p [%c] - <%m>%n
log4j.appender.stdout.layout.ConversionPattern=%d %5p %c{1} [%t] : %m%n
log4j.category.org.springframework=WARN
log4j.category.org.springframework.integration.aws=WARN
log4j.category.org.springframework.integration=WARN