diff --git a/spring-session/src/main/java/org/springframework/session/MapSessionRepository.java b/spring-session/src/main/java/org/springframework/session/MapSessionRepository.java
index 6c3d982..19bde00 100644
--- a/spring-session/src/main/java/org/springframework/session/MapSessionRepository.java
+++ b/spring-session/src/main/java/org/springframework/session/MapSessionRepository.java
@@ -15,6 +15,7 @@
*/
package org.springframework.session;
+import org.springframework.session.events.SessionDestroyedEvent;
import org.springframework.util.Assert;
import java.util.Map;
@@ -25,6 +26,10 @@ import java.util.concurrent.ConcurrentHashMap;
* {@link java.util.concurrent.ConcurrentHashMap} is used, but a custom {@link java.util.Map} can be injected to use
* distributed maps provided by NoSQL stores like Redis and Hazelcast.
*
+ *
+ * The implementation does NOT support firing {@link SessionDestroyedEvent}.
+ *
+ *
* @author Rob Winch
* @since 1.0
*/
diff --git a/spring-session/src/main/java/org/springframework/session/data/redis/RedisOperationsSessionRepository.java b/spring-session/src/main/java/org/springframework/session/data/redis/RedisOperationsSessionRepository.java
index 33b20c0..902975b 100644
--- a/spring-session/src/main/java/org/springframework/session/data/redis/RedisOperationsSessionRepository.java
+++ b/spring-session/src/main/java/org/springframework/session/data/redis/RedisOperationsSessionRepository.java
@@ -30,6 +30,7 @@ import org.springframework.session.ExpiringSession;
import org.springframework.session.MapSession;
import org.springframework.session.Session;
import org.springframework.session.SessionRepository;
+import org.springframework.session.events.SessionDestroyedEvent;
import org.springframework.session.web.http.SessionRepositoryFilter;
import org.springframework.util.Assert;
@@ -39,7 +40,8 @@ import org.springframework.util.Assert;
* using Spring Data's
* {@link org.springframework.data.redis.core.RedisOperations}. In a web
* environment, this is typically used in combination with
- * {@link SessionRepositoryFilter}.
+ * {@link SessionRepositoryFilter}. This implementation supports
+ * {@link SessionDestroyedEvent} through {@link SessionMessageListener}.
*
*
* Creating a new instance
diff --git a/spring-session/src/main/java/org/springframework/session/data/redis/SessionMessageListener.java b/spring-session/src/main/java/org/springframework/session/data/redis/SessionMessageListener.java
new file mode 100644
index 0000000..05877ff
--- /dev/null
+++ b/spring-session/src/main/java/org/springframework/session/data/redis/SessionMessageListener.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2002-2013 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.session.data.redis;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.springframework.context.ApplicationEvent;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.data.redis.connection.Message;
+import org.springframework.data.redis.connection.MessageListener;
+import org.springframework.session.events.SessionDestroyedEvent;
+import org.springframework.util.Assert;
+
+/**
+ * Listen for Redis {@link Message} notifications. If it is a "del" or "expired"
+ * translate into a {@link SessionDestroyedEvent}.
+ *
+ * @author Rob Winch
+ * @since 1.0
+ */
+public class SessionMessageListener implements MessageListener {
+ private static final Log logger = LogFactory.getLog(SessionMessageListener.class);
+
+ private final ApplicationEventPublisher eventPublisher;
+
+ /**
+ * Creates a new instance
+ *
+ * @param eventPublisher the {@link ApplicationEventPublisher} to use. Cannot be null.
+ */
+ public SessionMessageListener(ApplicationEventPublisher eventPublisher) {
+ Assert.notNull(eventPublisher, "eventPublisher cannot be null");
+ this.eventPublisher = eventPublisher;
+ }
+
+ @Override
+ public void onMessage(Message message, byte[] pattern) {
+ byte[] messageBody = message.getBody();
+ if(messageBody == null) {
+ return;
+ }
+ String body = new String(messageBody);
+ if(!("del".equals(body) || "expired".equals(body))) {
+ return;
+ }
+ String channel = new String(message.getChannel());
+ int beginIndex = channel.lastIndexOf(":") + 1;
+ int endIndex = channel.length();
+ String sessionId = channel.substring(beginIndex, endIndex);
+
+ publishEvent(new SessionDestroyedEvent(this, sessionId));
+ }
+
+ private void publishEvent(ApplicationEvent event) {
+ try {
+ this.eventPublisher.publishEvent(event);
+ }
+ catch (Throwable ex) {
+ logger.error("Error publishing " + event + ".", ex);
+ }
+ }
+
+}
diff --git a/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java b/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java
index cb2de28..5775f9b 100644
--- a/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java
+++ b/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java
@@ -20,6 +20,7 @@ import java.util.Map;
import org.springframework.beans.factory.BeanClassLoaderAware;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportAware;
@@ -28,10 +29,13 @@ import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.type.AnnotationMetadata;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.listener.PatternTopic;
+import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.session.ExpiringSession;
import org.springframework.session.SessionRepository;
import org.springframework.session.data.redis.RedisOperationsSessionRepository;
+import org.springframework.session.data.redis.SessionMessageListener;
import org.springframework.session.web.http.HttpSessionStrategy;
import org.springframework.session.web.http.SessionRepositoryFilter;
import org.springframework.util.ClassUtils;
@@ -55,6 +59,24 @@ public class RedisHttpSessionConfiguration implements ImportAware, BeanClassLoad
private HttpSessionStrategy httpSessionStrategy;
+ @Autowired
+ private ApplicationEventPublisher eventPublisher;
+
+ @Bean
+ public RedisMessageListenerContainer redisMessageListenerContainer(
+ RedisConnectionFactory connectionFactory) {
+ RedisMessageListenerContainer container = new RedisMessageListenerContainer();
+ container.setConnectionFactory(connectionFactory);
+ container.addMessageListener(redisSessionMessageListener(),
+ new PatternTopic("__keyspace@0__:spring:session:sessions:*"));
+ return container;
+ }
+
+ @Bean
+ public SessionMessageListener redisSessionMessageListener() {
+ return new SessionMessageListener(eventPublisher);
+ }
+
@Bean
public RedisTemplate sessionRedisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate template = new RedisTemplate();
diff --git a/spring-session/src/main/java/org/springframework/session/events/SessionDestroyedEvent.java b/spring-session/src/main/java/org/springframework/session/events/SessionDestroyedEvent.java
new file mode 100644
index 0000000..e56411b
--- /dev/null
+++ b/spring-session/src/main/java/org/springframework/session/events/SessionDestroyedEvent.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2002-2013 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.session.events;
+
+import org.springframework.context.ApplicationEvent;
+import org.springframework.session.Session;
+import org.springframework.session.SessionRepository;
+
+/**
+ * For {@link SessionRepository} implementations that support it, this event is
+ * fired when a {@link Session} is destroyed either explicitly or via
+ * expiration.
+ *
+ * @author Rob Winch
+ * @since 1.0
+ *
+ */
+@SuppressWarnings("serial")
+public class SessionDestroyedEvent extends ApplicationEvent {
+ private final String sessionId;
+
+ public SessionDestroyedEvent(Object source, String sessionId) {
+ super(source);
+ this.sessionId = sessionId;
+ }
+
+ public String getSessionId() {
+ return sessionId;
+ }
+}
\ No newline at end of file
diff --git a/spring-session/src/test/java/org/springframework/session/data/redis/SessionMessageListenerTests.java b/spring-session/src/test/java/org/springframework/session/data/redis/SessionMessageListenerTests.java
new file mode 100644
index 0000000..a02f7ff
--- /dev/null
+++ b/spring-session/src/test/java/org/springframework/session/data/redis/SessionMessageListenerTests.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2002-2013 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.session.data.redis;
+
+import static org.fest.assertions.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+
+import java.io.UnsupportedEncodingException;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.runners.MockitoJUnitRunner;
+import org.springframework.context.ApplicationEvent;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.data.redis.connection.Message;
+import org.springframework.session.events.SessionDestroyedEvent;
+
+
+/**
+ *
+ * @author Rob Winch
+ *
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class SessionMessageListenerTests {
+ @Mock
+ ApplicationEventPublisher eventPublisher;
+
+ @Mock
+ Message message;
+
+ @Captor
+ ArgumentCaptor event;
+
+ byte[] pattern;
+
+ SessionMessageListener listener;
+
+ @Before
+ public void setup() {
+ listener = new SessionMessageListener(eventPublisher);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void constructorNullEventPublisher() {
+ new SessionMessageListener(null);
+ }
+
+ @Test
+ public void onMessageNullBody() throws Exception {
+ listener.onMessage(message, pattern);
+
+ verifyZeroInteractions(eventPublisher);
+ }
+
+ @Test
+ public void onMessageDel() throws Exception {
+ mockMessage("del","spring:sessions:session:123");
+
+ listener.onMessage(message, pattern);
+
+ verify(eventPublisher).publishEvent(event.capture());
+ assertThat(event.getValue().getSessionId()).isEqualTo("123");
+ }
+
+ @Test
+ public void onMessageSource() throws Exception {
+ mockMessage("del","spring:sessions:session:123");
+
+ listener.onMessage(message, pattern);
+
+ verify(eventPublisher).publishEvent(event.capture());
+ assertThat(event.getValue().getSource()).isEqualTo(listener);
+ }
+
+ @Test
+ public void onMessageExpired() throws Exception {
+ mockMessage("expired","spring:sessions:session:543");
+
+ listener.onMessage(message, pattern);
+
+ verify(eventPublisher).publishEvent(event.capture());
+ assertThat(event.getValue().getSessionId()).isEqualTo("543");
+ }
+
+ @Test
+ public void onMessageHset() throws Exception {
+ mockMessage("hset","spring:sessions:session:123");
+
+ listener.onMessage(message, pattern);
+
+ verifyZeroInteractions(eventPublisher);
+ }
+
+ @Test
+ public void onMessageRename() throws Exception {
+ mockMessage("rename","spring:sessions:session:123");
+
+ listener.onMessage(message, pattern);
+
+ verifyZeroInteractions(eventPublisher);
+ }
+
+ @Test
+ public void onMessageEventPublisherErrorCaught() throws Exception {
+ mockMessage("del","spring:sessions:session:123");
+ doThrow(new IllegalStateException("Test Exceptions are caught")).when(eventPublisher).publishEvent(any(ApplicationEvent.class));
+
+ listener.onMessage(message, pattern);
+
+ verify(eventPublisher).publishEvent(any(ApplicationEvent.class));
+ }
+
+ private void mockMessage(String body, String channel) throws UnsupportedEncodingException {
+ when(message.getBody()).thenReturn(bytes(body));
+ when(message.getChannel()).thenReturn(bytes(channel));
+ }
+
+ private static byte[] bytes(String s) throws UnsupportedEncodingException {
+ return s.getBytes("UTF-8");
+ }
+}