DATAREDIS-864 - Add support for Redis Streams.

We now support Redis Streams to add, read and consume stream records. We introduced StreamOperations, BoundStreamOperations, and ReactiveStreamOperations to interact with Redis Streams using imperative and reactive programming models. Record represents items within a stream. There are various flavors of Stream Records:

* MapRecord (maps to the hash body used in stream messages).
* Binary MapRecord: byte[] and ByteBuffer variants of MapRecord.
* ObjectRecord: Simple and Complex Objects mapped onto the stream body hash using ObjectHashMapper.

Redis Streams are supported for the Lettuce client only as Jedis has not received yet Redis Stream support.

Messages can be created as Map or as object:

redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value"));

redisTemplate.opsForStream().add(ObjectRecord.create("my-logins", new LoginEvent(…)));

Streams can be consumed by using a StreamMessageListenerContainer that allows for stream subscriptions or a StreamReceiver.

Synchronous Message Listener:

StreamMessageListenerContainer<String, MapRecord<String, String, String>> container = StreamMessageListenerContainer
      .create(connectionFactory);
container.start();

Subscription subscription = container.receive(StreamOffset.fromStart("my-stream"), record -> … );

Reactive Message Receiver:

StreamReceiverOptions<String, ObjectRecord<String, LoginEvent>> receiverOptions = StreamReceiverOptions.builder()
      .targetType(LoginEvent.class).build();

StreamReceiver<String, ObjectRecord<String, LoginEvent>> receiver = StreamReceiver.create(connectionFactory, receiverOptions);

Flux<ObjectRecord<String, LoginEvent>> messages = receiver.receive(StreamOffset.fromStart("my-logins"));

Original Pull Request: #356
This commit is contained in:
Mark Paluch
2018-09-05 11:23:02 +02:00
committed by Christoph Strobl
parent f2544a253a
commit 65754623f6
62 changed files with 8400 additions and 137 deletions

View File

@@ -40,6 +40,7 @@ public class RedisTestProfileValueSource implements ProfileValueSource {
private static final String REDIS_28 = "2.8";
private static final String REDIS_30 = "3.0";
private static final String REDIS_32 = "3.2";
private static final String REDIS_50 = "4.9";
private static final String REDIS_VERSION_KEY = "redisVersion";
private static RedisTestProfileValueSource INSTANCE;
@@ -96,6 +97,10 @@ public class RedisTestProfileValueSource implements ProfileValueSource {
return System.getProperty(key);
}
if (redisVersion.compareTo(RedisVersionUtils.parseVersion(REDIS_50)) >= 0) {
return REDIS_50;
}
if (redisVersion.compareTo(RedisVersionUtils.parseVersion(REDIS_32)) >= 0) {
return REDIS_32;
}

View File

@@ -64,6 +64,10 @@ import org.springframework.data.redis.RedisVersionUtils;
import org.springframework.data.redis.TestCondition;
import org.springframework.data.redis.connection.RedisGeoCommands.GeoLocation;
import org.springframework.data.redis.connection.RedisListCommands.Position;
import org.springframework.data.redis.connection.RedisStreamCommands.Consumer;
import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset;
import org.springframework.data.redis.connection.RedisStreamCommands.StreamMessage;
import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset;
import org.springframework.data.redis.connection.RedisStringCommands.BitOperation;
import org.springframework.data.redis.connection.RedisStringCommands.SetOption;
import org.springframework.data.redis.connection.RedisZSetCommands.Aggregate;
@@ -1156,11 +1160,11 @@ public abstract class AbstractConnectionIntegrationTests {
@IfProfileValue(name = "redisVersion", value = "4.0+")
public void unlinkReturnsNrOfKeysRemoved() {
connection.set("unlink.this", "Can't track this!");
actual.add(connection.set("unlink.this", "Can't track this!"));
actual.add(connection.unlink("unlink.this", "unlink.that"));
verifyResults(Arrays.asList(new Object[] { 1L }));
verifyResults(Arrays.asList(new Object[] { true, 1L }));
}
@Test // DATAREDIS-693
@@ -2999,6 +3003,101 @@ public abstract class AbstractConnectionIntegrationTests {
assertThat((List<Long>) results.get(1), contains(100L, -56L));
}
@Test // DATAREDIS-864
@IfProfileValue(name = "redisVersion", value = "5.0")
@WithRedisDriver({ RedisDriver.LETTUCE })
public void xAddShouldCreateStream() {
actual.add(connection.xAdd(KEY_1, Collections.singletonMap(KEY_2, VALUE_2)));
actual.add(connection.type(KEY_1));
List<Object> results = getResults();
assertThat(results, hasSize(2));
assertThat((String) results.get(0), containsString("-"));
assertThat(results.get(1), is(DataType.STREAM));
}
@Test // DATAREDIS-864
@IfProfileValue(name = "redisVersion", value = "5.0")
@WithRedisDriver({ RedisDriver.LETTUCE })
public void xReadShouldReadMessage() {
actual.add(connection.xAdd(KEY_1, Collections.singletonMap(KEY_2, VALUE_2)));
actual.add(connection.xReadAsString(StreamOffset.create(KEY_1, ReadOffset.from("0"))));
List<Object> results = getResults();
List<StreamMessage<String, String>> messages = (List) results.get(1);
assertThat(messages.get(0).getStream(), is(KEY_1));
assertThat(messages.get(0).getBody(), is(Collections.singletonMap(KEY_2, VALUE_2)));
}
@Test // DATAREDIS-864
@IfProfileValue(name = "redisVersion", value = "5.0")
@WithRedisDriver({ RedisDriver.LETTUCE })
public void xReadGroupShouldReadMessage() {
actual.add(connection.xAdd(KEY_1, Collections.singletonMap(KEY_2, VALUE_2)));
actual.add(connection.xGroupCreate(KEY_1, ReadOffset.from("0"), "my-group"));
actual.add(connection.xReadGroupAsString(Consumer.from("my-group", "my-consumer"), StreamOffset.create(KEY_1, ReadOffset.lastConsumed())));
actual.add(connection.xReadGroupAsString(Consumer.from("my-group", "my-consumer"), StreamOffset.create(KEY_1, ReadOffset.lastConsumed())));
List<Object> results = getResults();
List<StreamMessage<String, String>> messages = (List) results.get(2);
assertThat(messages.get(0).getStream(), is(KEY_1));
assertThat(messages.get(0).getBody(), is(Collections.singletonMap(KEY_2, VALUE_2)));
assertThat((List<StreamMessage>) results.get(3), is(empty()));
}
@Test // DATAREDIS-864
@IfProfileValue(name = "redisVersion", value = "5.0")
@WithRedisDriver({ RedisDriver.LETTUCE })
public void xRangeShouldReportMessages() {
actual.add(connection.xAdd(KEY_1, Collections.singletonMap(KEY_2, VALUE_2)));
actual.add(connection.xAdd(KEY_1, Collections.singletonMap(KEY_3, VALUE_3)));
actual.add(connection.xRange(KEY_1, org.springframework.data.domain.Range.unbounded()));
List<Object> results = getResults();
assertThat(results, hasSize(3));
assertThat((String) results.get(0), containsString("-"));
List<StreamMessage<String, String>> messages = (List) results.get(2);
assertThat(messages.get(0).getStream(), is(KEY_1));
assertThat(messages.get(0).getBody(), is(Collections.singletonMap(KEY_2, VALUE_2)));
assertThat(messages.get(1).getStream(), is(KEY_1));
assertThat(messages.get(1).getBody(), is(Collections.singletonMap(KEY_3, VALUE_3)));
}
@Test // DATAREDIS-864
@IfProfileValue(name = "redisVersion", value = "5.0")
@WithRedisDriver({ RedisDriver.LETTUCE })
public void xRevRangeShouldReportMessages() {
actual.add(connection.xAdd(KEY_1, Collections.singletonMap(KEY_2, VALUE_2)));
actual.add(connection.xAdd(KEY_1, Collections.singletonMap(KEY_3, VALUE_3)));
actual.add(connection.xRevRange(KEY_1, org.springframework.data.domain.Range.unbounded()));
List<Object> results = getResults();
assertThat(results, hasSize(3));
assertThat((String) results.get(0), containsString("-"));
List<StreamMessage<String, String>> messages = (List) results.get(2);
assertThat(messages.get(0).getStream(), is(KEY_1));
assertThat(messages.get(0).getBody(), is(Collections.singletonMap(KEY_3, VALUE_3)));
assertThat(messages.get(1).getStream(), is(KEY_1));
assertThat(messages.get(1).getBody(), is(Collections.singletonMap(KEY_2, VALUE_2)));
}
protected void verifyResults(List<Object> expected) {
assertEquals(expected, getResults());
}

View File

@@ -29,6 +29,7 @@ import org.junit.ClassRule;
import org.junit.Ignore;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.data.redis.connection.AbstractConnectionIntegrationTests;
import org.springframework.data.redis.connection.RedisSentinelConfiguration;
@@ -37,7 +38,9 @@ import org.springframework.data.redis.connection.RedisServer;
import org.springframework.data.redis.connection.ReturnType;
import org.springframework.data.redis.test.util.MinimumRedisVersionRule;
import org.springframework.data.redis.test.util.RedisSentinelRule;
import org.springframework.data.redis.test.util.RelaxedJUnit4ClassRunner;
import org.springframework.test.annotation.IfProfileValue;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.util.ReflectionTestUtils;
/**
@@ -45,6 +48,8 @@ import org.springframework.test.util.ReflectionTestUtils;
* @author Thomas Darimont
* @author Mark Paluch
*/
@RunWith(RelaxedJUnit4ClassRunner.class)
@ContextConfiguration("JedisConnectionIntegrationTests-context.xml")
public class JedisSentinelIntegrationTests extends AbstractConnectionIntegrationTests {
private static final String MASTER_NAME = "mymaster";

View File

@@ -83,7 +83,7 @@ public abstract class LettuceReactiveCommandsTestsBase {
RedisClusterCommands<String, String> nativeCommands;
RedisClusterCommands<ByteBuffer, ByteBuffer> nativeBinaryCommands;
@Parameterized.Parameters(name = "{2}")
@Parameterized.Parameters(name = "{3}")
public static List<Object[]> parameters() {
LettuceRedisClientProvider standalone = LettuceRedisClientProvider.local();

View File

@@ -0,0 +1,173 @@
/*
* Copyright 2018 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.data.redis.connection.lettuce;
import static org.assertj.core.api.Assertions.*;
import static org.junit.Assume.*;
import io.lettuce.core.XReadArgs;
import reactor.test.StepVerifier;
import java.util.Collections;
import org.junit.Before;
import org.junit.Test;
import org.springframework.data.domain.Range;
import org.springframework.data.redis.RedisTestProfileValueSource;
import org.springframework.data.redis.connection.RedisStreamCommands.Consumer;
import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset;
import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset;
import org.springframework.data.redis.connection.RedisZSetCommands.Limit;
/**
* Integration tests for {@link LettuceReactiveStreamCommands}.
*
* @author Mark Paluch
*/
public class LettuceReactiveStreamCommandsTests extends LettuceReactiveCommandsTestsBase {
@Before
public void before() {
// TODO: Upgrade to 5.0
assumeTrue(RedisTestProfileValueSource.matches("redisVersion", "4.9"));
}
@Test // DATAREDIS-864
public void xAddShouldAddMessage() {
connection.streamCommands().xAdd(KEY_1_BBUFFER, Collections.singletonMap(KEY_2_BBUFFER, VALUE_2_BBUFFER)) //
.as(StepVerifier::create) //
.expectNextCount(1) //
.verifyComplete();
connection.streamCommands().xLen(KEY_1_BBUFFER) //
.as(StepVerifier::create) //
.expectNext(1L) //
.verifyComplete();
}
@Test // DATAREDIS-864
public void xDelShouldRemoveMessage() {
String messageId = connection.streamCommands()
.xAdd(KEY_1_BBUFFER, Collections.singletonMap(KEY_2_BBUFFER, VALUE_2_BBUFFER)).block();
connection.streamCommands().xDel(KEY_1_BBUFFER, messageId) //
.as(StepVerifier::create) //
.expectNext(1L) //
.verifyComplete();
connection.streamCommands().xLen(KEY_1_BBUFFER) //
.as(StepVerifier::create) //
.expectNext(0L) //
.verifyComplete();
}
@Test // DATAREDIS-864
public void xRangeShouldReportMessages() {
connection.streamCommands().xAdd(KEY_1_BBUFFER, Collections.singletonMap(KEY_1_BBUFFER, VALUE_1_BBUFFER)) //
.as(StepVerifier::create) //
.expectNextCount(1) //
.verifyComplete();
connection.streamCommands().xAdd(KEY_1_BBUFFER, Collections.singletonMap(KEY_2_BBUFFER, VALUE_2_BBUFFER)) //
.as(StepVerifier::create) //
.expectNextCount(1) //
.verifyComplete();
connection.streamCommands().xRange(KEY_1_BBUFFER, Range.unbounded()) //
.as(StepVerifier::create) //
.assertNext(it -> {
assertThat(it.getStream()).isEqualTo(KEY_1_BBUFFER);
assertThat(it.getBody()).containsEntry(KEY_1_BBUFFER, VALUE_1_BBUFFER);
}) //
.expectNextCount(1).verifyComplete();
connection.streamCommands().xRange(KEY_1_BBUFFER, Range.unbounded(), Limit.limit().count(1)) //
.as(StepVerifier::create) //
.assertNext(it -> {
assertThat(it.getStream()).isEqualTo(KEY_1_BBUFFER);
assertThat(it.getBody()).containsEntry(KEY_1_BBUFFER, VALUE_1_BBUFFER);
}) //
.verifyComplete();
}
@Test // DATAREDIS-864
public void xReadShouldReadMessage() {
connection.streamCommands().xAdd(KEY_1_BBUFFER, Collections.singletonMap(KEY_1_BBUFFER, VALUE_1_BBUFFER)) //
.as(StepVerifier::create) //
.expectNextCount(1) //
.verifyComplete();
connection.streamCommands().xRead(StreamOffset.create(KEY_1_BBUFFER, ReadOffset.from("0-0"))) //
.as(StepVerifier::create) //
.assertNext(it -> {
assertThat(it.getStream()).isEqualTo(KEY_1_BBUFFER);
assertThat(it.getBody()).containsEntry(KEY_1_BBUFFER, VALUE_1_BBUFFER);
}) //
.verifyComplete();
}
@Test // DATAREDIS-864
public void xReadGroupShouldReadMessage() {
String initialMessage = nativeCommands.xadd(KEY_1, KEY_1, VALUE_1);
nativeCommands.xgroupCreate(XReadArgs.StreamOffset.from(KEY_1, initialMessage), "my-group");
nativeCommands.xadd(KEY_1, KEY_2, VALUE_2);
connection.streamCommands()
.xReadGroup(Consumer.from("my-group", "my-consumer"),
StreamOffset.create(KEY_1_BBUFFER, ReadOffset.lastConsumed())) //
.as(StepVerifier::create) //
.assertNext(it -> {
assertThat(it.getStream()).isEqualTo(KEY_1_BBUFFER);
assertThat(it.getBody()).containsEntry(KEY_2_BBUFFER, VALUE_2_BBUFFER);
}) //
.verifyComplete();
}
@Test // DATAREDIS-864
public void xRevRangeShouldReportMessages() {
connection.streamCommands().xAdd(KEY_1_BBUFFER, Collections.singletonMap(KEY_1_BBUFFER, VALUE_1_BBUFFER)) //
.as(StepVerifier::create) //
.expectNextCount(1) //
.verifyComplete();
connection.streamCommands().xAdd(KEY_1_BBUFFER, Collections.singletonMap(KEY_2_BBUFFER, VALUE_2_BBUFFER)) //
.as(StepVerifier::create) //
.expectNextCount(1) //
.verifyComplete();
connection.streamCommands().xRevRange(KEY_1_BBUFFER, Range.unbounded(), Limit.limit().count(1)) //
.as(StepVerifier::create) //
.assertNext(it -> {
assertThat(it.getStream()).isEqualTo(KEY_1_BBUFFER);
assertThat(it.getBody()).containsEntry(KEY_2_BBUFFER, VALUE_2_BBUFFER);
}) //
.verifyComplete();
}
}

View File

@@ -27,7 +27,7 @@ import org.springframework.data.redis.RawObjectFactory;
import org.springframework.data.redis.SettingsUtils;
import org.springframework.data.redis.StringObjectFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.GenericToStringSerializer;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
@@ -65,7 +65,7 @@ abstract public class AbstractOperationsTestParams {
throw new RuntimeException("Cannot init XStream", ex);
}
JedisConnectionFactory jedisConnectionFactory = new JedisConnectionFactory(standaloneConfiguration);
LettuceConnectionFactory jedisConnectionFactory = new LettuceConnectionFactory(standaloneConfiguration);
jedisConnectionFactory.afterPropertiesSet();
RedisTemplate<String, String> stringTemplate = new StringRedisTemplate();

View File

@@ -0,0 +1,228 @@
/*
* Copyright 2018 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.data.redis.core;
import static org.assertj.core.api.Assertions.*;
import static org.junit.Assume.*;
import reactor.test.StepVerifier;
import java.util.Collection;
import java.util.Collections;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameters;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Range.Bound;
import org.springframework.data.redis.ConnectionFactoryTracker;
import org.springframework.data.redis.ObjectFactory;
import org.springframework.data.redis.PersonObjectFactory;
import org.springframework.data.redis.RedisTestProfileValueSource;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset;
import org.springframework.data.redis.connection.RedisStreamCommands.StreamMessage;
import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset;
import org.springframework.data.redis.connection.RedisStreamCommands.StreamReadOptions;
import org.springframework.data.redis.connection.RedisZSetCommands.Limit;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.serializer.RedisSerializer;
/**
* Integration tests for {@link DefaultReactiveStreamOperations}.
*
* @author Mark Paluch
*/
@RunWith(Parameterized.class)
@SuppressWarnings("unchecked")
public class DefaultReactiveStreamOperationsTests<K, V> {
private final ReactiveRedisTemplate<K, V> redisTemplate;
private final ReactiveStreamOperations<K, V> streamOperations;
private final ObjectFactory<K> keyFactory;
private final ObjectFactory<V> valueFactory;
@Parameters(name = "{4}")
public static Collection<Object[]> testParams() {
return ReactiveOperationsTestParams.testParams();
}
@AfterClass
public static void cleanUp() {
ConnectionFactoryTracker.cleanUp();
}
/**
* @param redisTemplate
* @param keyFactory
* @param valueFactory
* @param label parameterized test label, no further use besides that.
*/
public DefaultReactiveStreamOperationsTests(ReactiveRedisTemplate<K, V> redisTemplate, ObjectFactory<K> keyFactory,
ObjectFactory<V> valueFactory, RedisSerializer serializer, String label) {
// Currently, only Lettuce supports Redis Streams.
// See https://github.com/xetorthio/jedis/issues/1820
assumeTrue(redisTemplate.getConnectionFactory() instanceof LettuceConnectionFactory);
// TODO: Change to 5.0 after Redis 5 GA
assumeTrue(RedisTestProfileValueSource.matches("redisVersion", "4.9"));
this.redisTemplate = redisTemplate;
this.streamOperations = redisTemplate.opsForStream();
this.keyFactory = keyFactory;
this.valueFactory = valueFactory;
ConnectionFactoryTracker.add(redisTemplate.getConnectionFactory());
}
@Before
public void before() {
RedisConnectionFactory connectionFactory = (RedisConnectionFactory) redisTemplate.getConnectionFactory();
RedisConnection connection = connectionFactory.getConnection();
connection.flushAll();
connection.close();
}
@Test // DATAREDIS-864
public void addShouldAddMessage() {
assumeFalse(valueFactory instanceof PersonObjectFactory);
K key = keyFactory.instance();
V value = valueFactory.instance();
String messageId = streamOperations.add(key, Collections.singletonMap(key, value)).block();
streamOperations.range(key, Range.unbounded()) //
.as(StepVerifier::create) //
.consumeNextWith(actual -> {
assertThat(actual.getId()).isEqualTo(messageId);
assertThat(actual.getStream()).isEqualTo(key);
if (!(key instanceof byte[] || value instanceof byte[])) {
assertThat(actual.getBody()).containsEntry(key, value);
}
}) //
.verifyComplete();
}
@Test // DATAREDIS-864
public void rangeShouldReportMessages() {
assumeFalse(valueFactory instanceof PersonObjectFactory);
K key = keyFactory.instance();
V value = valueFactory.instance();
String messageId1 = streamOperations.add(key, Collections.singletonMap(key, value)).block();
String messageId2 = streamOperations.add(key, Collections.singletonMap(key, value)).block();
streamOperations
.range(key, Range.from(Bound.inclusive(messageId1)).to(Bound.inclusive(messageId2)), Limit.limit().count(1)) //
.as(StepVerifier::create) //
.consumeNextWith(actual -> {
assertThat(actual.getId()).isEqualTo(messageId1);
}) //
.verifyComplete();
}
@Test // DATAREDIS-864
public void reverseRangeShouldReportMessages() {
assumeFalse(valueFactory instanceof PersonObjectFactory);
K key = keyFactory.instance();
V value = valueFactory.instance();
String messageId1 = streamOperations.add(key, Collections.singletonMap(key, value)).block();
String messageId2 = streamOperations.add(key, Collections.singletonMap(key, value)).block();
streamOperations.reverseRange(key, Range.unbounded()).map(StreamMessage::getId) //
.as(StepVerifier::create) //
.expectNext(messageId2, messageId1) //
.verifyComplete();
}
@Test // DATAREDIS-864
public void readShouldReadMessage() {
assumeFalse(valueFactory instanceof PersonObjectFactory);
K key = keyFactory.instance();
V value = valueFactory.instance();
String messageId = streamOperations.add(key, Collections.singletonMap(key, value)).block();
streamOperations.read(StreamOffset.create(key, ReadOffset.from("0-0"))) //
.as(StepVerifier::create) //
.consumeNextWith(actual -> {
assertThat(actual.getId()).isEqualTo(messageId);
assertThat(actual.getStream()).isEqualTo(key);
if (!(key instanceof byte[] || value instanceof byte[])) {
assertThat(actual.getBody()).containsEntry(key, value);
}
}).verifyComplete();
}
@Test // DATAREDIS-864
public void readShouldReadMessages() {
assumeFalse(valueFactory instanceof PersonObjectFactory);
K key = keyFactory.instance();
V value = valueFactory.instance();
streamOperations.add(key, Collections.singletonMap(key, value)).block();
streamOperations.add(key, Collections.singletonMap(key, value)).block();
streamOperations.read(StreamReadOptions.empty().count(2), StreamOffset.create(key, ReadOffset.from("0-0"))) //
.as(StepVerifier::create) //
.expectNextCount(2) //
.verifyComplete();
}
@Test // DATAREDIS-864
public void sizeShouldReportStreamSize() {
K key = keyFactory.instance();
V value = valueFactory.instance();
streamOperations.add(key, Collections.singletonMap(key, value)).as(StepVerifier::create).expectNextCount(1)
.verifyComplete();
streamOperations.size(key) //
.as(StepVerifier::create) //
.expectNext(1L) //
.verifyComplete();
streamOperations.add(key, Collections.singletonMap(key, value)).as(StepVerifier::create).expectNextCount(1)
.verifyComplete();
streamOperations.size(key) //
.as(StepVerifier::create) //
.expectNext(2L) //
.verifyComplete();
}
}

View File

@@ -0,0 +1,225 @@
/*
* Copyright 2018 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.data.redis.core;
import static org.assertj.core.api.Assertions.*;
import static org.junit.Assume.*;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameters;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Range.Bound;
import org.springframework.data.redis.ConnectionFactoryTracker;
import org.springframework.data.redis.ObjectFactory;
import org.springframework.data.redis.RedisTestProfileValueSource;
import org.springframework.data.redis.connection.RedisStreamCommands.Consumer;
import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset;
import org.springframework.data.redis.connection.RedisStreamCommands.StreamMessage;
import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset;
import org.springframework.data.redis.connection.RedisStreamCommands.StreamReadOptions;
import org.springframework.data.redis.connection.RedisZSetCommands.Limit;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
/**
* Integration test of {@link DefaultStreamOperations}
*
* @author Mark Paluch
*/
@RunWith(Parameterized.class)
public class DefaultStreamOperationsTests<K, V> {
private RedisTemplate<K, V> redisTemplate;
private ObjectFactory<K> keyFactory;
private ObjectFactory<V> valueFactory;
private StreamOperations<K, V> streamOps;
public DefaultStreamOperationsTests(RedisTemplate<K, V> redisTemplate, ObjectFactory<K> keyFactory,
ObjectFactory<V> valueFactory) {
// Currently, only Lettuce supports Redis Streams.
// See https://github.com/xetorthio/jedis/issues/1820
assumeTrue(redisTemplate.getConnectionFactory() instanceof LettuceConnectionFactory);
// TODO: Change to 5.0 after Redis 5 GA
assumeTrue(RedisTestProfileValueSource.matches("redisVersion", "4.9"));
this.redisTemplate = redisTemplate;
this.keyFactory = keyFactory;
this.valueFactory = valueFactory;
ConnectionFactoryTracker.add(redisTemplate.getConnectionFactory());
}
@Parameters
public static Collection<Object[]> testParams() {
return AbstractOperationsTestParams.testParams();
}
@AfterClass
public static void cleanUp() {
ConnectionFactoryTracker.cleanUp();
}
@Before
public void setUp() {
streamOps = redisTemplate.opsForStream();
redisTemplate.execute((RedisCallback<Object>) connection -> {
connection.flushDb();
return null;
});
}
@Test // DATAREDIS-864
public void addShouldAddMessage() {
K key = keyFactory.instance();
V value = valueFactory.instance();
String messageId = streamOps.add(key, Collections.singletonMap(key, value));
List<StreamMessage<K, V>> messages = streamOps.range(key, Range.unbounded());
assertThat(messages).hasSize(1);
StreamMessage<K, V> message = messages.get(0);
assertThat(message.getId()).isEqualTo(messageId);
assertThat(message.getStream()).isEqualTo(key);
if (!(key instanceof byte[] || value instanceof byte[])) {
assertThat(message.getBody()).containsEntry(key, value);
}
}
@Test // DATAREDIS-864
public void rangeShouldReportMessages() {
K key = keyFactory.instance();
V value = valueFactory.instance();
String messageId1 = streamOps.add(key, Collections.singletonMap(key, value));
String messageId2 = streamOps.add(key, Collections.singletonMap(key, value));
List<StreamMessage<K, V>> messages = streamOps.range(key,
Range.from(Bound.inclusive(messageId1)).to(Bound.inclusive(messageId2)), Limit.limit().count(1));
assertThat(messages).hasSize(1);
StreamMessage<K, V> message = messages.get(0);
assertThat(message.getId()).isEqualTo(messageId1);
}
@Test // DATAREDIS-864
public void reverseRangeShouldReportMessages() {
K key = keyFactory.instance();
V value = valueFactory.instance();
String messageId1 = streamOps.add(key, Collections.singletonMap(key, value));
String messageId2 = streamOps.add(key, Collections.singletonMap(key, value));
List<StreamMessage<K, V>> messages = streamOps.reverseRange(key, Range.unbounded());
assertThat(messages).hasSize(2).extracting("id").containsSequence(messageId2, messageId1);
}
@Test // DATAREDIS-864
public void readShouldReadMessage() {
K key = keyFactory.instance();
V value = valueFactory.instance();
String messageId = streamOps.add(key, Collections.singletonMap(key, value));
List<StreamMessage<K, V>> messages = streamOps.read(StreamOffset.create(key, ReadOffset.from("0-0")));
assertThat(messages).hasSize(1);
StreamMessage<K, V> message = messages.get(0);
assertThat(message.getId()).isEqualTo(messageId);
assertThat(message.getStream()).isEqualTo(key);
if (!(key instanceof byte[] || value instanceof byte[])) {
assertThat(message.getBody()).containsEntry(key, value);
}
}
@Test // DATAREDIS-864
public void readShouldReadMessages() {
K key = keyFactory.instance();
V value = valueFactory.instance();
streamOps.add(key, Collections.singletonMap(key, value));
streamOps.add(key, Collections.singletonMap(key, value));
List<StreamMessage<K, V>> messages = streamOps.read(StreamReadOptions.empty().count(2),
StreamOffset.create(key, ReadOffset.from("0-0")));
assertThat(messages).hasSize(2);
}
@Test // DATAREDIS-864
public void readShouldReadMessageWithConsumerGroup() {
K key = keyFactory.instance();
V value = valueFactory.instance();
String messageId = streamOps.add(key, Collections.singletonMap(key, value));
streamOps.createGroup(key, ReadOffset.from("0-0"), "my-group");
List<StreamMessage<K, V>> messages = streamOps.read(Consumer.from("my-group", "my-consumer"),
StreamOffset.create(key, ReadOffset.lastConsumed()));
assertThat(messages).hasSize(1);
StreamMessage<K, V> message = messages.get(0);
assertThat(message.getId()).isEqualTo(messageId);
assertThat(message.getStream()).isEqualTo(key);
if (!(key instanceof byte[] || value instanceof byte[])) {
assertThat(message.getBody()).containsEntry(key, value);
}
}
@Test // DATAREDIS-864
public void sizeShouldReportStreamSize() {
K key = keyFactory.instance();
V value = valueFactory.instance();
streamOps.add(key, Collections.singletonMap(key, value));
assertThat(streamOps.size(key)).isEqualTo(1);
streamOps.add(key, Collections.singletonMap(key, value));
assertThat(streamOps.size(key)).isEqualTo(2);
}
}

View File

@@ -342,6 +342,11 @@ public class RedisTemplateTests<K, V> {
operations.opsForList().rightPop(key1);
operations.opsForList().size(key1);
operations.exec();
try {
// Await EXEC completion as it's executed on a dedicated connection.
Thread.sleep(100);
} catch (InterruptedException e) {}
operations.opsForValue().set(key1, value1);
operations.opsForValue().get(key1);
return null;
@@ -704,11 +709,7 @@ public class RedisTemplateTests<K, V> {
}
});
if (redisTemplate.getConnectionFactory() instanceof JedisConnectionFactory) {
assertThat(results, is(empty()));
} else {
assertNull(results);
}
assertThat(results, is(empty()));
assertThat(redisTemplate.opsForValue().get(key1), isEqual(value2));
}
@@ -777,11 +778,7 @@ public class RedisTemplateTests<K, V> {
}
});
if (redisTemplate.getConnectionFactory() instanceof JedisConnectionFactory) {
assertThat(results, is(empty()));
} else {
assertNull(results);
}
assertThat(redisTemplate.opsForValue().get(key1), isEqual(value2));
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright 2018 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.data.redis.stream;
import static org.assertj.core.api.Assertions.*;
import java.util.Optional;
import org.junit.Test;
import org.springframework.data.redis.connection.RedisStreamCommands.Consumer;
import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset;
/**
* Unit tests for {@link ReadOffsetStrategy}.
*
* @author Mark Paluch
*/
public class ReadOffsetStrategyUnitTests {
static Optional<Consumer> consumer = Optional.of(Consumer.from("foo", "bar"));
@Test // DATAREDIS-864
public void nextMessageStandaloneShouldReturnLastSeenMessageId() {
ReadOffset offset = ReadOffset.from("foo");
assertThat(ReadOffsetStrategy.NextMessage.getFirst(offset, Optional.empty())).isEqualTo(offset);
assertThat(ReadOffsetStrategy.NextMessage.getNext(offset, Optional.empty(), "42")).isEqualTo(ReadOffset.from("42"));
}
@Test // DATAREDIS-864
public void lastConsumedStandaloneShouldReturnLastSeenMessageId() {
ReadOffset offset = ReadOffset.lastConsumed();
assertThat(ReadOffsetStrategy.LastConsumed.getFirst(offset, Optional.empty())).isEqualTo(ReadOffset.latest());
assertThat(ReadOffsetStrategy.LastConsumed.getNext(offset, Optional.empty(), "42"))
.isEqualTo(ReadOffset.from("42"));
}
@Test // DATAREDIS-864
public void latestStandaloneShouldReturnLatest() {
ReadOffset offset = ReadOffset.latest();
assertThat(ReadOffsetStrategy.Latest.getFirst(offset, Optional.empty())).isEqualTo(ReadOffset.latest());
assertThat(ReadOffsetStrategy.Latest.getNext(offset, Optional.empty(), "42")).isEqualTo(ReadOffset.latest());
}
@Test // DATAREDIS-864
public void nextMessageConsumerGroupShouldReturnLastSeenMessageId() {
ReadOffset offset = ReadOffset.from("foo");
assertThat(ReadOffsetStrategy.NextMessage.getFirst(offset, consumer)).isEqualTo(offset);
assertThat(ReadOffsetStrategy.NextMessage.getNext(offset, consumer, "42")).isEqualTo(ReadOffset.from("42"));
}
@Test // DATAREDIS-864
public void lastConsumedConsumerGroupShouldReturnLastSeenMessageId() {
ReadOffset offset = ReadOffset.lastConsumed();
assertThat(ReadOffsetStrategy.LastConsumed.getFirst(offset, consumer)).isEqualTo(ReadOffset.lastConsumed());
assertThat(ReadOffsetStrategy.LastConsumed.getNext(offset, consumer, "42")).isEqualTo(ReadOffset.lastConsumed());
}
@Test // DATAREDIS-864
public void latestConsumerGroupShouldReturnLatest() {
ReadOffset offset = ReadOffset.latest();
assertThat(ReadOffsetStrategy.Latest.getFirst(offset, consumer)).isEqualTo(ReadOffset.latest());
assertThat(ReadOffsetStrategy.Latest.getNext(offset, consumer, "42")).isEqualTo(ReadOffset.latest());
}
@Test // DATAREDIS-864
public void getStrategyShouldReturnAppropriateStrategy() {
assertThat(ReadOffsetStrategy.getStrategy(ReadOffset.from("foo"))).isEqualTo(ReadOffsetStrategy.NextMessage);
assertThat(ReadOffsetStrategy.getStrategy(ReadOffset.lastConsumed())).isEqualTo(ReadOffsetStrategy.LastConsumed);
assertThat(ReadOffsetStrategy.getStrategy(ReadOffset.latest())).isEqualTo(ReadOffsetStrategy.Latest);
}
}

View File

@@ -0,0 +1,286 @@
/*
* Copyright 2018 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.data.redis.stream;
import static org.assertj.core.api.Assertions.*;
import static org.junit.Assume.*;
import java.time.Duration;
import java.util.Collections;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.springframework.data.redis.ConnectionFactoryTracker;
import org.springframework.data.redis.RedisVersionUtils;
import org.springframework.data.redis.SettingsUtils;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.RedisStreamCommands.Consumer;
import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset;
import org.springframework.data.redis.connection.RedisStreamCommands.StreamMessage;
import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceTestClientResources;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.stream.StreamMessageListenerContainer.StreamMessageListenerContainerOptions;
import org.springframework.data.redis.stream.StreamMessageListenerContainer.StreamReadRequest;
/**
* Integration tests for {@link StreamMessageListenerContainer}.
*
* @author Mark Paluch
*/
public class StreamMessageListenerContainerIntegrationTests {
private static final RedisStandaloneConfiguration standaloneConfiguration = new RedisStandaloneConfiguration(
SettingsUtils.getHost(), SettingsUtils.getPort());
private static RedisConnectionFactory connectionFactory;
StringRedisTemplate redisTemplate = new StringRedisTemplate(connectionFactory);
StreamMessageListenerContainerOptions<String, String> containerOptions = StreamMessageListenerContainerOptions
.builder().pollTimeout(Duration.ofMillis(100)).build();
@BeforeClass
public static void beforeClass() {
LettuceClientConfiguration clientConfiguration = LettuceClientConfiguration.builder() //
.shutdownTimeout(Duration.ZERO) //
.clientResources(LettuceTestClientResources.getSharedClientResources()) //
.build();
LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(standaloneConfiguration,
clientConfiguration);
lettuceConnectionFactory.afterPropertiesSet();
ConnectionFactoryTracker.add(lettuceConnectionFactory);
connectionFactory = lettuceConnectionFactory;
// TODO: Upgrade to 5.0
assumeTrue(RedisVersionUtils.atLeast("4.9", connectionFactory.getConnection()));
}
@AfterClass
public static void tearDown() {
ConnectionFactoryTracker.cleanUp();
}
@Before
public void before() {
RedisConnection connection = connectionFactory.getConnection();
connection.flushDb();
connection.close();
}
@Test // DATAREDIS-864
public void shouldReceiveMessages() throws InterruptedException {
StreamMessageListenerContainer<String, String> container = StreamMessageListenerContainer.create(connectionFactory,
containerOptions);
BlockingQueue<StreamMessage<String, String>> queue = new LinkedBlockingQueue<>();
container.start();
Subscription subscription = container.receive(StreamOffset.create("my-stream", ReadOffset.from("0-0")), queue::add);
subscription.await(Duration.ofSeconds(2));
redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value1"));
redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value2"));
redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value3"));
assertThat(queue.poll(1, TimeUnit.SECONDS)).isNotNull();
assertThat(queue.poll(1, TimeUnit.SECONDS)).isNotNull();
assertThat(queue.poll(1, TimeUnit.SECONDS)).isNotNull();
cancelAwait(subscription);
assertThat(subscription.isActive()).isFalse();
}
@Test // DATAREDIS-864
public void shouldReceiveMessagesInConsumerGroup() throws InterruptedException {
StreamMessageListenerContainer<String, String> container = StreamMessageListenerContainer.create(connectionFactory,
containerOptions);
BlockingQueue<StreamMessage<String, String>> queue = new LinkedBlockingQueue<>();
String messageId = redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value1"));
redisTemplate.opsForStream().createGroup("my-stream", ReadOffset.from(messageId), "my-group");
container.start();
Subscription subscription = container.receive(Consumer.from("my-group", "my-consumer"),
StreamOffset.create("my-stream", ReadOffset.lastConsumed()), queue::add);
subscription.await(Duration.ofSeconds(2));
redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value2"));
StreamMessage<String, String> message = queue.poll(1, TimeUnit.SECONDS);
assertThat(message).isNotNull();
assertThat(message.getBody()).containsEntry("key", "value2");
cancelAwait(subscription);
}
@Test // DATAREDIS-864
public void shouldUseCustomErrorHandler() throws InterruptedException {
BlockingQueue<Throwable> failures = new LinkedBlockingQueue<>();
StreamMessageListenerContainerOptions<String, String> containerOptions = StreamMessageListenerContainerOptions
.builder().errorHandler(failures::add).pollTimeout(Duration.ofMillis(100)).build();
StreamMessageListenerContainer<String, String> container = StreamMessageListenerContainer.create(connectionFactory,
containerOptions);
container.start();
Subscription subscription = container.receive(Consumer.from("my-group", "my-consumer"),
StreamOffset.create("my-stream", ReadOffset.lastConsumed()), it -> {});
subscription.await(Duration.ofSeconds(2));
Throwable error = failures.poll(1, TimeUnit.SECONDS);
assertThat(failures).isEmpty();
assertThat(error).isNotNull();
cancelAwait(subscription);
}
@Test // DATAREDIS-864
public void errorShouldStopListening() throws InterruptedException {
BlockingQueue<Throwable> failures = new LinkedBlockingQueue<>();
StreamMessageListenerContainer<String, String> container = StreamMessageListenerContainer.create(connectionFactory,
containerOptions);
StreamReadRequest<String> readRequest = StreamReadRequest
.builder(StreamOffset.create("my-stream", ReadOffset.lastConsumed())).errorHandler(failures::add)
.consumer(Consumer.from("my-group", "my-consumer")).build();
String messageId = redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value1"));
redisTemplate.opsForStream().createGroup("my-stream", ReadOffset.from(messageId), "my-group");
container.start();
Subscription subscription = container.register(readRequest, it -> {});
subscription.await(Duration.ofSeconds(1));
redisTemplate.delete("my-stream");
subscription.await(Duration.ofSeconds(1));
assertThat(failures.poll(1, TimeUnit.SECONDS)).isNotNull();
assertThat(subscription.isActive()).isFalse();
cancelAwait(subscription);
}
@Test // DATAREDIS-864
public void customizedCancelPredicateShouldNotStopListening() throws InterruptedException {
BlockingQueue<Throwable> failures = new LinkedBlockingQueue<>();
StreamMessageListenerContainer<String, String> container = StreamMessageListenerContainer.create(connectionFactory,
containerOptions);
StreamReadRequest<String> readRequest = StreamReadRequest
.builder(StreamOffset.create("my-stream", ReadOffset.lastConsumed())) //
.errorHandler(failures::add) // //
.cancelOnError(t -> false) //
.consumer(Consumer.from("my-group", "my-consumer")) //
.build();
String messageId = redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value1"));
redisTemplate.opsForStream().createGroup("my-stream", ReadOffset.from(messageId), "my-group");
container.start();
Subscription subscription = container.register(readRequest, it -> {});
subscription.await(Duration.ofSeconds(2));
redisTemplate.delete("my-stream");
assertThat(failures.poll(1, TimeUnit.SECONDS)).isNotNull();
assertThat(failures.poll(1, TimeUnit.SECONDS)).isNotNull();
assertThat(subscription.isActive()).isTrue();
cancelAwait(subscription);
}
@Test // DATAREDIS-864
public void cancelledStreamShouldNotReceiveMessages() throws InterruptedException {
StreamMessageListenerContainer<String, String> container = StreamMessageListenerContainer.create(connectionFactory,
containerOptions);
BlockingQueue<StreamMessage<String, String>> queue = new LinkedBlockingQueue<>();
container.start();
Subscription subscription = container.receive(StreamOffset.create("my-stream", ReadOffset.from("0-0")), queue::add);
subscription.await(Duration.ofSeconds(2));
cancelAwait(subscription);
redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value4"));
assertThat(queue.poll(200, TimeUnit.MILLISECONDS)).isNull();
}
@Test // DATAREDIS-864
public void containerRestartShouldRestartSubscription() throws InterruptedException {
StreamMessageListenerContainer<String, String> container = StreamMessageListenerContainer.create(connectionFactory,
containerOptions);
BlockingQueue<StreamMessage<String, String>> queue = new LinkedBlockingQueue<>();
container.start();
Subscription subscription = container.receive(StreamOffset.create("my-stream", ReadOffset.from("0-0")), queue::add);
subscription.await(Duration.ofSeconds(2));
container.stop();
while (subscription.isActive()) {
Thread.sleep(10);
}
container.start();
subscription.await(Duration.ofSeconds(2));
redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value1"));
assertThat(queue.poll(1, TimeUnit.SECONDS)).isNotNull();
cancelAwait(subscription);
}
private static void cancelAwait(Subscription subscription) throws InterruptedException {
subscription.cancel();
while (subscription.isActive()) {
Thread.sleep(10);
}
}
}

View File

@@ -0,0 +1,199 @@
/*
* Copyright 2018 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.data.redis.stream;
import static org.assertj.core.api.Assertions.*;
import static org.junit.Assume.*;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import java.time.Duration;
import java.util.Collections;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.springframework.data.redis.ConnectionFactoryTracker;
import org.springframework.data.redis.RedisSystemException;
import org.springframework.data.redis.RedisVersionUtils;
import org.springframework.data.redis.SettingsUtils;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.RedisStreamCommands.Consumer;
import org.springframework.data.redis.connection.RedisStreamCommands.ReadOffset;
import org.springframework.data.redis.connection.RedisStreamCommands.StreamMessage;
import org.springframework.data.redis.connection.RedisStreamCommands.StreamOffset;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceTestClientResources;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.stream.StreamReceiver.StreamReceiverOptions;
/**
* Integration tests for {@link StreamReceiver}.
*
* @author Mark Paluch
*/
public class StreamReceiverIntegrationTests {
private static final RedisStandaloneConfiguration standaloneConfiguration = new RedisStandaloneConfiguration(
SettingsUtils.getHost(), SettingsUtils.getPort());
private static LettuceConnectionFactory connectionFactory;
StringRedisTemplate redisTemplate = new StringRedisTemplate(connectionFactory);
@BeforeClass
public static void beforeClass() {
LettuceClientConfiguration clientConfiguration = LettuceClientConfiguration.builder() //
.shutdownTimeout(Duration.ZERO) //
.clientResources(LettuceTestClientResources.getSharedClientResources()) //
.build();
LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(standaloneConfiguration,
clientConfiguration);
lettuceConnectionFactory.afterPropertiesSet();
ConnectionFactoryTracker.add(lettuceConnectionFactory);
connectionFactory = lettuceConnectionFactory;
// TODO: Upgrade to 5.0
assumeTrue(RedisVersionUtils.atLeast("4.9", connectionFactory.getConnection()));
}
@AfterClass
public static void tearDown() {
ConnectionFactoryTracker.cleanUp();
}
@Before
public void before() {
RedisConnection connection = connectionFactory.getConnection();
connection.flushDb();
connection.close();
}
@Test // DATAREDIS-864
public void shouldReceiveMessages() {
StreamReceiver<String, String> receiver = StreamReceiver.create(connectionFactory);
Flux<StreamMessage<String, String>> messages = receiver
.receive(StreamOffset.create("my-stream", ReadOffset.from("0-0")));
messages.as(StepVerifier::create) //
.then(() -> redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value")))
.consumeNextWith(it -> {
assertThat(it.getStream()).isEqualTo("my-stream");
assertThat(it.getBody()).containsEntry("key", "value");
}) //
.thenCancel() //
.verify(Duration.ofSeconds(5));
}
@Test // DATAREDIS-864
public void latestModeLosesMessages() {
// XADD/XREAD highly timing-dependent as this tests require a poll subscription to receive messages using $ offset.
StreamReceiverOptions<String, String> options = StreamReceiverOptions.builder().pollTimeout(Duration.ofSeconds(4))
.build();
StreamReceiver<String, String> receiver = StreamReceiver.create(connectionFactory, options);
Flux<StreamMessage<String, String>> messages = receiver
.receive(StreamOffset.create("my-stream", ReadOffset.latest()));
messages.as(publisher -> StepVerifier.create(publisher, 0)) //
.thenRequest(1) //
.then(() -> {
try {
Thread.sleep(500);
redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value1"));
} catch (InterruptedException e) {}
}) //
.expectNextCount(1) //
.then(() -> {
redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value2"));
}) //
.thenRequest(1) //
.then(() -> {
try {
Thread.sleep(500);
redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value3"));
} catch (InterruptedException e) {}
}).consumeNextWith(it -> {
assertThat(it.getStream()).isEqualTo("my-stream");
assertThat(it.getBody()).containsEntry("key", "value3");
}) //
.thenCancel() //
.verify(Duration.ofSeconds(5));
}
@Test // DATAREDIS-864
public void shouldReceiveAsConsumerGroupMessages() {
StreamReceiver<String, String> receiver = StreamReceiver.create(connectionFactory);
Flux<StreamMessage<String, String>> messages = receiver.receive(Consumer.from("my-group", "my-consumer-id"),
StreamOffset.create("my-stream", ReadOffset.lastConsumed()));
// required to initialize stream
redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value"));
redisTemplate.opsForStream().createGroup("my-stream", ReadOffset.from("0-0"), "my-group");
redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key2", "value2"));
messages.as(StepVerifier::create) //
.consumeNextWith(it -> {
assertThat(it.getStream()).isEqualTo("my-stream");
assertThat(it.getBody()).containsEntry("key", "value");
}).consumeNextWith(it -> {
assertThat(it.getStream()).isEqualTo("my-stream");
assertThat(it.getBody()).containsEntry("key2", "value2");
}) //
.thenCancel() //
.verify(Duration.ofSeconds(5));
}
@Test // DATAREDIS-864
public void shouldStopReceivingOnError() {
StreamReceiverOptions<String, String> options = StreamReceiverOptions.builder().pollTimeout(Duration.ofMillis(100))
.build();
StreamReceiver<String, String> receiver = StreamReceiver.create(connectionFactory, options);
Flux<StreamMessage<String, String>> messages = receiver.receive(Consumer.from("my-group", "my-consumer-id"),
StreamOffset.create("my-stream", ReadOffset.lastConsumed()));
// required to initialize stream
redisTemplate.opsForStream().add("my-stream", Collections.singletonMap("key", "value"));
redisTemplate.opsForStream().createGroup("my-stream", ReadOffset.from("0-0"), "my-group");
messages.as(StepVerifier::create) //
.expectNextCount(1) //
.then(() -> redisTemplate.delete("my-stream")) //
.expectError(RedisSystemException.class) //
.verify(Duration.ofSeconds(5));
}
}