Skip to content

Change default value for KeyspaceEventMessageListener on keyspace event notifications #2671

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@

<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>3.2.0-SNAPSHOT</version>
<version>3.2.0-GH-2670-SNAPSHOT</version>

<name>Spring Data Redis</name>
<description>Spring Data module for Redis</description>
Original file line number Diff line number Diff line change
@@ -25,24 +25,35 @@
/**
* {@link MessageListener} publishing {@link RedisKeyExpiredEvent}s via {@link ApplicationEventPublisher} by listening
* to Redis keyspace notifications for key expirations.
* <p>
* For development-time convenience the {@link #setKeyspaceNotificationsConfigParameter(String)} is set to
* {@literal "Ex"}, by default. However, it is strongly recommended that users specifically set
* {@literal notify-keyspace-events} to the appropriate value on the Redis server, in {@literal redis.conf}.
* <p>
* Any Redis server configuration coming from your Spring (Data Redis) application only occurs during Spring container
* initialization, and is not persisted across Redis server restarts.
*
* @author Christoph Strobl
* @author John Blum
* @since 1.7
*/
public class KeyExpirationEventMessageListener extends KeyspaceEventMessageListener implements
ApplicationEventPublisherAware {

private static final String EXPIRED_KEY_EVENTS = "Ex";

private static final Topic KEYEVENT_EXPIRED_TOPIC = new PatternTopic("__keyevent@*__:expired");

private @Nullable ApplicationEventPublisher publisher;

/**
* Creates new {@link MessageListener} for {@code __keyevent@*__:expired} messages.
* Creates new {@link MessageListener} for {@code __keyevent@*__:expired} messages and configures notification on
* expired keys ({@literal Ex}).
*
* @param listenerContainer must not be {@literal null}.
*/
public KeyExpirationEventMessageListener(RedisMessageListenerContainer listenerContainer) {
super(listenerContainer);
super(listenerContainer, EXPIRED_KEY_EVENTS);
}

@Override
Original file line number Diff line number Diff line change
@@ -17,101 +17,199 @@

import java.util.Properties;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;

/**
* Base {@link MessageListener} implementation for listening to Redis keyspace notifications.
* <p>
* By default, this {@link MessageListener} does not listen for, or notify on, any keyspace events. You must explicitly
* set the {@link #setKeyspaceNotificationsConfigParameter(String)} to a valid {@literal redis.conf},
* {@literal notify-keyspace-events} value (for example: {@literal EA}) to enable keyspace event notifications
* from your Redis server.
* <p>
* Any configuration set in the Redis server take precedence. Therefore, if the Redis server already set a value
* for {@literal notify-keyspace-events}, then any {@link #setKeyspaceNotificationsConfigParameter(String)}
* specified on this listener will be ignored.
* <p>
* It is recommended that all infrastructure settings, such as {@literal notify-keyspace-events}, be configured on
* the Redis server itself. If the Redis server is rebooted, then any keyspace event configuration coming from
* the application will be lost when the Redis server is restarted since Redis server configuration is not persistent,
* and any configuration coming from your application only occurs during Spring container initialization.
*
* @author Christoph Strobl
* @author Mark Paluch
* @author John Blum
* @since 1.7
*/
public abstract class KeyspaceEventMessageListener implements MessageListener, InitializingBean, DisposableBean {

protected static final String DISABLED_KEY_EVENTS = "";
protected static final String NOTIFY_KEYSPACE_EVENTS = "notify-keyspace-events";

private static final Topic TOPIC_ALL_KEYEVENTS = new PatternTopic("__keyevent@*");

private final RedisMessageListenerContainer listenerContainer;
private final Logger logger = LoggerFactory.getLogger(getClass());

private final RedisMessageListenerContainer messageListenerContainer;

private @Nullable String keyspaceNotificationsConfigParameter;

/**
* Creates a new {@link KeyspaceEventMessageListener}.
*
* @param messageListenerContainer {@link RedisMessageListenerContainer} in which this listener will be registered;
* must not be {@literal null}.
*/
public KeyspaceEventMessageListener(RedisMessageListenerContainer messageListenerContainer) {
this(messageListenerContainer, DISABLED_KEY_EVENTS);
}

/**
* Creates a new {@link KeyspaceEventMessageListener} along with initialization for
* {@literal notify-keyspace-events}.
*
* @param messageListenerContainer {@link RedisMessageListenerContainer} in which this listener will be registered;
* must not be {@literal null}.
* @param keyspaceNotificationsConfigParameter {@link String default value} for {@literal notify-keyspace-events};
* may be {@literal null}.
*/
protected KeyspaceEventMessageListener(RedisMessageListenerContainer messageListenerContainer,
@Nullable String keyspaceNotificationsConfigParameter) {

Assert.notNull(messageListenerContainer, "RedisMessageListenerContainer to run in must not be null");

private String keyspaceNotificationsConfigParameter = "EA";
this.messageListenerContainer = messageListenerContainer;
this.keyspaceNotificationsConfigParameter = keyspaceNotificationsConfigParameter;
}

/**
* Creates new {@link KeyspaceEventMessageListener}.
* Returns a reference to the configured {@link Logger}.
*
* @param listenerContainer must not be {@literal null}.
* @return a reference to the configured {@link Logger}.
*/
public KeyspaceEventMessageListener(RedisMessageListenerContainer listenerContainer) {
protected Logger getLogger() {
return this.logger;
}

Assert.notNull(listenerContainer, "RedisMessageListenerContainer to run in must not be null");
this.listenerContainer = listenerContainer;
/**
* Returns a configured reference to the {@link RedisMessageListenerContainer} to which this {@link MessageListener}
* is registered.
*
* @return a configured reference to the {@link RedisMessageListenerContainer} to which this {@link MessageListener}
* is registered.
*/
protected RedisMessageListenerContainer getMessageListenerContainer() {
return this.messageListenerContainer;
}

@Override
public void onMessage(Message message, @Nullable byte[] pattern) {

if (ObjectUtils.isEmpty(message.getChannel()) || ObjectUtils.isEmpty(message.getBody())) {
return;
if (containsChannelContent(message)) {
doHandleMessage(message);
}
}

doHandleMessage(message);
// Message must have a channel and body (contain content)
private boolean containsChannelContent(Message message) {
return !(ObjectUtils.isEmpty(message.getChannel()) || ObjectUtils.isEmpty(message.getBody()));
}

/**
* Handle the actual message
* Handle the actual {@link Message}.
*
* @param message never {@literal null}.
* @param message {@link Message} to process; never {@literal null}.
*/
protected abstract void doHandleMessage(Message message);

@Override
public void afterPropertiesSet() throws Exception {
init();
}

/**
* Initialize the message listener by writing requried redis config for {@literal notify-keyspace-events} and
* registering the listener within the container.
* Initialize this {@link MessageListener} by writing required Redis server config
* for {@literal notify-keyspace-events} and registering this {@link MessageListener}
* with the {@link RedisMessageListenerContainer}.
*/
public void init() {

if (StringUtils.hasText(keyspaceNotificationsConfigParameter)) {
String keyspaceNotificationsConfigParameter = getKeyspaceNotificationsConfigParameter();

RedisConnection connection = listenerContainer.getConnectionFactory().getConnection();
if (isSet(keyspaceNotificationsConfigParameter)) {
configureKeyspaceEventNotifications(keyspaceNotificationsConfigParameter);
}

try {
doRegister(getMessageListenerContainer());
}

Properties config = connection.getConfig("notify-keyspace-events");
private boolean isSet(@Nullable String value) {
return StringUtils.hasText(value);
}

if (!StringUtils.hasText(config.getProperty("notify-keyspace-events"))) {
connection.setConfig("notify-keyspace-events", keyspaceNotificationsConfigParameter);
}
void configureKeyspaceEventNotifications(String keyspaceNotificationsConfigParameter) {

RedisConnectionFactory connectionFactory = getMessageListenerContainer().getConnectionFactory();

} finally {
connection.close();
if (connectionFactory != null) {
try (RedisConnection connection = connectionFactory.getConnection()) {
if (canChangeNotifyKeyspaceEvents(connection)) {
setKeyspaceEventNotifications(connection, keyspaceNotificationsConfigParameter);
}
}
}
else {
if (getLogger().isWarnEnabled()) {
getLogger().warn("Unable to configure notification on keyspace events;"
+ " no RedisConnectionFactory was configured in the RedisMessageListenerContainer");
}
}
}

private boolean canChangeNotifyKeyspaceEvents(@Nullable RedisConnection connection) {

if (connection != null) {

Properties config = connection.serverCommands().getConfig(NOTIFY_KEYSPACE_EVENTS);

return config == null || !isSet(config.getProperty(NOTIFY_KEYSPACE_EVENTS));
}

doRegister(listenerContainer);
return false;
}

void setKeyspaceEventNotifications(RedisConnection connection, String keyspaceNotificationsConfigParameter) {
connection.serverCommands().setConfig(NOTIFY_KEYSPACE_EVENTS, keyspaceNotificationsConfigParameter);
}

@Override
public void destroy() throws Exception {
getMessageListenerContainer().removeMessageListener(this);
}

/**
* Register instance within the container.
* Register instance within the {@link RedisMessageListenerContainer}.
*
* @param container never {@literal null}.
*/
protected void doRegister(RedisMessageListenerContainer container) {
listenerContainer.addMessageListener(this, TOPIC_ALL_KEYEVENTS);
}

@Override
public void destroy() throws Exception {
listenerContainer.removeMessageListener(this);
container.addMessageListener(this, TOPIC_ALL_KEYEVENTS);
}

/**
* Set the configuration string to use for {@literal notify-keyspace-events}.
* Set the {@link String configuration setting} (for example: {@literal EA}) to use
* for {@literal notify-keyspace-events}.
*
* @param keyspaceNotificationsConfigParameter can be {@literal null}.
* @since 1.8
@@ -120,8 +218,13 @@ public void setKeyspaceNotificationsConfigParameter(String keyspaceNotifications
this.keyspaceNotificationsConfigParameter = keyspaceNotificationsConfigParameter;
}

@Override
public void afterPropertiesSet() throws Exception {
init();
/**
* Get the configured {@link String setting} for {@literal notify-keyspace-events}.
*
* @return the configured {@link String setting} for {@literal notify-keyspace-events}.
*/
@Nullable
protected String getKeyspaceNotificationsConfigParameter() {
return this.keyspaceNotificationsConfigParameter;
}
}
Original file line number Diff line number Diff line change
@@ -27,12 +27,10 @@
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;

import org.springframework.beans.factory.DisposableBean;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.connection.jedis.extension.JedisConnectionFactoryExtension;
import org.springframework.data.redis.test.extension.RedisStanalone;

@@ -103,7 +101,7 @@ void listenerShouldPublishEventCorrectly() {
@Test // DATAREDIS-425
void listenerShouldNotReactToDeleteEvents() throws InterruptedException {

byte[] key = ("to-delete:" + UUID.randomUUID().toString()).getBytes();
byte[] key = ("to-delete:" + UUID.randomUUID()).getBytes();

try (RedisConnection connection = connectionFactory.getConnection()) {

Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
/*
* Copyright 2017-2023 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
*
* https://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.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.withSettings;

import java.util.Properties;
import java.util.function.Consumer;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.quality.Strictness;

import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisServerCommands;
import org.springframework.lang.Nullable;

/**
* Unit tests for {@link KeyspaceEventMessageListener}.
*
* @author John Blum
*/
@ExtendWith(MockitoExtension.class)
class KeyspaceEventMessageListenerUnitTests {

@Mock
private RedisMessageListenerContainer mockMessageListenerContainer;

private Message mockMessage(@Nullable String channel, @Nullable String body) {

Message mockMessage = mock(Message.class, withSettings().strictness(Strictness.LENIENT));

doReturn(toBytes(body)).when(mockMessage).getBody();
doReturn(toBytes(channel)).when(mockMessage).getChannel();

return mockMessage;
}

@Nullable
private byte[] toBytes(@Nullable String value) {
return value != null ? value.getBytes() : null;
}

private KeyspaceEventMessageListener newKeyspaceEventMessageListener() {
return newKeyspaceEventMessageListener(listener -> { });
}

private KeyspaceEventMessageListener newKeyspaceEventMessageListener(
Consumer<KeyspaceEventMessageListener> preConditions) {

TestKeyspaceEventMessageListener listener =
new TestKeyspaceEventMessageListener(this.mockMessageListenerContainer);

preConditions.accept(listener);

return spy(listener);
}

@SuppressWarnings("all")
private Properties singletonProperties(String propertyName, String propertyValue) {
Properties properties = new Properties();
properties.setProperty(propertyName, propertyValue);
return properties;
}

@Test // GH-2670
void handlesMessageWithChannelAndBody() {

Message mockMessage = mockMessage("TestChannel", "TestBody");

KeyspaceEventMessageListener listener = newKeyspaceEventMessageListener();

listener.onMessage(mockMessage, null);

verify(listener, times(1)).onMessage(eq(mockMessage), isNull());
verify(mockMessage, times(1)).getChannel();
verify(mockMessage, times(1)).getBody();
verify(listener, times(1)).doHandleMessage(eq(mockMessage));
verifyNoMoreInteractions(mockMessage, listener);
}

@Test // GH-2670
public void ignoreMessageWithNoBody() {

Message mockMessage = mockMessage("TestChannel", null);

KeyspaceEventMessageListener listener = newKeyspaceEventMessageListener();

listener.onMessage(mockMessage, null);

verify(listener, times(1)).onMessage(eq(mockMessage), isNull());
verify(mockMessage, times(1)).getChannel();
verify(mockMessage, times(1)).getBody();
verify(listener, never()).doHandleMessage(any());
verifyNoMoreInteractions(mockMessage, listener);
}

@Test // GH-2670
public void ignoreMessageWithNoChannel() {

Message mockMessage = mockMessage(null, "TestBody");

KeyspaceEventMessageListener listener = newKeyspaceEventMessageListener();

listener.onMessage(mockMessage, null);

verify(listener, times(1)).onMessage(eq(mockMessage), isNull());
verify(mockMessage, times(1)).getChannel();
verify(listener, never()).doHandleMessage(any());
verifyNoMoreInteractions(mockMessage, listener);
}

@Test // GH-2670
public void doNotConfigureKeyspaceEventNotificationsWhenConfigParameterNotSpecified() {

KeyspaceEventMessageListener listener = newKeyspaceEventMessageListener(it ->
assertThat(it.getKeyspaceNotificationsConfigParameter()).isEmpty());

listener.init();

verify(listener, never()).configureKeyspaceEventNotifications(any());
}

@Test // GH-2670
public void doNotConfigureKeyspaceEventNotificationsWhenContainerHasNoConnectionFactory() {

KeyspaceEventMessageListener listener = newKeyspaceEventMessageListener();

listener.setKeyspaceNotificationsConfigParameter("EA");
listener.init();

assertThat(listener.getKeyspaceNotificationsConfigParameter()).isEqualTo("EA");

verify(listener, times(1)).configureKeyspaceEventNotifications(any());
verify(listener, never()).setKeyspaceEventNotifications(any(), any());
}

@Test // GH-2670
public void doNotConfigureKeyspaceEventNotificationsWhenRedisServerSettingIsAlreadySet() {

RedisConnectionFactory mockConnectionFactory = mock(RedisConnectionFactory.class);

RedisConnection mockConnection = mock(RedisConnection.class);

RedisServerCommands mockServerCommands = mock(RedisServerCommands.class);

Properties config = singletonProperties(KeyspaceEventMessageListener.NOTIFY_KEYSPACE_EVENTS, "Em");

doReturn(mockConnectionFactory).when(this.mockMessageListenerContainer).getConnectionFactory();
doReturn(mockConnection).when(mockConnectionFactory).getConnection();
doReturn(mockServerCommands).when(mockConnection).serverCommands();
doReturn(config).when(mockServerCommands).getConfig(any());

KeyspaceEventMessageListener listener = newKeyspaceEventMessageListener();

listener.setKeyspaceNotificationsConfigParameter("EA");
listener.init();

assertThat(listener.getKeyspaceNotificationsConfigParameter()).isEqualTo("EA");

verify(listener, times(1)).configureKeyspaceEventNotifications(any());
verify(mockServerCommands, times(1)).getConfig(eq(KeyspaceEventMessageListener.NOTIFY_KEYSPACE_EVENTS));
verify(listener, never()).setKeyspaceEventNotifications(any(), any());
verify(mockConnection, times(1)).close();
}

@Test // GH-2670
public void configuresKeyspaceEventNotificationsWhenRedisServerHasNoSettings() {

RedisConnectionFactory mockConnectionFactory = mock(RedisConnectionFactory.class);

RedisConnection mockConnection = mock(RedisConnection.class);

RedisServerCommands mockServerCommands = mock(RedisServerCommands.class);

doReturn(mockConnectionFactory).when(this.mockMessageListenerContainer).getConnectionFactory();
doReturn(mockConnection).when(mockConnectionFactory).getConnection();
doReturn(mockServerCommands).when(mockConnection).serverCommands();
doReturn(null).when(mockServerCommands).getConfig(any());

KeyspaceEventMessageListener listener = newKeyspaceEventMessageListener();

listener.setKeyspaceNotificationsConfigParameter("EA");
listener.init();

assertThat(listener.getKeyspaceNotificationsConfigParameter()).isEqualTo("EA");

verify(listener, times(1)).configureKeyspaceEventNotifications(any());
verify(mockServerCommands, times(1))
.getConfig(eq(KeyspaceEventMessageListener.NOTIFY_KEYSPACE_EVENTS));
verify(listener, times(1))
.setKeyspaceEventNotifications(eq(mockConnection), eq("EA"));
verify(mockServerCommands, times(1))
.setConfig(eq(KeyspaceEventMessageListener.NOTIFY_KEYSPACE_EVENTS), eq("EA"));
verify(mockConnection, times(1)).close();
}

@Test // GH-2670
public void configuresKeyspaceEventNotificationsCorrectly() {

RedisConnectionFactory mockConnectionFactory = mock(RedisConnectionFactory.class);

RedisConnection mockConnection = mock(RedisConnection.class);

RedisServerCommands mockServerCommands = mock(RedisServerCommands.class);

Properties config = singletonProperties(KeyspaceEventMessageListener.NOTIFY_KEYSPACE_EVENTS, " ");

doReturn(mockConnectionFactory).when(this.mockMessageListenerContainer).getConnectionFactory();
doReturn(mockConnection).when(mockConnectionFactory).getConnection();
doReturn(mockServerCommands).when(mockConnection).serverCommands();
doReturn(config).when(mockServerCommands).getConfig(any());

KeyspaceEventMessageListener listener = newKeyspaceEventMessageListener();

listener.setKeyspaceNotificationsConfigParameter("EA");
listener.init();

assertThat(listener.getKeyspaceNotificationsConfigParameter()).isEqualTo("EA");

verify(listener, times(1)).configureKeyspaceEventNotifications(any());
verify(mockServerCommands, times(1))
.getConfig(eq(KeyspaceEventMessageListener.NOTIFY_KEYSPACE_EVENTS));
verify(listener, times(1))
.setKeyspaceEventNotifications(eq(mockConnection), eq("EA"));
verify(mockServerCommands, times(1))
.setConfig(eq(KeyspaceEventMessageListener.NOTIFY_KEYSPACE_EVENTS), eq("EA"));
verify(mockConnection, times(1)).close();
}

static class TestKeyspaceEventMessageListener extends KeyspaceEventMessageListener {

TestKeyspaceEventMessageListener(RedisMessageListenerContainer messageListenerContainer) {
super(messageListenerContainer);
}

@Override
protected void doHandleMessage(Message message) {

}
}
}