diff --git a/docs/src/docs/asciidoc/index.adoc b/docs/src/docs/asciidoc/index.adoc index 757d071..941e1f3 100644 --- a/docs/src/docs/asciidoc/index.adoc +++ b/docs/src/docs/asciidoc/index.adoc @@ -301,27 +301,94 @@ For additional information on how to create a `RedisConnectionFactory`, refer to [[api-redisoperationssessionrepository-storage]] ==== Storage Details +The sections below outline how Redis is updated for each operation. +An example of creating a new session can be found below. +The subsequent sections describe the details. + +---- +HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe creationTime 1404360000000 \ + maxInactiveInterval 1800 \ + lastAccessedTime 1404360000000 \ + sessionAttr:attrName someAttrValue \ + sessionAttr2:attrName someAttrValue2 +EXPIRE spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe 2100 +APPEND spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe "" +EXPIRE spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe 1800 +SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe +EXPIRE spring:session:expirations1439245080000 2100 +---- + +===== Saving a Session + Each session is stored in Redis as a Hash. Each session is set and updated using the HMSET command. An example of how each session is stored can be seen below. - HMSET spring:session:sessions: creationTime 1404360000000 \ - maxInactiveInterval 1800 lastAccessedTime 1404360000000 \ - sessionAttr: someAttrValue sessionAttr: someAttrValue2 + +---- +HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe creationTime 1404360000000 \ + maxInactiveInterval 1800 \ + lastAccessedTime 1404360000000 \ + sessionAttr:attrName someAttrValue \ + sessionAttr2:attrName someAttrValue2 +---- + +In this example, the session following statements are true about the session: + +* The session id is 33fdd1b6-b496-4b33-9f7d-df96679d32fe +* The session was created at 1404360000000 in milliseconds since midnight of 1/1/1970 GMT. +* The session expires in 1800 seconds (30 minutes). +* The session was last accessed at 1404360000000 in milliseconds since midnight of 1/1/1970 GMT. +* The session has two attributes. +The first is "attrName" with the value of "someAttrValue". +The second session attribute is named "attrName2" with the value of "someAttrValue2". + +[[api-redisoperationssessionrepository-writes]] +===== Optimized Writes + +The `Session` instances managed by `RedisOperationsSessionRepository` keeps track of the properties that have changed and only updates those. +This means if an attribute is written once and read many times we only need to write that attribute once. +For example, assume the session attribute "sessionAttr2" from earlier was updated. +The following would be executed upon saving: + +---- +HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe sessionAttr:attrName2 newValue +---- [[api-redisoperationssessionrepository-expiration]] ===== Session Expiration -An expiration is associated to each session using the EXPIRE command based upon the RedisOperationsSessionRepository.RedisSession.getMaxInactiveInterval(). +An expiration is associated to each session using the EXPIRE command based upon the `ExpiringSession.getMaxInactiveInterval()`. For example: - EXPIRE spring:session:sessions: 1800 +---- +EXPIRE spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe 2100 +---- + +You will note that the expiration that is set is 5 minutes after the session actually expires. +This is necessary so that the value of the session can be accessed when the session expires. +An expiration is set on the session itself five minutes after it actually expires to ensure it is cleaned up, but only after we perform any necessary processing. + +[NOTE] +==== +The `SessionRepository.getSession(String)` method ensures that no expired sessions will be returned. +This means there is no need to check the expiration before using a session. +==== Spring Session relies on the expired and delete http://redis.io/topics/notifications[keyspace notifications] from Redis to fire a <> and <> respectively. It is the `SessionDeletedEvent` or `SessionExpiredEvent` that ensures resources associated with the Session are cleaned up. For example, when using Spring Session's WebSocket support the Redis expired or delete event is what triggers any WebSocket connections associated with the session to be closed. -One problem with this approach is that Redis makes no guarantee of when the expired event will be fired if they key has not been accessed. +Expiration is not tracked directly on the session key itself since this would mean the session data would no longer be available. Instead a special session expires key is used. In our example the expires key is: + +---- +APPEND spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe "" +EXPIRE spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe 1800 +---- + +When a session expires key is deleted or expires, the keyspace notification triggers a lookup of the actual session and a SessionDestroyedEvent is fired. + +One problem with relying on Redis expiration exclusively is that Redis makes no guarantee of when the expired event will be fired if they key has not been accessed. Specifically the background task that Redis uses to clean up expired keys is a low priority task and may not trigger the key expiration. For additional details see http://redis.io/topics/notifications[Timing of expired events] section in the Redis documentation. @@ -332,26 +399,21 @@ For this reason, each session expiration is also tracked to the nearest minute. This allows a background task to access the potentially expired sessions to ensure that Redis expired events are fired in a more deterministic fashion. For example: -SADD spring:session:expirations: -EXPIRE spring:session:expirations: 1860 +---- +SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe +EXPIRE spring:session:expirations1439245080000 2100 +---- The background task will then use these mappings to explicitly request each key. By accessing they key, rather than deleting it, we ensure that Redis deletes the key for us only if the TTL is expired. -NOTE: We do not explicitly delete the keys since in some instances there may be a race condition that incorrectly identifies a key as expired when it is not. +[NOTE] +==== +We do not explicitly delete the keys since in some instances there may be a race condition that incorrectly identifies a key as expired when it is not. Short of using distributed locks (which would kill our performance) there is no way to ensure the consistency of the expiration mapping. By simply accessing the key, we ensure that the key is only removed if the TTL on that key is expired. +==== -[[api-redisoperationssessionrepository-writes]] -===== Optimized Writes - -The `Session` instances managed by `RedisOperationsSessionRepository` keeps track of the properties that have changed and only updates those. -This means if an attribute is written once and read many times we only need to write that attribute once. -For example, assume the session attribute "sessionAttr2" from earlier was updated. -The following would be executed upon saving: - -HMSET spring:session:sessions: sessionAttr: newValue -EXPIRE spring:session:sessions: 1800 [[api-redisoperationssessionrepository-sessiondestroyedevent]] ==== SessionDeletedEvent and SessionExpiredEvent diff --git a/spring-session/src/integration-test/java/org/springframework/session/data/redis/RedisOperationsSessionRepositoryITests.java b/spring-session/src/integration-test/java/org/springframework/session/data/redis/RedisOperationsSessionRepositoryITests.java index 4fb3388..eb19951 100644 --- a/spring-session/src/integration-test/java/org/springframework/session/data/redis/RedisOperationsSessionRepositoryITests.java +++ b/spring-session/src/integration-test/java/org/springframework/session/data/redis/RedisOperationsSessionRepositoryITests.java @@ -33,7 +33,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.session.Session; import org.springframework.session.SessionRepository; import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession; -import org.springframework.session.events.SessionDeletedEvent; +import org.springframework.session.events.SessionDestroyedEvent; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.context.web.WebAppConfiguration; @@ -46,7 +46,7 @@ public class RedisOperationsSessionRepositoryITests { private SessionRepository repository; @Autowired - private SessionDeletedEventRegistry registry; + private SessionDestroyedEventRegistry registry; private final Object lock = new Object(); @@ -58,7 +58,9 @@ public class RedisOperationsSessionRepositoryITests { @Test public void saves() throws InterruptedException { S toSave = repository.createSession(); - toSave.setAttribute("a", "b"); + String expectedAttributeName = "a"; + String expectedAttributeValue = "b"; + toSave.setAttribute(expectedAttributeName, expectedAttributeValue); Authentication toSaveToken = new UsernamePasswordAuthenticationToken("user","password", AuthorityUtils.createAuthorityList("ROLE_USER")); SecurityContext toSaveContext = SecurityContextHolder.createEmptyContext(); toSaveContext.setAuthentication(toSaveToken); @@ -70,7 +72,7 @@ public class RedisOperationsSessionRepositoryITests { assertThat(session.getId()).isEqualTo(toSave.getId()); assertThat(session.getAttributeNames()).isEqualTo(session.getAttributeNames()); - assertThat(session.getAttribute("a")).isEqualTo(toSave.getAttribute("a")); + assertThat(session.getAttribute(expectedAttributeName)).isEqualTo(toSave.getAttribute(expectedAttributeName)); repository.delete(toSave.getId()); @@ -79,6 +81,9 @@ public class RedisOperationsSessionRepositoryITests { lock.wait(3000); } assertThat(registry.receivedEvent()).isTrue(); + + + assertThat(registry.getEvent().getSession().getAttribute(expectedAttributeName)).isEqualTo(expectedAttributeValue); } @Test @@ -100,19 +105,23 @@ public class RedisOperationsSessionRepositoryITests { assertThat(session.getAttribute("1")).isEqualTo("2"); } - static class SessionDeletedEventRegistry implements ApplicationListener { - private boolean receivedEvent; + static class SessionDestroyedEventRegistry implements ApplicationListener { + private SessionDestroyedEvent event; private Object lock; - public void onApplicationEvent(SessionDeletedEvent event) { - receivedEvent = true; + public void onApplicationEvent(SessionDestroyedEvent event) { + this.event = event; synchronized (lock) { lock.notifyAll(); } } public boolean receivedEvent() { - return receivedEvent; + return this.event != null; + } + + public SessionDestroyedEvent getEvent() { + return event; } public void setLock(Object lock) { @@ -131,8 +140,8 @@ public class RedisOperationsSessionRepositoryITests { } @Bean - public SessionDeletedEventRegistry sessionDestroyedEventRegistry() { - return new SessionDeletedEventRegistry(); + public SessionDestroyedEventRegistry sessionDestroyedEventRegistry() { + return new SessionDestroyedEventRegistry(); } } } \ No newline at end of file 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 a16f7e7..258b1c1 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 @@ -20,6 +20,12 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; +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.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.BoundHashOperations; import org.springframework.data.redis.core.RedisOperations; @@ -42,7 +48,7 @@ import org.springframework.util.Assert; * {@link org.springframework.data.redis.core.RedisOperations}. In a web * environment, this is typically used in combination with * {@link SessionRepositoryFilter}. This implementation supports - * {@link SessionDeletedEvent} and {@link SessionExpiredEvent} through {@link SessionMessageListener}. + * {@link SessionDeletedEvent} and {@link SessionExpiredEvent} by implementing {@link MessageListener}. *

* *

Creating a new instance

@@ -52,36 +58,61 @@ import org.springframework.util.Assert; *
  * JedisConnectionFactory factory = new JedisConnectionFactory();
  *
- * RedisOperationsSessionRepository redisSessionRepository = new RedisOperationsSessionRepository(
- * 		factory);
+ * RedisOperationsSessionRepository redisSessionRepository = new RedisOperationsSessionRepository(factory);
  * 
* *

- * For additional information on how to create a RedisTemplate, refer to the Spring Data Redis Reference. + * For additional information on how to create a RedisTemplate, refer to the + * + * Spring Data Redis Reference. *

* *

Storage Details

* + * The sections below outline how Redis is updated for each operation. An + * example of creating a new session can be found below. The subsequent sections + * describe the details. + * + *
+ * HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe creationTime 1404360000000 maxInactiveInterval 1800 lastAccessedTime 1404360000000 sessionAttr:attrName someAttrValue sessionAttr2:attrName someAttrValue2
+ * EXPIRE spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe 2100
+ * APPEND spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe ""
+ * EXPIRE spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe 1800
+ * SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe
+ * EXPIRE spring:session:expirations1439245080000 2100
+ * 
+ * + *

Saving a Session

+ * *

- * Each session is stored in Redis as a Hash. Each session is set - * and updated using the HMSET + * Each session is stored in Redis as a + * Hash. Each session is + * set and updated using the HMSET * command. An example of how each session is stored can be seen below. *

* - *
HMSET spring:session:sessions:<session-id> creationTime 1404360000000 maxInactiveInterval 1800 lastAccessedTime 1404360000000 sessionAttr:<attrName> someAttrValue sessionAttr2:<attrName> someAttrValue2
+ *
+ * HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe creationTime 1404360000000 maxInactiveInterval 1800 lastAccessedTime 1404360000000 sessionAttr:attrName someAttrValue sessionAttr:attrName2 someAttrValue2
+ * 
* *

- * An expiration is associated to each session using the EXPIRE command based upon the - * {@link org.springframework.session.data.redis.RedisOperationsSessionRepository.RedisSession#getMaxInactiveIntervalInSeconds()} - * . For example: + * In this example, the session following statements are true about the session: *

+ *
    + *
  • The session id is 33fdd1b6-b496-4b33-9f7d-df96679d32fe
  • + *
  • The session was created at 1404360000000 in milliseconds since midnight + * of 1/1/1970 GMT.
  • + *
  • The session expires in 1800 seconds (30 minutes).
  • + *
  • The session was last accessed at 1404360000000 in milliseconds since + * midnight of 1/1/1970 GMT.
  • + *
  • The session has two attributes. The first is "attrName" with the value of + * "someAttrValue". The second session attribute is named "attrName2" with the + * value of "someAttrValue2".
  • + *
* - *
EXPIRE spring:session:sessions:<session-id> 1800
+ * + *

Optimized Writes

* *

* The {@link RedisSession} keeps track of the properties that have changed and @@ -92,54 +123,118 @@ import org.springframework.util.Assert; *

* *
- *     HMSET spring:session:sessions:<session-id> sessionAttr2:<attrName> newValue
- *     EXPIRE spring:session:sessions:<session-id> 1800
+ * HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe sessionAttr:attrName2 newValue
  * 
* - *

- * Spring Session relies on the expired and delete keyspace notifications from Redis to fire a <<SessionDestroyedEvent>>. - * It is the `SessionDestroyedEvent` that ensures resources associated with the Session are cleaned up. - * For example, when using Spring Session's WebSocket support the Redis expired or delete event is what triggers any - * WebSocket connections associated with the session to be closed. - *

+ *

Expiration

* *

- * One problem with this approach is that Redis makes no guarantee of when the expired event will be fired if they key has not been accessed. - * Specifically the background task that Redis uses to clean up expired keys is a low priority task and may not trigger the key expiration. - * For additional details see Timing of expired events section in the Redis documentation. - *

- * - *

- * To circumvent the fact that expired events are not guaranteed to happen we can ensure that each key is accessed when it is expected to expire. - * This means that if the TTL is expired on the key, Redis will remove the key and fire the expired event when we try to access they key. - *

- * - *

- * For this reason, each session expiration is also tracked to the nearest minute. - * This allows a background task to access the potentially expired sessions to ensure that Redis expired events are fired in a more deterministic fashion. - * For example: + * An expiration is associated to each session using the + * EXPIRE command based upon the + * {@link org.springframework.session.data.redis.RedisOperationsSessionRepository.RedisSession#getMaxInactiveIntervalInSeconds()} + * . For example: *

* *
- *     SADD spring:session:expirations:<expire-rounded-up-to-nearest-minute> <session-id>
- *     EXPIRE spring:session:expirations:<expire-rounded-up-to-nearest-minute> 1800
+ * EXPIRE spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe 2100
  * 
* *

- * The background task will then use these mappings to explicitly request each key. - * By accessing they key, rather than deleting it, we ensure that Redis deletes the key for us only if the TTL is expired. + * You will note that the expiration that is set is 5 minutes after the session + * actually expires. This is necessary so that the value of the session can be + * accessed when the session expires. An expiration is set on the session itself + * five minutes after it actually expires to ensure it is cleaned up, but only + * after we perform any necessary processing. + *

+ * + *

+ * NOTE: The {@link #getSession(String)} method ensures that no expired + * sessions will be returned. This means there is no need to check the + * expiration before using a session + *

+ * + *

+ * Spring Session relies on the expired and delete + * keyspace notifications + * from Redis to fire a SessionDestroyedEvent. It is the + * SessionDestroyedEvent that ensures resources associated with the Session + * are cleaned up. For example, when using Spring Session's WebSocket support + * the Redis expired or delete event is what triggers any WebSocket connections + * associated with the session to be closed. + *

+ * + *

+ * Expiration is not tracked directly on the session key itself since this would + * mean the session data would no longer be available. Instead a special session + * expires key is used. In our example the expires key is: + *

+ * + *
+ * APPEND spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe ""
+ * EXPIRE spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe 1800
+ * 
+ * + *

+ * When a session expires key is deleted or expires, the keyspace notification + * triggers a lookup of the actual session and a {@link SessionDestroyedEvent} + * is fired. + *

+ * + *

+ * One problem with relying on Redis expiration exclusively is that Redis makes + * no guarantee of when the expired event will be fired if they key has not been + * accessed. Specifically the background task that Redis uses to clean up + * expired keys is a low priority task and may not trigger the key expiration. + * For additional details see + * Timing of expired events + * section in the Redis documentation. + *

+ * + *

+ * To circumvent the fact that expired events are not guaranteed to happen we + * can ensure that each key is accessed when it is expected to expire. This + * means that if the TTL is expired on the key, Redis will remove the key and + * fire the expired event when we try to access they key. + *

+ * + *

+ * For this reason, each session expiration is also tracked to the nearest + * minute. This allows a background task to access the potentially expired + * sessions to ensure that Redis expired events are fired in a more + * deterministic fashion. For example: + *

+ * + *
+ * SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe
+ * EXPIRE spring:session:expirations1439245080000 2100
+ * 
+ * + *

+ * The background task will then use these mappings to explicitly request each + * session expires key. By accessing the key, rather than deleting it, we ensure + * that Redis deletes the key for us only if the TTL is expired. *

*

- * NOTE: We do not explicitly delete the keys since in some instances there may be a race condition that incorrectly identifies a key as expired when it is not. - * Short of using distributed locks (which would kill our performance) there is no way to ensure the consistency of the expiration mapping. - * By simply accessing the key, we ensure that the key is only removed if the TTL on that key is expired. + * NOTE: We do not explicitly delete the keys since in some instances + * there may be a race condition that incorrectly identifies a key as expired + * when it is not. Short of using distributed locks (which would kill our + * performance) there is no way to ensure the consistency of the expiration + * mapping. By simply accessing the key, we ensure that the key is only removed + * if the TTL on that key is expired. *

* * @since 1.0 * * @author Rob Winch */ -public class RedisOperationsSessionRepository implements SessionRepository { +public class RedisOperationsSessionRepository implements SessionRepository, MessageListener { + private static final Log logger = LogFactory.getLog(SessionMessageListener.class); + + private ApplicationEventPublisher eventPublisher = new ApplicationEventPublisher() { + public void publishEvent(ApplicationEvent event) { + } + }; + /** * The prefix for each key of the Redis Hash representing a single session. The suffix is the unique session id. */ @@ -196,6 +291,20 @@ public class RedisOperationsSessionRepository implements SessionRepository expireOperations = redis.boundSetOps(expireKey); - expireOperations.add(session.getId()); + expireOperations.add(keyToExpire); long sessionExpireInSeconds = session.getMaxInactiveIntervalInSeconds(); - String sessionKey = getSessionKey(session.getId()); + long fiveMinutesAfterExpires = sessionExpireInSeconds + TimeUnit.MINUTES.toSeconds(5); + String sessionKey = getSessionKey(keyToExpire); - expireOperations.expire(sessionExpireInSeconds + 60, TimeUnit.SECONDS); - redis.boundHashOps(sessionKey).expire(sessionExpireInSeconds, TimeUnit.SECONDS); + expireOperations.expire(fiveMinutesAfterExpires, TimeUnit.SECONDS); + redis.boundValueOps(sessionKey).append(""); + redis.boundValueOps(sessionKey).expire(sessionExpireInSeconds, TimeUnit.SECONDS); + redis.boundHashOps(getSessionKey(session.getId())).expire(fiveMinutesAfterExpires, TimeUnit.SECONDS); } String getExpirationKey(long expires) { 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 abdc70c..3d848ee 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 @@ -23,6 +23,7 @@ import javax.servlet.ServletContext; import org.springframework.beans.factory.BeanClassLoaderAware; import org.springframework.beans.factory.InitializingBean; 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; @@ -32,6 +33,7 @@ import org.springframework.core.annotation.AnnotationUtils; import org.springframework.core.type.AnnotationMetadata; import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisOperations; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.listener.PatternTopic; import org.springframework.data.redis.listener.RedisMessageListenerContainer; @@ -67,26 +69,18 @@ public class RedisHttpSessionConfiguration implements ImportAware, BeanClassLoad private HttpSessionStrategy httpSessionStrategy; - @Autowired - private ApplicationEventPublisher eventPublisher; - private ConfigureRedisAction configureRedisAction = new ConfigureNotifyKeyspaceEventsAction(); @Bean public RedisMessageListenerContainer redisMessageListenerContainer( - RedisConnectionFactory connectionFactory) { + RedisConnectionFactory connectionFactory, RedisOperationsSessionRepository redisSessionMessageListener) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); - container.addMessageListener(redisSessionMessageListener(), + container.addMessageListener(redisSessionMessageListener, Arrays.asList(new PatternTopic("__keyevent@*:del"),new PatternTopic("__keyevent@*:expired"))); return container; } - @Bean - public SessionMessageListener redisSessionMessageListener() { - return new SessionMessageListener(eventPublisher); - } - @Bean public RedisTemplate sessionRedisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate template = new RedisTemplate(); @@ -97,8 +91,9 @@ public class RedisHttpSessionConfiguration implements ImportAware, BeanClassLoad } @Bean - public RedisOperationsSessionRepository sessionRepository(@Qualifier("sessionRedisTemplate") RedisOperations sessionRedisTemplate) { + public RedisOperationsSessionRepository sessionRepository(@Qualifier("sessionRedisTemplate") RedisOperations sessionRedisTemplate, ApplicationEventPublisher applicationEventPublisher) { RedisOperationsSessionRepository sessionRepository = new RedisOperationsSessionRepository(sessionRedisTemplate); + sessionRepository.setApplicationEventPublisher(applicationEventPublisher); sessionRepository.setDefaultMaxInactiveInterval(maxInactiveIntervalInSeconds); return sessionRepository; } diff --git a/spring-session/src/main/java/org/springframework/session/events/SessionDeletedEvent.java b/spring-session/src/main/java/org/springframework/session/events/SessionDeletedEvent.java index 432a1cb..1c4ab05 100644 --- a/spring-session/src/main/java/org/springframework/session/events/SessionDeletedEvent.java +++ b/spring-session/src/main/java/org/springframework/session/events/SessionDeletedEvent.java @@ -23,6 +23,7 @@ import org.springframework.session.SessionRepository; * fired when a {@link Session} is destroyed via deletion. * * @author Mark Anderson + * @author Rob Winch * @since 1.1 * */ @@ -33,4 +34,7 @@ public class SessionDeletedEvent extends SessionDestroyedEvent { super(source, sessionId); } -} + public SessionDeletedEvent(Object source, Session session) { + super(source, session); + } +} \ No newline at end of file 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 index 9da8a39..d0e7dbc 100644 --- a/spring-session/src/main/java/org/springframework/session/events/SessionDestroyedEvent.java +++ b/spring-session/src/main/java/org/springframework/session/events/SessionDestroyedEvent.java @@ -29,9 +29,30 @@ import org.springframework.session.Session; public class SessionDestroyedEvent extends ApplicationEvent { private final String sessionId; + private final Session session; + public SessionDestroyedEvent(Object source, String sessionId) { super(source); this.sessionId = sessionId; + this.session = null; + } + + public SessionDestroyedEvent(Object source, Session session) { + super(source); + this.session = session; + this.sessionId = session.getId(); + } + + /** + * Gets the {@link Session} that was destroyed. For some + * {@link SessionRepository} implementations it may not be possible to get + * the original session in which case this may be null. + * + * @return the expired {@link Session} or null if the data store does not support obtaining it + */ + @SuppressWarnings("unchecked") + public S getSession() { + return (S) session; } public String getSessionId() { diff --git a/spring-session/src/main/java/org/springframework/session/events/SessionExpiredEvent.java b/spring-session/src/main/java/org/springframework/session/events/SessionExpiredEvent.java index 3dbd773..8323fbe 100644 --- a/spring-session/src/main/java/org/springframework/session/events/SessionExpiredEvent.java +++ b/spring-session/src/main/java/org/springframework/session/events/SessionExpiredEvent.java @@ -23,6 +23,7 @@ import org.springframework.session.SessionRepository; * fired when a {@link Session} is destroyed via expiration. * * @author Mark Anderson + * @author Rob Winch * @since 1.1 * */ @@ -32,4 +33,8 @@ public class SessionExpiredEvent extends SessionDestroyedEvent { public SessionExpiredEvent(Object source, String sessionId) { super(source, sessionId); } + + public SessionExpiredEvent(Object source, Session session) { + super(source, session); + } } \ No newline at end of file diff --git a/spring-session/src/test/java/org/springframework/session/data/redis/RedisOperationsSessionRepositoryTests.java b/spring-session/src/test/java/org/springframework/session/data/redis/RedisOperationsSessionRepositoryTests.java index 80d38de..a816e57 100644 --- a/spring-session/src/test/java/org/springframework/session/data/redis/RedisOperationsSessionRepositoryTests.java +++ b/spring-session/src/test/java/org/springframework/session/data/redis/RedisOperationsSessionRepositoryTests.java @@ -16,8 +16,10 @@ package org.springframework.session.data.redis; import static org.fest.assertions.Assertions.assertThat; -import static org.mockito.Matchers.*; -import static org.mockito.Mockito.*; +import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static org.springframework.session.data.redis.RedisOperationsSessionRepository.CREATION_TIME_ATTR; import static org.springframework.session.data.redis.RedisOperationsSessionRepository.LAST_ACCESSED_ATTR; import static org.springframework.session.data.redis.RedisOperationsSessionRepository.MAX_INACTIVE_ATTR; @@ -42,6 +44,7 @@ import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.BoundHashOperations; import org.springframework.data.redis.core.BoundSetOperations; +import org.springframework.data.redis.core.BoundValueOperations; import org.springframework.data.redis.core.RedisOperations; import org.springframework.session.ExpiringSession; import org.springframework.session.MapSession; @@ -56,19 +59,28 @@ public class RedisOperationsSessionRepositoryTests { @Mock RedisConnection connection; @Mock - RedisOperations redisOperations; + RedisOperations redisOperations; @Mock - BoundHashOperations boundHashOperations; + BoundValueOperations boundValueOperations; @Mock - BoundSetOperations boundSetOperations; + BoundHashOperations boundHashOperations; + @Mock + BoundSetOperations boundSetOperations; @Captor ArgumentCaptor> delta; + private MapSession cached; + private RedisOperationsSessionRepository redisRepository; @Before public void setup() { this.redisRepository = new RedisOperationsSessionRepository(redisOperations); + + cached = new MapSession(); + cached.setId("session-id"); + cached.setCreationTime(1404360000000L); + cached.setLastAccessedTime(1404360000000L); } @Test(expected=IllegalArgumentException.class) @@ -76,6 +88,11 @@ public class RedisOperationsSessionRepositoryTests { new RedisOperationsSessionRepository((RedisConnectionFactory)null); } + @Test(expected=IllegalArgumentException.class) + public void setApplicationEventPublisherNull() { + redisRepository.setApplicationEventPublisher(null); + } + // gh-61 @Test public void constructorConnectionFactory() { @@ -104,25 +121,66 @@ public class RedisOperationsSessionRepositoryTests { @Test public void saveNewSession() { RedisSession session = redisRepository.createSession(); - when(redisOperations.boundHashOps(getKey(session.getId()))).thenReturn(boundHashOperations); + when(redisOperations.boundHashOps(anyString())).thenReturn(boundHashOperations); when(redisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); + when(redisOperations.boundValueOps(anyString())).thenReturn(boundValueOperations); redisRepository.save(session); Map delta = getDelta(); assertThat(delta.size()).isEqualTo(3); Object creationTime = delta.get(CREATION_TIME_ATTR); - assertThat(creationTime).isInstanceOf(Long.class); + assertThat(creationTime).isEqualTo(session.getCreationTime()); assertThat(delta.get(MAX_INACTIVE_ATTR)).isEqualTo(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS); - assertThat(delta.get(LAST_ACCESSED_ATTR)).isEqualTo(creationTime); + assertThat(delta.get(LAST_ACCESSED_ATTR)).isEqualTo(session.getCreationTime()); + } + + @Test + public void saveJavadocSummary() { + RedisSession session = redisRepository.createSession(); + + String sessionKey = "spring:session:sessions:" + session.getId(); + String backgroundExpireKey = "spring:session:expirations:" + RedisSessionExpirationPolicy.roundUpToNextMinute(RedisSessionExpirationPolicy.expiresInMillis(session)); + String destroyedTriggerKey = "spring:session:sessions:expires:" + session.getId(); + + when(redisOperations.boundHashOps(sessionKey)).thenReturn(boundHashOperations); + when(redisOperations.boundSetOps(backgroundExpireKey)).thenReturn(boundSetOperations); + when(redisOperations.boundValueOps(destroyedTriggerKey)).thenReturn(boundValueOperations); + + redisRepository.save(session); + + // the actual data in the session expires 5 minutes after expiration so the data can be accessed in expiration events + // if the session is retrieved and expired it will not be returned since getSession checks if it is expired + long fiveMinutesAfterExpires = session.getMaxInactiveIntervalInSeconds() + TimeUnit.MINUTES.toSeconds(5); + verify(boundHashOperations).expire(fiveMinutesAfterExpires, TimeUnit.SECONDS); + verify(boundSetOperations).expire(fiveMinutesAfterExpires, TimeUnit.SECONDS); + verify(boundSetOperations).add("expires:" + session.getId()); + verify(boundValueOperations).expire(1800L, TimeUnit.SECONDS); + verify(boundValueOperations).append(""); + } + + @Test + public void saveJavadoc() { + RedisSession session = redisRepository.new RedisSession(cached); + + when(redisOperations.boundHashOps("spring:session:sessions:session-id")).thenReturn(boundHashOperations); + when(redisOperations.boundSetOps("spring:session:expirations:1404361860000")).thenReturn(boundSetOperations); + when(redisOperations.boundValueOps("spring:session:sessions:expires:session-id")).thenReturn(boundValueOperations); + + redisRepository.save(session); + + // the actual data in the session expires 5 minutes after expiration so the data can be accessed in expiration events + // if the session is retrieved and expired it will not be returned since getSession checks if it is expired + verify(boundHashOperations).expire(session.getMaxInactiveIntervalInSeconds() + TimeUnit.MINUTES.toSeconds(5), TimeUnit.SECONDS); } @Test public void saveLastAccessChanged() { - RedisSession session = redisRepository.new RedisSession(new MapSession()); + RedisSession session = redisRepository.new RedisSession(new MapSession(cached)); session.setLastAccessedTime(12345678L); - when(redisOperations.boundHashOps(getKey(session.getId()))).thenReturn(boundHashOperations); + when(redisOperations.boundHashOps(anyString())).thenReturn(boundHashOperations); when(redisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); + when(redisOperations.boundValueOps(anyString())).thenReturn(boundValueOperations); redisRepository.save(session); @@ -134,8 +192,9 @@ public class RedisOperationsSessionRepositoryTests { String attrName = "attrName"; RedisSession session = redisRepository.new RedisSession(new MapSession()); session.setAttribute(attrName, "attrValue"); - when(redisOperations.boundHashOps(getKey(session.getId()))).thenReturn(boundHashOperations); + when(redisOperations.boundHashOps(anyString())).thenReturn(boundHashOperations); when(redisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); + when(redisOperations.boundValueOps(anyString())).thenReturn(boundValueOperations); redisRepository.save(session); @@ -147,8 +206,9 @@ public class RedisOperationsSessionRepositoryTests { String attrName = "attrName"; RedisSession session = redisRepository.new RedisSession(new MapSession()); session.removeAttribute(attrName); - when(redisOperations.boundHashOps(getKey(session.getId()))).thenReturn(boundHashOperations); + when(redisOperations.boundHashOps(anyString())).thenReturn(boundHashOperations); when(redisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); + when(redisOperations.boundValueOps(anyString())).thenReturn(boundValueOperations); redisRepository.save(session); @@ -158,7 +218,7 @@ public class RedisOperationsSessionRepositoryTests { @Test public void redisSessionGetAttributes() { String attrName = "attrName"; - RedisSession session = redisRepository.new RedisSession(new MapSession()); + RedisSession session = redisRepository.new RedisSession(); assertThat(session.getAttributeNames()).isEmpty(); session.setAttribute(attrName, "attrValue"); assertThat(session.getAttributeNames()).containsOnly(attrName); @@ -172,7 +232,9 @@ public class RedisOperationsSessionRepositoryTests { MapSession expected = new MapSession(); expected.setLastAccessedTime(System.currentTimeMillis() - 60000); expected.setAttribute(attrName, "attrValue"); - when(redisOperations.boundHashOps(getKey(expected.getId()))).thenReturn(boundHashOperations); + when(redisOperations.boundHashOps(anyString())).thenReturn(boundHashOperations); + when(redisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); + when(redisOperations.boundValueOps(anyString())).thenReturn(boundValueOperations); Map map = map( getSessionAttrNameKey(attrName), expected.getAttribute(attrName), CREATION_TIME_ATTR, expected.getCreationTime(), @@ -183,13 +245,16 @@ public class RedisOperationsSessionRepositoryTests { String id = expected.getId(); redisRepository.delete(id); - verify(redisOperations).delete(getKey(id)); + + assertThat(getDelta().get(MAX_INACTIVE_ATTR)).isEqualTo(0); + verify(redisOperations).delete(getKey("expires:"+id)); } @Test public void deleteNullSession() { - when(redisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); when(redisOperations.boundHashOps(anyString())).thenReturn(boundHashOperations); + when(redisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); + when(redisOperations.boundValueOps(anyString())).thenReturn(boundValueOperations); String id = "abc"; redisRepository.delete(id); @@ -249,12 +314,12 @@ public class RedisOperationsSessionRepositoryTests { when(redisOperations.boundHashOps(getKey(expiredId))).thenReturn(boundHashOperations); when(redisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); - Set expiredIds = new HashSet(Arrays.asList("expired-key1","expired-key2")); + Set expiredIds = new HashSet(Arrays.asList("expired-key1","expired-key2")); when(boundSetOperations.members()).thenReturn(expiredIds); redisRepository.cleanupExpiredSessions(); - for(String id : expiredIds) { + for(Object id : expiredIds) { String expiredKey = RedisOperationsSessionRepository.BOUNDED_HASH_KEY_PREFIX + id; // https://github.com/spring-projects/spring-session/issues/93 verify(redisOperations).hasKey(expiredKey); diff --git a/spring-session/src/test/java/org/springframework/session/data/redis/RedisSessionExpirationPolicyTests.java b/spring-session/src/test/java/org/springframework/session/data/redis/RedisSessionExpirationPolicyTests.java index f7ffd73..952454f 100644 --- a/spring-session/src/test/java/org/springframework/session/data/redis/RedisSessionExpirationPolicyTests.java +++ b/spring-session/src/test/java/org/springframework/session/data/redis/RedisSessionExpirationPolicyTests.java @@ -26,6 +26,7 @@ import org.mockito.Mock; import org.mockito.runners.MockitoJUnitRunner; import org.springframework.data.redis.core.BoundHashOperations; import org.springframework.data.redis.core.BoundSetOperations; +import org.springframework.data.redis.core.BoundValueOperations; import org.springframework.data.redis.core.RedisOperations; import org.springframework.session.MapSession; @@ -33,7 +34,6 @@ import org.springframework.session.MapSession; * @author Rob Winch */ @RunWith(MockitoJUnitRunner.class) -@SuppressWarnings({"rawtypes","unchecked"}) public class RedisSessionExpirationPolicyTests { // Wed Apr 15 10:28:32 CDT 2015 final static Long NOW = 1429111712346L; @@ -42,11 +42,13 @@ public class RedisSessionExpirationPolicyTests { final static Long ONE_MINUTE_AGO = 1429111652346L; @Mock - RedisOperations sessionRedisOperations; + RedisOperations sessionRedisOperations; @Mock - BoundSetOperations setOperations; + BoundSetOperations setOperations; @Mock - BoundHashOperations hashOperations; + BoundHashOperations hashOperations; + @Mock + BoundValueOperations valueOperations; RedisSessionExpirationPolicy policy; @@ -61,6 +63,7 @@ public class RedisSessionExpirationPolicyTests { when(sessionRedisOperations.boundSetOps(anyString())).thenReturn(setOperations); when(sessionRedisOperations.boundHashOps(anyString())).thenReturn(hashOperations); + when(sessionRedisOperations.boundValueOps(anyString())).thenReturn(valueOperations); } // gh-169 @@ -74,7 +77,7 @@ public class RedisSessionExpirationPolicyTests { // verify the original is removed verify(sessionRedisOperations).boundSetOps(originalExpireKey); - verify(setOperations).remove(session.getId()); + verify(setOperations).remove("expires:"+ session.getId()); } @Test @@ -86,8 +89,8 @@ public class RedisSessionExpirationPolicyTests { policy.onExpirationUpdated(null, session); verify(sessionRedisOperations).boundSetOps(expectedExpireKey); - verify(setOperations).add(session.getId()); - verify(setOperations).expire(session.getMaxInactiveIntervalInSeconds() + 60, TimeUnit.SECONDS); + verify(setOperations).add("expires:" + session.getId()); + verify(setOperations).expire(session.getMaxInactiveIntervalInSeconds() + TimeUnit.MINUTES.toSeconds(5), TimeUnit.SECONDS); } @Test @@ -97,6 +100,6 @@ public class RedisSessionExpirationPolicyTests { policy.onExpirationUpdated(null, session); verify(sessionRedisOperations).boundHashOps(sessionKey); - verify(hashOperations).expire(session.getMaxInactiveIntervalInSeconds(), TimeUnit.SECONDS); + verify(hashOperations).expire(session.getMaxInactiveIntervalInSeconds() + TimeUnit.MINUTES.toSeconds(5), TimeUnit.SECONDS); } }