diff --git a/src/main/java/org/springframework/data/redis/listener/KeyExpirationEventMessageListener.java b/src/main/java/org/springframework/data/redis/listener/KeyExpirationEventMessageListener.java index 61039da973..4b370010df 100644 --- a/src/main/java/org/springframework/data/redis/listener/KeyExpirationEventMessageListener.java +++ b/src/main/java/org/springframework/data/redis/listener/KeyExpirationEventMessageListener.java @@ -25,24 +25,36 @@ /** * {@link MessageListener} publishing {@link RedisKeyExpiredEvent}s via {@link ApplicationEventPublisher} by listening * to Redis keyspace notifications for key expirations. + *

+ * 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}. + *

+ * 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); + setKeyspaceNotificationsConfigParameter(EXPIRED_KEY_EVENTS); } @Override diff --git a/src/main/java/org/springframework/data/redis/listener/KeyspaceEventMessageListener.java b/src/main/java/org/springframework/data/redis/listener/KeyspaceEventMessageListener.java index eba222f5fd..59b013bd0c 100644 --- a/src/main/java/org/springframework/data/redis/listener/KeyspaceEventMessageListener.java +++ b/src/main/java/org/springframework/data/redis/listener/KeyspaceEventMessageListener.java @@ -17,11 +17,14 @@ 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; @@ -29,89 +32,168 @@ /** * Base {@link MessageListener} implementation for listening to Redis keyspace notifications. + *

+ * 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. + *

+ * 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. + *

+ * 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 { + 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 String keyspaceNotificationsConfigParameter = "EA"; + private String keyspaceNotificationsConfigParameter = ""; /** - * Creates new {@link KeyspaceEventMessageListener}. + * Creates a new {@link KeyspaceEventMessageListener}. * - * @param listenerContainer must not be {@literal null}. + * @param messageListenerContainer {@link RedisMessageListenerContainer} in which this listener will be registered; + * must not be {@literal null}. */ - public KeyspaceEventMessageListener(RedisMessageListenerContainer listenerContainer) { + public KeyspaceEventMessageListener(RedisMessageListenerContainer messageListenerContainer) { - Assert.notNull(listenerContainer, "RedisMessageListenerContainer to run in must not be null"); - this.listenerContainer = listenerContainer; + Assert.notNull(messageListenerContainer, "RedisMessageListenerContainer to run in must not be null"); + + this.messageListenerContainer = messageListenerContainer; + } + + /** + * Returns a reference to the configured {@link Logger}. + * + * @return a reference to the configured {@link Logger}. + */ + protected Logger getLogger() { + return this.logger; + } + + /** + * 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); + } + + doRegister(getMessageListenerContainer()); + } - try { + private boolean isSet(@Nullable String value) { + return StringUtils.hasText(value); + } - Properties config = connection.getConfig("notify-keyspace-events"); + void configureKeyspaceEventNotifications(String keyspaceNotificationsConfigParameter) { - if (!StringUtils.hasText(config.getProperty("notify-keyspace-events"))) { - connection.setConfig("notify-keyspace-events", 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)); + } + + return false; + } + + void setKeyspaceEventNotifications(RedisConnection connection, String keyspaceNotificationsConfigParameter) { + connection.serverCommands().setConfig(NOTIFY_KEYSPACE_EVENTS, keyspaceNotificationsConfigParameter); + } - doRegister(listenerContainer); + @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 +202,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; } } diff --git a/src/test/java/org/springframework/data/redis/listener/KeyExpirationEventMessageListenerIntegrationTests.java b/src/test/java/org/springframework/data/redis/listener/KeyExpirationEventMessageListenerIntegrationTests.java index dc4b13e02c..1194c78463 100644 --- a/src/test/java/org/springframework/data/redis/listener/KeyExpirationEventMessageListenerIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/listener/KeyExpirationEventMessageListenerIntegrationTests.java @@ -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()) { diff --git a/src/test/java/org/springframework/data/redis/listener/KeyspaceEventMessageListenerUnitTests.java b/src/test/java/org/springframework/data/redis/listener/KeyspaceEventMessageListenerUnitTests.java new file mode 100644 index 0000000000..3feb48a00d --- /dev/null +++ b/src/test/java/org/springframework/data/redis/listener/KeyspaceEventMessageListenerUnitTests.java @@ -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 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) { + + } + } +}