diff --git a/src/main/java/org/springframework/data/redis/connection/ReactiveRedisConnection.java b/src/main/java/org/springframework/data/redis/connection/ReactiveRedisConnection.java
index 3ca1c3672..b5d0f017c 100644
--- a/src/main/java/org/springframework/data/redis/connection/ReactiveRedisConnection.java
+++ b/src/main/java/org/springframework/data/redis/connection/ReactiveRedisConnection.java
@@ -116,6 +116,14 @@ public interface ReactiveRedisConnection extends Closeable {
*/
ReactiveHyperLogLogCommands hyperLogLogCommands();
+ /**
+ * Get {@link ReactiveRedisPubSubCommands}.
+ *
+ * @return never {@literal null}.
+ * @since 2.1
+ */
+ ReactiveRedisPubSubCommands pubSubCommands();
+
/**
* Get {@link ReactiveScriptingCommands}.
*
diff --git a/src/main/java/org/springframework/data/redis/connection/ReactiveRedisPubSubCommands.java b/src/main/java/org/springframework/data/redis/connection/ReactiveRedisPubSubCommands.java
new file mode 100644
index 000000000..7bdd7817c
--- /dev/null
+++ b/src/main/java/org/springframework/data/redis/connection/ReactiveRedisPubSubCommands.java
@@ -0,0 +1,85 @@
+/*
+ * 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.data.redis.connection;
+
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.nio.ByteBuffer;
+
+import org.reactivestreams.Publisher;
+import org.springframework.data.redis.connection.ReactiveSubscription.ChannelMessage;
+
+/**
+ * Redis Pub/Sub commands executed using reactive infrastructure.
+ *
+ * @author Mark Paluch
+ * @since 2.1
+ */
+public interface ReactiveRedisPubSubCommands {
+
+ /**
+ * Creates a subscription for this connection. Connections can have multiple {@link ReactiveSubscription}s.
+ *
+ * @return the subscription.
+ */
+ Mono createSubscription();
+
+ /**
+ * Publishes the given {@code message} to the given {@code channel}.
+ *
+ * @param channel the channel to publish to. Must not be {@literal null}.
+ * @param message message to publish. Must not be {@literal null}.
+ * @return the number of clients that received the message.
+ * @see Redis Documentation: PUBLISH
+ */
+ default Mono publish(ByteBuffer channel, ByteBuffer message) {
+ return publish(Mono.just(new ChannelMessage<>(channel, message))).next();
+ }
+
+ /**
+ * Publishes the given messages to the {@link ChannelMessage#getChannel() appropriate channels}.
+ *
+ * @param messageStream the messages to publish to. Must not be {@literal null}.
+ * @return the number of clients that received the message.
+ * @see Redis Documentation: PUBLISH
+ */
+ Flux publish(Publisher> messageStream);
+
+ /**
+ * Subscribes the connection to the given {@code channels}. Once subscribed, a connection enters listening mode and
+ * can only subscribe to other channels or unsubscribe. No other commands are accepted until the connection is
+ * unsubscribed.
+ *
+ * Note that cancellation of the {@link Flux} will unsubscribe from {@code channels}.
+ *
+ * @param channels channel names, must not be {@literal null}.
+ * @see Redis Documentation: SUBSCRIBE
+ */
+ Mono subscribe(ByteBuffer... channels);
+
+ /**
+ * Subscribes the connection to all channels matching the given {@code patterns}. Once subscribed, a connection enters
+ * listening mode and can only subscribe to other channels or unsubscribe. No other commands are accepted until the
+ * connection is unsubscribed.
+ *
+ * Note that cancellation of the {@link Flux} will unsubscribe from {@code patterns}.
+ *
+ * @param patterns channel name patterns, must not be {@literal null}.
+ * @see Redis Documentation: PSUBSCRIBE
+ */
+ Mono pSubscribe(ByteBuffer... patterns);
+}
diff --git a/src/main/java/org/springframework/data/redis/connection/ReactiveSubscription.java b/src/main/java/org/springframework/data/redis/connection/ReactiveSubscription.java
new file mode 100644
index 000000000..855d0facd
--- /dev/null
+++ b/src/main/java/org/springframework/data/redis/connection/ReactiveSubscription.java
@@ -0,0 +1,187 @@
+/*
+ * 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.data.redis.connection;
+
+import lombok.EqualsAndHashCode;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.nio.ByteBuffer;
+import java.util.Collection;
+
+/**
+ * Subscription for Redis channels using reactive infrastructure. A {@link ReactiveSubscription} allows subscription to
+ * {@link #subscribe(ByteBuffer...) channels} and {@link #pSubscribe(ByteBuffer...) patterns}. It provides access to the
+ * {@link ChannelMessage} {@link #receive() stream} that emits only messages for channels and patterns registered in
+ * this {@link ReactiveSubscription}.
+ *
+ * A reactive Redis connection can have multiple subscriptions. If two or more subscriptions subscribe to the same
+ * target (channel/pattern) and one unsubscribes, then the other one will no longer receive messages for the target due
+ * to how Redis handled Pub/Sub subscription.
+ *
+ * @author Mark Paluch
+ * @since 2.1
+ */
+public interface ReactiveSubscription {
+
+ /**
+ * Subscribes to the {@code channels} and adds these to the current subscription.
+ *
+ * @param channels channel names. Must not be empty.
+ * @return empty {@link Mono} that completes once the channel subscription is registered.
+ */
+ Mono subscribe(ByteBuffer... channels);
+
+ /**
+ * Subscribes to the channel {@code patterns} and adds these to the current subscription.
+ *
+ * @param patterns channel patterns. Must not be empty.
+ * @return empty {@link Mono} that completes once the pattern subscription is registered.
+ */
+ Mono pSubscribe(ByteBuffer... patterns);
+
+ /**
+ * Cancels the current subscription for all {@link #getChannels() channels} given by name.
+ *
+ * @return empty {@link Mono} that completes once the channel subscriptions are unregistered.
+ */
+ Mono unsubscribe();
+
+ /**
+ * Cancels the current subscription for all given channels.
+ *
+ * @param channels channel names. Must not be empty.
+ * @return empty {@link Mono} that completes once the channel subscription is unregistered.
+ */
+ Mono unsubscribe(ByteBuffer... channels);
+
+ /**
+ * Cancels the subscription for all channels matched by {@link #getPatterns()} patterns}.
+ *
+ * @return empty {@link Mono} that completes once the patterns subscriptions are unregistered.
+ */
+ Mono pUnsubscribe();
+
+ /**
+ * Cancels the subscription for all channels matching the given patterns.
+ *
+ * @param patterns must not be empty.
+ * @return empty {@link Mono} that completes once the patterns subscription is unregistered.
+ */
+ Mono pUnsubscribe(ByteBuffer... patterns);
+
+ /**
+ * Returns the (named) channels for this subscription.
+ *
+ * @return collection of named channels
+ */
+ Collection getChannels();
+
+ /**
+ * Returns the channel patters for this subscription.
+ *
+ * @return collection of channel patterns
+ */
+ Collection getPatterns();
+
+ /**
+ * Retrieve the message stream emitting {@link ChannelMessage} and {@link PatternMessage}. The resulting message
+ * stream contains only messages for subscribed and registered {@link #getChannels() channels} and
+ * {@link #getPatterns() patterns}.
+ *
+ * Stream publishing uses {@link reactor.core.publisher.ConnectableFlux} turning the stream into a hot sequence.
+ * Emission is paused if there is no demand. Messages received in that time are buffered. This stream terminates
+ * either if all subscribers unsubscribe or if this {@link Subscription} is {@link #terminate() is terminated}.
+ *
+ * @return the message stream.
+ */
+ Flux> receive();
+
+ /**
+ * Unsubscribe from all {@link #getChannels() channels} and {@link #getPatterns() patterns} and request termination of
+ * all active {@link #receive() message streams}. Active streams will terminate with a
+ * {@link java.util.concurrent.CancellationException}.
+ *
+ * @return a {@link Mono} that completes once termination is finished.
+ */
+ Mono terminate();
+
+ /**
+ * Value object for a Redis channel message.
+ *
+ * @param type of how the channel name is represented.
+ * @param type of how the message is represented.
+ * @author Mark Paluch
+ * @since 2.1
+ */
+ @EqualsAndHashCode
+ class ChannelMessage {
+
+ private final C channel;
+ private final B message;
+
+ /**
+ * Create a new {@link ChannelMessage}.
+ *
+ * @param channel must not be {@literal null}.
+ * @param message must not be {@literal null}.
+ */
+ public ChannelMessage(C channel, B message) {
+ this.channel = channel;
+ this.message = message;
+ }
+
+ public C getChannel() {
+ return channel;
+ }
+
+ public B getMessage() {
+ return message;
+ }
+ }
+
+ /**
+ * Value object for a Redis channel message received from a pattern subscription.
+ *
+ * @param type of how the pattern is represented.
+ * @param type of how the channel name is represented.
+ * @param type of how the message is represented.
+ * @author Mark Paluch
+ * @since 2.1
+ */
+ @EqualsAndHashCode(callSuper = true)
+ class PatternMessage
extends ChannelMessage {
+
+ private final P pattern;
+
+ /**
+ * Create a new {@link PatternMessage}.
+ *
+ * @param pattern must not be {@literal null}.
+ * @param channel must not be {@literal null}.
+ * @param message must not be {@literal null}.
+ */
+ public PatternMessage(P pattern, C channel, B message) {
+
+ super(channel, message);
+ this.pattern = pattern;
+ }
+
+ public P getPattern() {
+ return pattern;
+ }
+ }
+}
diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactivePubSubCommands.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactivePubSubCommands.java
new file mode 100644
index 000000000..4cab537f5
--- /dev/null
+++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactivePubSubCommands.java
@@ -0,0 +1,93 @@
+/*
+ * 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.data.redis.connection.lettuce;
+
+import io.lettuce.core.pubsub.api.reactive.RedisPubSubReactiveCommands;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.nio.ByteBuffer;
+import java.util.function.Function;
+
+import org.reactivestreams.Publisher;
+import org.springframework.data.redis.connection.ReactiveRedisPubSubCommands;
+import org.springframework.data.redis.connection.ReactiveSubscription;
+import org.springframework.data.redis.connection.ReactiveSubscription.ChannelMessage;
+import org.springframework.util.Assert;
+
+/**
+ * @author Mark Paluch
+ * @since 2.1
+ */
+@RequiredArgsConstructor
+class LettuceReactivePubSubCommands implements ReactiveRedisPubSubCommands {
+
+ private final @NonNull LettuceReactiveRedisConnection connection;
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.redis.connection.ReactiveRedisPubSubCommands#createSubscription()
+ */
+ @Override
+ public Mono createSubscription() {
+ return connection.getPubSubConnection()
+ .map(c -> new LettuceReactiveSubscription(c.reactive(), connection.translateException()));
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.redis.connection.ReactiveRedisPubSubCommands#publish(org.reactivestreams.Publisher)
+ */
+ @Override
+ public Flux publish(Publisher> messageStream) {
+
+ Assert.notNull(messageStream, "ChannelMessage stream must not be null!");
+
+ return connection.getCommands().flatMapMany(
+ c -> Flux.from(messageStream).flatMap(message -> c.publish(message.getChannel(), message.getMessage())));
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.redis.connection.ReactiveRedisPubSubCommands#subscribe(java.nio.ByteBuffer[])
+ */
+ @Override
+ public Mono subscribe(ByteBuffer... channels) {
+
+ Assert.notNull(channels, "Channels must not be null!");
+
+ return doWithPubSub(c -> c.subscribe(channels));
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.redis.connection.ReactiveRedisPubSubCommands#pSubscribe(java.nio.ByteBuffer[])
+ */
+ @Override
+ public Mono pSubscribe(ByteBuffer... patterns) {
+
+ Assert.notNull(patterns, "Patterns must not be null!");
+
+ return doWithPubSub(c -> c.psubscribe(patterns));
+ }
+
+ private Mono doWithPubSub(Function, Mono> function) {
+ return connection.getPubSubConnection().flatMap(c -> function.apply(c.reactive()))
+ .onErrorMap(connection.translateException());
+ }
+}
diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveRedisConnection.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveRedisConnection.java
index d01986e8e..15426997e 100644
--- a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveRedisConnection.java
+++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveRedisConnection.java
@@ -21,6 +21,7 @@ import io.lettuce.core.api.reactive.BaseRedisReactiveCommands;
import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
import io.lettuce.core.cluster.api.reactive.RedisClusterReactiveCommands;
import io.lettuce.core.codec.RedisCodec;
+import io.lettuce.core.pubsub.StatefulRedisPubSubConnection;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
@@ -46,7 +47,8 @@ class LettuceReactiveRedisConnection implements ReactiveRedisConnection {
static final RedisCodec CODEC = ByteBufferCodec.INSTANCE;
- private final AsyncConnect dedicatedConnection;
+ private final AsyncConnect> dedicatedConnection;
+ private final AsyncConnect> pubSubConnection;
private @Nullable Mono> sharedConnection;
@@ -62,7 +64,8 @@ class LettuceReactiveRedisConnection implements ReactiveRedisConnection {
Assert.notNull(connectionProvider, "LettuceConnectionProvider must not be null!");
- this.dedicatedConnection = new AsyncConnect(connectionProvider);
+ this.dedicatedConnection = new AsyncConnect(connectionProvider, StatefulConnection.class);
+ this.pubSubConnection = new AsyncConnect(connectionProvider, StatefulRedisPubSubConnection.class);
}
/**
@@ -81,7 +84,8 @@ class LettuceReactiveRedisConnection implements ReactiveRedisConnection {
Assert.notNull(sharedConnection, "Shared StatefulConnection must not be null!");
Assert.notNull(connectionProvider, "LettuceConnectionProvider must not be null!");
- this.dedicatedConnection = new AsyncConnect(connectionProvider);
+ this.dedicatedConnection = new AsyncConnect(connectionProvider, StatefulConnection.class);
+ this.pubSubConnection = new AsyncConnect(connectionProvider, StatefulRedisPubSubConnection.class);
this.sharedConnection = Mono.just(sharedConnection);
}
@@ -166,6 +170,15 @@ class LettuceReactiveRedisConnection implements ReactiveRedisConnection {
return new LettuceReactiveHyperLogLogCommands(this);
}
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.redis.connection.ReactiveRedisConnection#pubSubCommands()
+ */
+ @Override
+ public ReactiveRedisPubSubCommands pubSubCommands() {
+ return new LettuceReactivePubSubCommands(this);
+ }
+
/*
* (non-Javadoc)
* @see org.springframework.data.redis.connection.ReactiveRedisConnection#scriptingCommands()
@@ -232,6 +245,10 @@ class LettuceReactiveRedisConnection implements ReactiveRedisConnection {
return dedicatedConnection.getConnection().onErrorMap(translateException());
}
+ protected Mono> getPubSubConnection() {
+ return pubSubConnection.getConnection().onErrorMap(translateException());
+ }
+
protected Mono extends RedisClusterReactiveCommands> getCommands() {
if (sharedConnection != null) {
@@ -320,24 +337,22 @@ class LettuceReactiveRedisConnection implements ReactiveRedisConnection {
* @author Christoph Strobl
* @since 2.0.1
*/
- static class AsyncConnect {
+ static class AsyncConnect> {
- private final Mono> connectionPublisher;
+ private final Mono connectionPublisher;
private final LettuceConnectionProvider connectionProvider;
private AtomicReference state = new AtomicReference<>(State.INITIAL);
- private volatile @Nullable CompletableFuture> connection;
+ private volatile @Nullable CompletableFuture connection;
@SuppressWarnings("unchecked")
- AsyncConnect(LettuceConnectionProvider connectionProvider) {
+ AsyncConnect(LettuceConnectionProvider connectionProvider, Class extends T> connectionType) {
Assert.notNull(connectionProvider, "LettuceConnectionProvider must not be null!");
this.connectionProvider = connectionProvider;
- Mono> defer = Mono
- .defer(() -> Mono.> just(
- connectionProvider.getConnection(StatefulConnection.class)));
+ Mono defer = Mono.defer(() -> Mono. just(connectionProvider.getConnection(connectionType)));
this.connectionPublisher = defer.subscribeOn(Schedulers.elastic());
}
@@ -348,13 +363,13 @@ class LettuceReactiveRedisConnection implements ReactiveRedisConnection {
*
* @return never {@literal null}.
*/
- Mono> getConnection() {
+ Mono getConnection() {
if (state.get() == State.CLOSED) {
throw new IllegalStateException("Unable to connect. Connection is closed!");
}
- CompletableFuture> connection = this.connection;
+ CompletableFuture connection = this.connection;
if (connection != null) {
return Mono.fromCompletionStage(connection);
diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveSubscription.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveSubscription.java
new file mode 100644
index 000000000..87c117f01
--- /dev/null
+++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveSubscription.java
@@ -0,0 +1,289 @@
+/*
+ * 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.data.redis.connection.lettuce;
+
+import io.lettuce.core.pubsub.api.reactive.RedisPubSubReactiveCommands;
+import lombok.RequiredArgsConstructor;
+import reactor.core.Disposable;
+import reactor.core.publisher.ConnectableFlux;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentSkipListSet;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import org.springframework.data.redis.connection.ReactiveSubscription;
+import org.springframework.lang.Nullable;
+import org.springframework.util.Assert;
+
+/**
+ * Lettuce-specific implementation of {@link ReactiveSubscription}.
+ *
+ * @author Mark Paluch
+ * @since 2.1
+ */
+class LettuceReactiveSubscription implements ReactiveSubscription {
+
+ private final RedisPubSubReactiveCommands commands;
+
+ private final State patternState;
+ private final State channelState;
+
+ LettuceReactiveSubscription(RedisPubSubReactiveCommands commands,
+ Function exceptionTranslator) {
+
+ this.commands = commands;
+ this.patternState = new State(exceptionTranslator);
+ this.channelState = new State(exceptionTranslator);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.redis.connection.ReactiveSubscription#subscribe(java.nio.ByteBuffer[])
+ */
+ @Override
+ public Mono subscribe(ByteBuffer... channels) {
+
+ Assert.notNull(channels, "Channels must not be null!");
+ Assert.noNullElements(channels, "Channels must not contain null elements!");
+
+ return channelState.subscribe(channels, commands::subscribe);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.redis.connection.ReactiveSubscription#pSubscribe(java.nio.ByteBuffer[])
+ */
+ @Override
+ public Mono pSubscribe(ByteBuffer... patterns) {
+
+ Assert.notNull(patterns, "Patterns must not be null!");
+ Assert.noNullElements(patterns, "Patterns must not contain null elements!");
+
+ return patternState.subscribe(patterns, commands::psubscribe);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.redis.connection.ReactiveSubscription#unsubscribe()
+ */
+ @Override
+ public Mono unsubscribe() {
+ return unsubscribe(channelState.getTargets().toArray(new ByteBuffer[0]));
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.redis.connection.ReactiveSubscription#unsubscribe(java.nio.ByteBuffer[])
+ */
+ @Override
+ public Mono unsubscribe(ByteBuffer... channels) {
+
+ Assert.notNull(channels, "Channels must not be null!");
+ Assert.noNullElements(channels, "Channels must not contain null elements!");
+
+ return channels.length == 0 ? Mono.empty() : channelState.unsubscribe(channels, commands::unsubscribe);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.redis.connection.ReactiveSubscription#pUnsubscribe()
+ */
+ @Override
+ public Mono pUnsubscribe() {
+ return pUnsubscribe(patternState.getTargets().toArray(new ByteBuffer[0]));
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.redis.connection.ReactiveSubscription#pUnsubscribe(java.nio.ByteBuffer[])
+ */
+ @Override
+ public Mono pUnsubscribe(ByteBuffer... patterns) {
+
+ Assert.notNull(patterns, "Patterns must not be null!");
+ Assert.noNullElements(patterns, "Patterns must not contain null elements!");
+
+ return patterns.length == 0 ? Mono.empty() : patternState.unsubscribe(patterns, commands::punsubscribe);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.redis.connection.ReactiveSubscription#getChannels()
+ */
+ @Override
+ public Collection getChannels() {
+ return Collections.unmodifiableCollection(channelState.getTargets());
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.redis.connection.ReactiveSubscription#getPatterns()
+ */
+ @Override
+ public Collection getPatterns() {
+ return Collections.unmodifiableCollection(patternState.getTargets());
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.redis.connection.ReactiveSubscription#receive()
+ */
+ @Override
+ public Flux> receive() {
+
+ Flux> channelMessages = channelState.receive(() -> commands.observeChannels() //
+ .filter(m -> channelState.getTargets().contains(m.getChannel())) //
+ .map(m -> new ChannelMessage<>(m.getChannel(), m.getMessage())));
+
+ Flux> patternMessages = patternState.receive(() -> commands.observePatterns() //
+ .filter(m -> patternState.getTargets().contains(m.getPattern())) //
+ .map(m -> new PatternMessage<>(m.getPattern(), m.getChannel(), m.getMessage())));
+
+ return channelMessages.mergeWith(patternMessages);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.redis.connection.ReactiveSubscription#terminate()
+ */
+ @Override
+ public Mono terminate() {
+
+ return unsubscribe().then(pUnsubscribe()).then(Mono.defer(() -> {
+
+ channelState.terminate();
+ patternState.terminate();
+ return Mono.empty();
+ }));
+ }
+
+ /**
+ * Subscription state holder.
+ *
+ * @author Mark Paluch
+ */
+ @RequiredArgsConstructor
+ static class State {
+
+ private final Set targets = new ConcurrentSkipListSet<>();
+ private final AtomicLong subscribers = new AtomicLong();
+ private final AtomicReference> flux = new AtomicReference<>();
+ private final Function exceptionTranslator;
+
+ private volatile @Nullable Disposable disposable;
+
+ /**
+ * Subscribe to {@code targets} using subscribe {@link Function} and register {@code targets} after subscription.
+ *
+ * @param targets
+ * @param subscribeFunction
+ * @return
+ */
+ Mono subscribe(ByteBuffer[] targets, Function> subscribeFunction) {
+
+ return subscribeFunction.apply(targets).doOnSuccess((v) -> {
+ this.targets.addAll(Arrays.asList(targets));
+ }).onErrorMap(exceptionTranslator);
+ }
+
+ /**
+ * Unsubscribe from to {@code targets} using unsubscribe {@link Function} and register {@code targets} after
+ * subscription.
+ *
+ * @param targets
+ * @param unsubscribeFunction
+ * @return
+ */
+ Mono unsubscribe(ByteBuffer[] targets, Function> unsubscribeFunction) {
+
+ return Mono.defer(() -> {
+
+ List targetCollection = Arrays.asList(targets);
+
+ return unsubscribeFunction.apply(targets).doOnSuccess((v) -> {
+ this.targets.removeAll(targetCollection);
+ }).onErrorMap(exceptionTranslator);
+ });
+ }
+
+ Collection getTargets() {
+ return targets;
+ }
+
+ /**
+ * Create a message stream from connect {@link Function}. Multiple calls to this method are lock-free synchronized.
+ * The first successful caller creates the actual stream. Other concurrent callers that do not pass the
+ * synchronization use the stream created by the first successful caller.
+ *
+ * The stream registers a disposal function upon subscription for external {@link #terminate() termination}.
+ *
+ * @param connectFunction
+ * @param message type.
+ * @return
+ */
+ @SuppressWarnings("unchecked")
+ Flux receive(Supplier> connectFunction) {
+
+ Flux> fastPath = flux.get();
+
+ if (fastPath != null) {
+ return (Flux) fastPath;
+ }
+
+ ConnectableFlux connectableFlux = connectFunction.get().onErrorMap(exceptionTranslator).publish();
+ Flux fluxToUse = connectableFlux.doOnSubscribe(s -> {
+
+ if (subscribers.incrementAndGet() == 1) {
+ disposable = connectableFlux.connect();
+ }
+ }).doFinally(s -> {
+
+ if (subscribers.decrementAndGet() == 0) {
+
+ this.flux.compareAndSet(connectableFlux, null);
+ terminate();
+ }
+ });
+
+ if (this.flux.compareAndSet(null, fluxToUse)) {
+ return fluxToUse;
+ }
+
+ return (Flux) this.flux.get();
+ }
+
+ void terminate() {
+
+ this.flux.set(null);
+
+ Disposable disposable = this.disposable;
+
+ if (disposable != null && !disposable.isDisposed()) {
+ disposable.dispose();
+ }
+ }
+ }
+}
diff --git a/src/main/java/org/springframework/data/redis/core/ReactiveRedisOperations.java b/src/main/java/org/springframework/data/redis/core/ReactiveRedisOperations.java
index e92ad2de5..4fdefbd6a 100644
--- a/src/main/java/org/springframework/data/redis/core/ReactiveRedisOperations.java
+++ b/src/main/java/org/springframework/data/redis/core/ReactiveRedisOperations.java
@@ -56,6 +56,17 @@ public interface ReactiveRedisOperations {
*/
Flux execute(ReactiveRedisCallback action);
+ /**
+ * Publishes the given message to the given channel.
+ *
+ * @param destination the channel to publish to, must not be {@literal null} or empty.
+ * @param message message to publish.
+ * @return the number of clients that received the message
+ * @since 2.1
+ * @see Redis Documentation: PUBLISH
+ */
+ Mono convertAndSend(String destination, V message);
+
// -------------------------------------------------------------------------
// Methods dealing with Redis Keys
// -------------------------------------------------------------------------
diff --git a/src/main/java/org/springframework/data/redis/core/ReactiveRedisTemplate.java b/src/main/java/org/springframework/data/redis/core/ReactiveRedisTemplate.java
index d7bc2b308..12b1750c2 100644
--- a/src/main/java/org/springframework/data/redis/core/ReactiveRedisTemplate.java
+++ b/src/main/java/org/springframework/data/redis/core/ReactiveRedisTemplate.java
@@ -193,6 +193,16 @@ public class ReactiveRedisTemplate implements ReactiveRedisOperations conn.close());
}
+ @Override
+ public Mono convertAndSend(String destination, V message) {
+
+ Assert.hasText(destination, "Destination channel must not be empty!");
+
+ return createMono(connection -> connection.pubSubCommands().publish(
+ getSerializationContext().getStringSerializationPair().write(destination),
+ getSerializationContext().getValueSerializationPair().write(message)));
+ }
+
// -------------------------------------------------------------------------
// Methods dealing with Redis keys
// -------------------------------------------------------------------------
diff --git a/src/main/java/org/springframework/data/redis/listener/ReactiveRedisMessageListenerContainer.java b/src/main/java/org/springframework/data/redis/listener/ReactiveRedisMessageListenerContainer.java
new file mode 100644
index 000000000..b49d0260b
--- /dev/null
+++ b/src/main/java/org/springframework/data/redis/listener/ReactiveRedisMessageListenerContainer.java
@@ -0,0 +1,354 @@
+/*
+ * 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.data.redis.listener;
+
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.MonoProcessor;
+
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLongFieldUpdater;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.dao.InvalidDataAccessApiUsageException;
+import org.springframework.data.redis.connection.ReactiveRedisConnection;
+import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory;
+import org.springframework.data.redis.connection.ReactiveSubscription;
+import org.springframework.data.redis.connection.ReactiveSubscription.ChannelMessage;
+import org.springframework.data.redis.connection.ReactiveSubscription.PatternMessage;
+import org.springframework.data.redis.serializer.RedisElementReader;
+import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;
+import org.springframework.data.redis.serializer.RedisSerializer;
+import org.springframework.lang.Nullable;
+import org.springframework.util.Assert;
+
+/**
+ * Container providing a stream of {@link ChannelMessage} for messages received via Redis Pub/Sub listeners. The stream
+ * is infinite and registers Redis subscriptions. Handles the low level details of listening, converting and message
+ * dispatching.
+ *
+ * Note the container allocates a single connection when it is created and releases the connection on
+ * {@link #destroy()}. Connections are allocated eagerly to not interfere with non-blocking use during application
+ * operations. Using reactive infrastructure allows usage of a single connection due to channel multiplexing.
+ *
+ * This class is thread-safe and allows subscription by multiple concurrent threads.
+ *
+ * @author Mark Paluch
+ * @since 2.1
+ * @see ReactiveSubscription
+ * @see org.springframework.data.redis.connection.ReactiveRedisPubSubCommands
+ */
+public class ReactiveRedisMessageListenerContainer implements DisposableBean {
+
+ private final SerializationPair stringSerializationPair = SerializationPair
+ .fromSerializer(RedisSerializer.string());
+ private final Map subscriptions = new ConcurrentHashMap<>();
+
+ private volatile @Nullable ReactiveRedisConnection connection;
+
+ /**
+ * Create a new {@link ReactiveRedisMessageListenerContainer} given {@link ReactiveRedisConnectionFactory}.
+ *
+ * @param connectionFactory must not be {@literal null}.
+ */
+ public ReactiveRedisMessageListenerContainer(ReactiveRedisConnectionFactory connectionFactory) {
+
+ Assert.notNull(connectionFactory, "ReactiveRedisConnectionFactory must not be null!");
+ this.connection = connectionFactory.getReactiveConnection();
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.beans.factory.DisposableBean#destroy()
+ */
+ @Override
+ public void destroy() {
+
+ ReactiveRedisConnection connection = this.connection;
+
+ if (connection != null) {
+
+ Flux terminationSignals = null;
+ while (!subscriptions.isEmpty()) {
+
+ Map local = new HashMap<>(subscriptions);
+ List> monos = local.keySet().stream() //
+ .peek(subscriptions::remove) //
+ .map(ReactiveSubscription::terminate) //
+ .collect(Collectors.toList());
+
+ if (terminationSignals == null) {
+ terminationSignals = Flux.merge(monos);
+ } else {
+ terminationSignals = terminationSignals.mergeWith(Flux.merge(monos));
+ }
+ }
+
+ if (terminationSignals != null) {
+ terminationSignals.blockLast();
+ }
+
+ connection.close();
+ this.connection = null;
+ }
+ }
+
+ /**
+ * Return the currently active {@link ReactiveSubscription subscriptions}.
+ *
+ * @return {@link Set} of active {@link ReactiveSubscription}
+ */
+ public Collection getActiveSubscriptions() {
+
+ Set subscriptions = new HashSet<>(this.subscriptions.size(), 1);
+
+ this.subscriptions.forEach((subscription, subscribers) -> {
+
+ if (subscribers.hasRegistration()) {
+ subscriptions.add(subscription);
+ }
+ });
+
+ return subscriptions;
+ }
+
+ /**
+ * Subscribe to one or more {@link ChannelTopic}s and receive a stream of {@link ChannelMessage}. Messages and channel
+ * names are treated as {@link String}. The message stream subscribes lazily to the Redis channels and unsubscribes if
+ * the {@link org.reactivestreams.Subscription} is {@link org.reactivestreams.Subscription#cancel() cancelled}.
+ *
+ * @param channelTopics the channels to subscribe.
+ * @return the message stream.
+ * @throws InvalidDataAccessApiUsageException if {@code patternTopics} is empty.
+ * @see #receive(Iterable, SerializationPair, SerializationPair)
+ */
+ public Flux> receive(ChannelTopic... channelTopics) {
+
+ Assert.notNull(channelTopics, "ChannelTopics must not be null!");
+ Assert.noNullElements(channelTopics, "ChannelTopics must not contain null elements!");
+
+ return receive(Arrays.asList(channelTopics), stringSerializationPair, stringSerializationPair);
+ }
+
+ /**
+ * Subscribe to one or more {@link PatternTopic}s and receive a stream of {@link PatternMessage}. Messages, pattern,
+ * and channel names are treated as {@link String}. The message stream subscribes lazily to the Redis channels and
+ * unsubscribes if the {@link org.reactivestreams.Subscription} is {@link org.reactivestreams.Subscription#cancel()
+ * cancelled}.
+ *
+ * @param channelTopics the channels to subscribe.
+ * @return the message stream.
+ * @throws InvalidDataAccessApiUsageException if {@code patternTopics} is empty.
+ * @see #receive(Iterable, SerializationPair, SerializationPair)
+ */
+ @SuppressWarnings("unchecked")
+ public Flux> receive(PatternTopic... patternTopics) {
+
+ Assert.notNull(patternTopics, "PatternTopic must not be null!");
+ Assert.noNullElements(patternTopics, "PatternTopic must not contain null elements!");
+
+ return receive(Arrays.asList(patternTopics), stringSerializationPair, stringSerializationPair)
+ .map(m -> (PatternMessage) m);
+ }
+
+ /**
+ * Subscribe to one or more {@link Topic}s and receive a stream of {@link ChannelMessage} The stream may contain
+ * {@link PatternMessage} if subscribed to patterns. Messages, and channel names are serialized/deserialized using the
+ * given {@code channelSerializer} and {@code messageSerializer}. The message stream subscribes lazily to the Redis
+ * channels and unsubscribes if the {@link org.reactivestreams.Subscription} is
+ * {@link org.reactivestreams.Subscription#cancel() cancelled}.
+ *
+ * @param topics the channels to subscribe.
+ * @return the message stream.
+ * @see #receive(Iterable, SerializationPair, SerializationPair)
+ * @throws InvalidDataAccessApiUsageException if {@code topics} is empty.
+ */
+ public Flux> receive(Iterable extends Topic> topics,
+ SerializationPair channelSerializer, SerializationPair messageSerializer) {
+
+ Assert.notNull(topics, "Topics must not be null!");
+
+ ReactiveRedisConnection connection = this.connection;
+ if (connection == null) {
+ throw new IllegalStateException("ReactiveRedisMessageListenerContainer is already disposed!");
+ }
+
+ Mono subscription = connection.pubSubCommands().createSubscription();
+
+ ByteBuffer[] patterns = getTargets(topics, PatternTopic.class);
+ ByteBuffer[] channels = getTargets(topics, ChannelTopic.class);
+
+ if (patterns.length == 0 && channels.length == 0) {
+ throw new InvalidDataAccessApiUsageException("No channels or patterns to subscribe");
+ }
+
+ return doReceive(channelSerializer, messageSerializer, subscription, patterns, channels);
+ }
+
+ private Flux> doReceive(SerializationPair channelSerializer,
+ SerializationPair messageSerializer, Mono subscription, ByteBuffer[] patterns,
+ ByteBuffer[] channels) {
+
+ Flux> messageStream = subscription.flatMapMany(it -> {
+
+ Mono subscribe = subscribe(patterns, channels, it);
+
+ MonoProcessor> terminalProcessor = MonoProcessor.create();
+ return it.receive().mergeWith(subscribe.then(Mono.defer(() -> {
+
+ getSubscribers(it).registered();
+
+ return Mono.empty();
+ }))).doOnCancel(() -> {
+
+ Subscribers subscribers = getSubscribers(it);
+ if (subscribers.unregister()) {
+ subscriptions.remove(it);
+ it.unsubscribe().subscribe(v -> terminalProcessor.onComplete(), terminalProcessor::onError);
+ }
+ }).mergeWith(terminalProcessor);
+ });
+
+ return messageStream
+ .map(message -> readMessage(channelSerializer.getReader(), messageSerializer.getReader(), message));
+ }
+
+ private static Mono subscribe(ByteBuffer[] patterns, ByteBuffer[] channels, ReactiveSubscription it) {
+
+ Assert.isTrue(channels.length != 0 || patterns.length != 0, "Must provide either channels or patterns!");
+
+ Mono subscribe = null;
+
+ if (patterns.length != 0) {
+ subscribe = it.pSubscribe(patterns);
+ }
+
+ if (channels.length != 0) {
+
+ Mono channelsSubscribe = it.subscribe(channels);
+
+ if (subscribe == null) {
+ subscribe = channelsSubscribe;
+ } else {
+ subscribe = subscribe.and(channelsSubscribe);
+ }
+ }
+
+ return subscribe;
+ }
+
+ private Subscribers getSubscribers(ReactiveSubscription it) {
+ return subscriptions.computeIfAbsent(it, key -> new Subscribers());
+ }
+
+ private ByteBuffer[] getTargets(Iterable extends Topic> topics, Class> classFilter) {
+
+ return StreamSupport.stream(topics.spliterator(), false) //
+ .filter(classFilter::isInstance) //
+ .map(Topic::getTopic) //
+ .map(stringSerializationPair::write) //
+ .toArray(ByteBuffer[]::new);
+ }
+
+ @SuppressWarnings("unchecked")
+ private ChannelMessage readMessage(RedisElementReader channelSerializer,
+ RedisElementReader messageSerializer, ChannelMessage message) {
+
+ if (message instanceof PatternMessage) {
+
+ PatternMessage patternMessage = (PatternMessage) message;
+
+ String pattern = read(stringSerializationPair.getReader(), patternMessage.getPattern());
+ C channel = read(channelSerializer, patternMessage.getChannel());
+ B body = read(messageSerializer, patternMessage.getMessage());
+
+ return new PatternMessage<>(pattern, channel, body);
+ }
+
+ C channel = read(channelSerializer, message.getChannel());
+ B body = read(messageSerializer, message.getMessage());
+
+ return new ChannelMessage<>(channel, body);
+ }
+
+ private static C read(RedisElementReader reader, ByteBuffer buffer) {
+
+ try {
+ buffer.mark();
+ return reader.read(buffer);
+ } finally {
+ buffer.reset();
+ }
+ }
+
+ /**
+ * Object to track subscriber count and to determine the last unsubscribed subscriber.
+ *
+ * @author Mark Paluch
+ */
+ static class Subscribers {
+
+ private static final AtomicLongFieldUpdater SUBSCRIBERS = AtomicLongFieldUpdater
+ .newUpdater(Subscribers.class, "subscribers");
+
+ // accessed via SUBSCRIBERS
+ @SuppressWarnings("unused") private volatile long subscribers;
+
+ /**
+ * Register a subscriber and increment subscriber count.
+ */
+ void registered() {
+ SUBSCRIBERS.incrementAndGet(this);
+ }
+
+ /**
+ * @return {@literal true} if at least one subscriber registered via {@link #registered()}.
+ */
+ boolean hasRegistration() {
+ return SUBSCRIBERS.get(this) > 0;
+ }
+
+ /**
+ * Unregister a subscriber and decrement subscriber count.
+ *
+ * @return {@literal true} if this was the last unregistered subscriber.
+ */
+ boolean unregister() {
+
+ long value = SUBSCRIBERS.get(this);
+
+ if (value <= 0) {
+ return false;
+ }
+
+ if (SUBSCRIBERS.compareAndSet(this, value, value - 1) && value == 1) {
+ return true;
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/org/springframework/data/redis/util/ByteUtils.java b/src/main/java/org/springframework/data/redis/util/ByteUtils.java
index 7b1783b54..2e24adb96 100644
--- a/src/main/java/org/springframework/data/redis/util/ByteUtils.java
+++ b/src/main/java/org/springframework/data/redis/util/ByteUtils.java
@@ -16,6 +16,8 @@
package org.springframework.data.redis.util;
import java.nio.ByteBuffer;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -203,4 +205,31 @@ public final class ByteUtils {
return -1;
}
+
+ /**
+ * Convert a {@link String} into a {@link ByteBuffer} using {@link java.nio.charset.StandardCharsets#UTF_8}.
+ *
+ * @param theString must not be {@literal null}.
+ * @return
+ * @since 2.1
+ */
+ public static ByteBuffer getByteBuffer(String theString) {
+ return getByteBuffer(theString, StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Convert a {@link String} into a {@link ByteBuffer} using the given {@link Charset}.
+ *
+ * @param theString must not be {@literal null}.
+ * @param charset must not be {@literal null}.
+ * @return
+ * @since 2.1
+ */
+ public static ByteBuffer getByteBuffer(String theString, Charset charset) {
+
+ Assert.notNull(theString, "The String must not be null!");
+ Assert.notNull(charset, "The String must not be null!");
+
+ return charset.encode(theString);
+ }
}
diff --git a/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveSubscriptionUnitTests.java b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveSubscriptionUnitTests.java
new file mode 100644
index 000000000..7b7fe89b6
--- /dev/null
+++ b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveSubscriptionUnitTests.java
@@ -0,0 +1,233 @@
+/*
+ * 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.data.redis.connection.lettuce;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+import static org.springframework.data.redis.util.ByteUtils.*;
+
+import io.lettuce.core.RedisConnectionException;
+import io.lettuce.core.pubsub.api.reactive.RedisPubSubReactiveCommands;
+import reactor.core.Disposable;
+import reactor.core.publisher.DirectProcessor;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.nio.ByteBuffer;
+import java.util.concurrent.CancellationException;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.springframework.data.redis.RedisSystemException;
+import org.springframework.data.redis.connection.ReactiveSubscription.ChannelMessage;
+import org.springframework.data.redis.connection.ReactiveSubscription.PatternMessage;
+
+/**
+ * Unit tests for {@link LettuceReactiveSubscription}.
+ *
+ * @author Mark Paluch
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class LettuceReactiveSubscriptionUnitTests {
+
+ LettuceReactiveSubscription subscription;
+
+ @Mock RedisPubSubReactiveCommands commandsMock;
+
+ @Before
+ public void before() {
+ subscription = new LettuceReactiveSubscription(commandsMock, e -> new RedisSystemException(e.getMessage(), e));
+ }
+
+ @Test // DATAREDIS-612
+ public void shouldSubscribeChannels() {
+
+ when(commandsMock.subscribe(any())).thenReturn(Mono.empty());
+
+ Mono subscribe = subscription.subscribe(getByteBuffer("foo"), getByteBuffer("bar"));
+
+ assertThat(subscription.getChannels()).isEmpty();
+
+ StepVerifier.create(subscribe).verifyComplete();
+
+ assertThat(subscription.getChannels()).containsOnly(getByteBuffer("foo"), getByteBuffer("bar"));
+ assertThat(subscription.getPatterns()).isEmpty();
+ }
+
+ @Test // DATAREDIS-612
+ public void shouldSubscribeChannelsShouldFail() {
+
+ when(commandsMock.subscribe(any())).thenReturn(Mono.error(new RedisConnectionException("Foo")));
+
+ Mono subscribe = subscription.subscribe(getByteBuffer("foo"), getByteBuffer("bar"));
+
+ StepVerifier.create(subscribe).expectError(RedisSystemException.class).verify();
+ }
+
+ @Test // DATAREDIS-612
+ public void shouldSubscribePatterns() {
+
+ when(commandsMock.psubscribe(any())).thenReturn(Mono.empty());
+
+ Mono subscribe = subscription.pSubscribe(getByteBuffer("foo"), getByteBuffer("bar"));
+
+ assertThat(subscription.getPatterns()).isEmpty();
+
+ StepVerifier.create(subscribe).verifyComplete();
+
+ assertThat(subscription.getPatterns()).containsOnly(getByteBuffer("foo"), getByteBuffer("bar"));
+ assertThat(subscription.getChannels()).isEmpty();
+ }
+
+ @Test // DATAREDIS-612
+ public void shouldUnsubscribeChannels() {
+
+ when(commandsMock.subscribe(any())).thenReturn(Mono.empty());
+ when(commandsMock.unsubscribe(any())).thenReturn(Mono.empty());
+ StepVerifier.create(subscription.subscribe(getByteBuffer("foo"), getByteBuffer("bar"))).verifyComplete();
+
+ StepVerifier.create(subscription.unsubscribe()).verifyComplete();
+
+ assertThat(subscription.getChannels()).isEmpty();
+ verify(commandsMock).unsubscribe(any());
+ }
+
+ @Test // DATAREDIS-612
+ public void shouldUnsubscribePatterns() {
+
+ when(commandsMock.psubscribe(any())).thenReturn(Mono.empty());
+ when(commandsMock.punsubscribe(any())).thenReturn(Mono.empty());
+ StepVerifier.create(subscription.pSubscribe(getByteBuffer("foo"), getByteBuffer("bar"))).verifyComplete();
+
+ StepVerifier.create(subscription.pUnsubscribe()).verifyComplete();
+
+ assertThat(subscription.getPatterns()).isEmpty();
+ verify(commandsMock).punsubscribe(any());
+ }
+
+ @Test // DATAREDIS-612
+ public void shouldEmitChannelMessage() {
+
+ when(commandsMock.subscribe(any())).thenReturn(Mono.empty());
+ StepVerifier.create(subscription.subscribe(getByteBuffer("foo"), getByteBuffer("bar"))).verifyComplete();
+
+ DirectProcessor> emitter = DirectProcessor
+ .create();
+ when(commandsMock.observeChannels()).thenReturn(emitter);
+ when(commandsMock.observePatterns()).thenReturn(Flux.empty());
+
+ StepVerifier.create(subscription.receive()).then(() -> {
+
+ emitter.onNext(createChannelMessage("other", "body"));
+ emitter.onNext(createChannelMessage("foo", "body"));
+ }).assertNext(msg -> {
+ assertThat(msg.getChannel()).isEqualTo(getByteBuffer("foo"));
+ }).thenCancel().verify();
+ }
+
+ @Test // DATAREDIS-612
+ public void shouldEmitPatternMessage() {
+
+ when(commandsMock.psubscribe(any())).thenReturn(Mono.empty());
+ StepVerifier.create(subscription.pSubscribe(getByteBuffer("foo*"), getByteBuffer("bar*"))).verifyComplete();
+
+ DirectProcessor> emitter = DirectProcessor
+ .create();
+ when(commandsMock.observeChannels()).thenReturn(Flux.empty());
+ when(commandsMock.observePatterns()).thenReturn(emitter);
+
+ StepVerifier.create(subscription.receive()).then(() -> {
+
+ emitter.onNext(createPatternMessage("other*", "channel", "body"));
+ emitter.onNext(createPatternMessage("foo*", "foo", "body"));
+ }).assertNext(msg -> {
+
+ assertThat(((PatternMessage) msg).getPattern()).isEqualTo(getByteBuffer("foo*"));
+ assertThat(msg.getChannel()).isEqualTo(getByteBuffer("foo"));
+ }).thenCancel().verify();
+ }
+
+ @Test // DATAREDIS-612
+ public void shouldEmitError() {
+
+ when(commandsMock.subscribe(any())).thenReturn(Mono.empty());
+ StepVerifier.create(subscription.subscribe(getByteBuffer("foo"), getByteBuffer("bar"))).verifyComplete();
+
+ DirectProcessor> emitter = DirectProcessor
+ .create();
+ when(commandsMock.observeChannels()).thenReturn(emitter);
+ when(commandsMock.observePatterns()).thenReturn(Flux.empty());
+
+ StepVerifier.create(subscription.receive()).then(() -> {
+
+ emitter.onError(new RedisConnectionException("foo"));
+ }).expectError(RedisSystemException.class).verify();
+ }
+
+ @Test // DATAREDIS-612
+ public void shouldTerminateActiveSubscriptions() {
+
+ when(commandsMock.psubscribe(any())).thenReturn(Mono.empty());
+ when(commandsMock.punsubscribe(any())).thenReturn(Mono.empty());
+ StepVerifier.create(subscription.pSubscribe(getByteBuffer("foo*"))).verifyComplete();
+
+ when(commandsMock.observeChannels()).thenReturn(Flux.never());
+ when(commandsMock.observePatterns()).thenReturn(Flux.never());
+
+ StepVerifier.create(subscription.receive()).then(() -> {
+ subscription.terminate().subscribe();
+ }).expectError(CancellationException.class).verify();
+
+ assertThat(subscription.getPatterns()).isEmpty();
+ }
+
+ @Test // DATAREDIS-612
+ public void cancelledSubscriptionShouldUnregisterDownstream() {
+
+ DirectProcessor> emitter = DirectProcessor
+ .create();
+
+ when(commandsMock.psubscribe(any())).thenReturn(Mono.empty());
+ StepVerifier.create(subscription.pSubscribe(getByteBuffer("foo*"))).verifyComplete();
+
+ when(commandsMock.observeChannels()).thenReturn(Flux.never());
+ when(commandsMock.observePatterns()).thenReturn(emitter);
+
+ Flux> receive = subscription.receive();
+ Disposable subscribe = receive.subscribe();
+
+ assertThat(emitter.downstreamCount()).isEqualTo(1);
+
+ subscribe.dispose();
+ assertThat(emitter.downstreamCount()).isEqualTo(0);
+ }
+
+ private static io.lettuce.core.pubsub.api.reactive.ChannelMessage createChannelMessage(
+ String channel, String body) {
+ return new io.lettuce.core.pubsub.api.reactive.ChannelMessage<>(getByteBuffer(channel), getByteBuffer(body));
+ }
+
+ private static io.lettuce.core.pubsub.api.reactive.PatternMessage createPatternMessage(
+ String pattern, String channel, String body) {
+ return new io.lettuce.core.pubsub.api.reactive.PatternMessage<>(getByteBuffer(pattern), getByteBuffer(channel),
+ getByteBuffer(body));
+ }
+}
diff --git a/src/test/java/org/springframework/data/redis/listener/ReactiveOperationsTestParams.java b/src/test/java/org/springframework/data/redis/listener/ReactiveOperationsTestParams.java
new file mode 100644
index 000000000..dae2efeee
--- /dev/null
+++ b/src/test/java/org/springframework/data/redis/listener/ReactiveOperationsTestParams.java
@@ -0,0 +1,103 @@
+/*
+ * 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.data.redis.listener;
+
+import static org.springframework.data.redis.connection.ClusterTestVariables.*;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+import org.junit.runners.model.Statement;
+import org.springframework.data.redis.SettingsUtils;
+import org.springframework.data.redis.connection.RedisClusterConfiguration;
+import org.springframework.data.redis.connection.RedisClusterNode;
+import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
+import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
+import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
+import org.springframework.data.redis.connection.lettuce.LettucePoolingClientConfiguration;
+import org.springframework.data.redis.connection.lettuce.LettuceTestClientResources;
+import org.springframework.data.redis.test.util.RedisClusterRule;
+
+/**
+ * Parameters for testing implementations of {@link ReactiveRedisMessageListenerContainer}
+ *
+ * @author Mark Paluch
+ */
+class ReactiveOperationsTestParams {
+
+ public static Collection