From 6430e43f0e386dbd11755935e1aedece83155b1c Mon Sep 17 00:00:00 2001 From: Rob Winch Date: Tue, 11 Nov 2014 20:47:34 -0600 Subject: [PATCH] Add Support for background task cleanup of RedisSession Redis key expiration has no guarantees of when the expired key is actually removed. In some instances, it is necessary to clean up resources as soon as the sessione expires. For example, when an HTTP Session expires we must ensure that the associated Web Socket sessions are closed. Sessions are now mapped to their expiration times and a background task is used to clean up the sessions as they expire. Fixes gh-59 --- ...edisOperationsSessionRepositoryITests.java | 29 +--- .../RedisOperationsSessionRepository.java | 161 +++++++++++++----- .../redis/RedisSessionExpirationPolicy.java | 144 ++++++++++++++++ .../http/RedisHttpSessionConfiguration.java | 16 +- ...RedisOperationsSessionRepositoryTests.java | 91 +++++++++- 5 files changed, 372 insertions(+), 69 deletions(-) create mode 100644 spring-session/src/main/java/org/springframework/session/data/redis/RedisSessionExpirationPolicy.java 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 eb6de49..4192f70 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 @@ -1,6 +1,9 @@ package org.springframework.session.data.redis; -import static org.fest.assertions.Assertions.*; +import static org.fest.assertions.Assertions.assertThat; + +import java.io.IOException; +import java.net.ServerSocket; import org.junit.After; import org.junit.Before; @@ -9,24 +12,19 @@ import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.session.ExpiringSession; import org.springframework.session.Session; import org.springframework.session.SessionRepository; +import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; -import redis.embedded.RedisServer; -import java.io.IOException; -import java.net.ServerSocket; +import redis.embedded.RedisServer; @RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration @@ -89,6 +87,7 @@ public class RedisOperationsSessionRepositoryITests { } @Configuration + @EnableRedisHttpSession static class Config { @Bean public JedisConnectionFactory connectionFactory() throws Exception { @@ -97,20 +96,6 @@ public class RedisOperationsSessionRepositoryITests { factory.setUsePool(false); return factory; } - - @Bean - public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { - RedisTemplate template = new RedisTemplate(); - template.setKeySerializer(new StringRedisSerializer()); - template.setHashKeySerializer(new StringRedisSerializer()); - template.setConnectionFactory(connectionFactory); - return template; - } - - @Bean - public RedisOperationsSessionRepository sessionRepository(RedisTemplate redisTemplate) { - return new RedisOperationsSessionRepository(redisTemplate); - } } private static Integer availablePort; 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 bed77ba..33b20c0 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 @@ -15,8 +15,17 @@ */ package org.springframework.session.data.redis; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.BoundHashOperations; import org.springframework.data.redis.core.RedisOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.session.ExpiringSession; import org.springframework.session.MapSession; import org.springframework.session.Session; @@ -24,16 +33,13 @@ import org.springframework.session.SessionRepository; import org.springframework.session.web.http.SessionRepositoryFilter; import org.springframework.util.Assert; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.TimeUnit; - /** *

- * A {@link org.springframework.session.SessionRepository} that is implemented using Spring Data's - * {@link org.springframework.data.redis.core.RedisOperations}. In a web environment, this is typically used in - * combination with {@link SessionRepositoryFilter}. + * A {@link org.springframework.session.SessionRepository} that is implemented + * using Spring Data's + * {@link org.springframework.data.redis.core.RedisOperations}. In a web + * environment, this is typically used in combination with + * {@link SessionRepositoryFilter}. *

* *

Creating a new instance

@@ -41,54 +47,68 @@ import java.util.concurrent.TimeUnit; * A typical example of how to create a new instance can be seen below: * *
- *  JedisConnectionFactory factory = new JedisConnectionFactory();
+ * JedisConnectionFactory factory = new JedisConnectionFactory();
  *
- *  RedisTemplate template = new RedisTemplate();
- *  template.setKeySerializer(new StringRedisSerializer());
- *  template.setHashKeySerializer(new StringRedisSerializer());
- *  template.setConnectionFactory(factory);
- *
- *  RedisOperationsSessionRepository redisSessionRepository = new RedisOperationsSessionRepository(template);
+ * 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

* *

- * 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. + * 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-security-sessions: creationTime 1404360000000 maxInactiveInterval 1800 lastAccessedTime 1404360000000 sessionAttr: someAttrValue sessionAttr2: someAttrValue2
+ *     HMSET spring:session:sessions: creationTime 1404360000000 maxInactiveInterval 1800 lastAccessedTime 1404360000000 sessionAttr: someAttrValue sessionAttr2: someAttrValue2
  * 
* *

- * An expiration is associated to each session using the EXPIRE command based upon the - * {@link org.springframework.session.data.redis.RedisOperationsSessionRepository.RedisSession#getMaxInactiveInterval()}. - * For example: + * An expiration is associated to each session using the EXPIRE command based upon the + * {@link org.springframework.session.data.redis.RedisOperationsSessionRepository.RedisSession#getMaxInactiveInterval()} + * . For example: *

* *
- *    EXPIRE spring-security-sessions: 1800
+ *    EXPIRE spring:session:sessions: 1800
  * 
* *

- * The {@link RedisSession} 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: + * The {@link RedisSession} 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-security-sessions: sessionAttr2: newValue
- *     EXPIRE spring-security-sessions: 1800
+ *     HMSET spring:session:sessions: sessionAttr2: newValue
+ *     EXPIRE spring:session:sessions: 1800
  * 
* + * Each session expiration is also tracked to the nearest minute. This allows a + * background task to cleanup expired sessions in a deterministic fashion. For + * example: + * + *
+ *     SADD spring:session:expirations: 
+ *     EXPIRE spring:session:expirations: 1800
+ * 
+ * + * The Redis expiration is still placed on each key to ensure that if the server + * is down when the session expires, it is still cleaned up. + * * @since 1.0 * * @author Rob Winch @@ -121,21 +141,36 @@ public class RedisOperationsSessionRepository implements SessionRepository redisOperations; + private final RedisOperations sessionRedisOperations; + + private final RedisSessionExpirationPolicy expirationPolicy; /** * If non-null, this value is used to override {@link RedisSession#setDefaultMaxInactiveInterval(int)}. */ private Integer defaultMaxInactiveInterval; + /** + * Allows creating an instance and uses a default {@link RedisOperations} for both managing the session and the expirations. + * + * @param redisConnectionFactory the {@link RedisConnectionFactory} to use. + */ + @SuppressWarnings("unchecked") + public RedisOperationsSessionRepository(RedisConnectionFactory redisConnectionFactory) { + this(createDefaultTemplate(redisConnectionFactory), createDefaultTemplate(redisConnectionFactory)); + } + /** * Creates a new instance. For an example, refer to the class level javadoc. * - * @param redisOperations The {@link RedisOperations} to use. Cannot be null. + * @param sessionRedisOperations The {@link RedisOperations} to use for managing the sessions. Cannot be null. + * @param expirationRedisOperations The {@link RedisOperations} to use for managing expiration mappings. Cannot be null. */ - public RedisOperationsSessionRepository(RedisOperations redisOperations) { - Assert.notNull(redisOperations, "RedisOperations cannot be null"); - this.redisOperations = redisOperations; + public RedisOperationsSessionRepository(RedisOperations sessionRedisOperations, RedisOperations expirationRedisOperations) { + Assert.notNull(sessionRedisOperations, "sessionRedisOperations cannot be null"); + Assert.notNull(expirationRedisOperations, "expirationRedisOperations cannot be null"); + this.sessionRedisOperations = sessionRedisOperations; + this.expirationPolicy = new RedisSessionExpirationPolicy(sessionRedisOperations, expirationRedisOperations); } /** @@ -154,8 +189,26 @@ public class RedisOperationsSessionRepository implements SessionRepository entries = getSessionBoundHashOperations(id).entries(); if(entries.isEmpty()) { return null; @@ -174,15 +227,27 @@ public class RedisOperationsSessionRepository implements SessionRepository getSessionBoundHashOperations(String sessionId) { String key = getKey(sessionId); - return this.redisOperations.boundHashOps(key); + return this.sessionRedisOperations.boundHashOps(key); + } + + @SuppressWarnings("rawtypes") + private static RedisTemplate createDefaultTemplate(RedisConnectionFactory connectionFactory) { + Assert.notNull(connectionFactory,"connectionFactory cannot be null"); + RedisTemplate template = new RedisTemplate(); + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setConnectionFactory(connectionFactory); + return template; } /** @@ -235,6 +310,7 @@ public class RedisOperationsSessionRepository implements SessionRepository delta = new HashMap(); /** @@ -262,6 +338,11 @@ public class RedisOperationsSessionRepository implements SessionRepository(delta.size()); + + expirationPolicy.onExpirationUpdated(originalLastAccessTime, this); } } } \ No newline at end of file diff --git a/spring-session/src/main/java/org/springframework/session/data/redis/RedisSessionExpirationPolicy.java b/spring-session/src/main/java/org/springframework/session/data/redis/RedisSessionExpirationPolicy.java new file mode 100644 index 0000000..6a03fe8 --- /dev/null +++ b/spring-session/src/main/java/org/springframework/session/data/redis/RedisSessionExpirationPolicy.java @@ -0,0 +1,144 @@ +/* + * 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 java.util.Calendar; +import java.util.Date; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.session.ExpiringSession; +import org.springframework.session.data.redis.RedisOperationsSessionRepository.RedisSession; + +/** + * A strategy for expiring {@link RedisSession} instances. This performs two + * operations: + * + * Redis has no guarantees of when an expired session event will be fired. In + * order to ensure expired session events are processed in a timely fashion the + * expiration (rounded to the nearest minute) is mapped to all the sessions that + * expire at that time. Whenever {@link #cleanExpiredSessions()} is invoked, the + * sessions for the previous minute are then deleted explicitly. + * + * In some instances the {@link #cleanExpiredSessions()} method may not be not + * invoked for a specific time. For example, this may happen when a server is + * restarted. To account for this, the expiration on the Redis session is also set. + * + * @author Rob Winch + * @since 1.0 + */ +final class RedisSessionExpirationPolicy { + + private static final Log logger = LogFactory.getLog(RedisOperationsSessionRepository.class); + + /** + * The prefix for each key of the Redis Hash representing a single session. The suffix is the unique session id. + */ + static final String EXPIRATION_BOUNDED_HASH_KEY_PREFIX = "spring:session:expirations:"; + + private final RedisOperations sessionRedisOperations; + + private final RedisOperations expirationRedisOperations; + + public RedisSessionExpirationPolicy( + RedisOperations sessionRedisOperations, + RedisOperations expirationRedisOperations) { + super(); + this.sessionRedisOperations = sessionRedisOperations; + this.expirationRedisOperations = expirationRedisOperations; + } + + public void onDelete(ExpiringSession session) { + long lastAccessedTime = session.getLastAccessedTime(); + int maxInactiveInterval = session.getMaxInactiveInterval(); + + long toExpire = roundUpToNextMinute(lastAccessedTime, maxInactiveInterval); + String expireKey = getExpirationKey(toExpire); + expirationRedisOperations.boundSetOps(expireKey).remove(session.getId()); + } + + public void onExpirationUpdated(Long originalExpirationTime, ExpiringSession session) { + if(originalExpirationTime != null) { + String expireKey = getExpirationKey(originalExpirationTime); + expirationRedisOperations.boundSetOps(expireKey).remove(session.getId()); + } + + long toExpire = roundUpToNextMinute(session.getLastAccessedTime(), session.getMaxInactiveInterval()); + + String expireKey = getExpirationKey(toExpire); + expirationRedisOperations.boundSetOps(expireKey).add(session.getId()); + + long redisExpirationInSeconds = session.getMaxInactiveInterval(); + String sessionKey = getSessionKey(session.getId()); + expirationRedisOperations.boundSetOps(expireKey).expire(redisExpirationInSeconds, TimeUnit.SECONDS); + sessionRedisOperations.boundHashOps(sessionKey).expire(redisExpirationInSeconds, TimeUnit.SECONDS); + } + + private String getExpirationKey(long expires) { + return EXPIRATION_BOUNDED_HASH_KEY_PREFIX + expires; + } + + private String getSessionKey(String sessionId) { + return RedisOperationsSessionRepository.BOUNDED_HASH_KEY_PREFIX + sessionId; + } + + public void cleanExpiredSessions() { + long now = System.currentTimeMillis(); + long prevMin = roundDownMinute(now); + + if(logger.isDebugEnabled()) { + logger.debug("Cleaning up sessions expiring at "+ new Date(prevMin)); + } + + String expirationKey = getExpirationKey(prevMin); + Set sessionsToExpire = expirationRedisOperations.boundSetOps(expirationKey).members(); + Set keysToDelete = new HashSet(sessionsToExpire.size() + 1); + keysToDelete.add(expirationKey); + for(String session : sessionsToExpire) { + String sessionKey = getSessionKey(session); + keysToDelete.add(sessionKey); + } + + sessionRedisOperations.delete(keysToDelete); + + if(logger.isDebugEnabled()) { + logger.debug("The following expired Sessions were deleted " + keysToDelete); + } + } + + private long roundUpToNextMinute(long timeInMs, int inactiveIntervalInSec) { + + Calendar date = Calendar.getInstance(); + date.setTimeInMillis(timeInMs + TimeUnit.SECONDS.toMillis(inactiveIntervalInSec)); + date.add(Calendar.MINUTE, 1); + date.clear(Calendar.SECOND); + date.clear(Calendar.MILLISECOND); + return date.getTimeInMillis(); + } + + private long roundDownMinute(long timeInMs) { + Calendar date = Calendar.getInstance(); + date.setTimeInMillis(timeInMs); + date.add(Calendar.MINUTE, -1); + date.clear(Calendar.SECOND); + date.clear(Calendar.MILLISECOND); + return date.getTimeInMillis(); + } +} 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 0002e6e..cb2de28 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 @@ -19,6 +19,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.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.ImportAware; @@ -55,7 +56,7 @@ public class RedisHttpSessionConfiguration implements ImportAware, BeanClassLoad private HttpSessionStrategy httpSessionStrategy; @Bean - public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + public RedisTemplate sessionRedisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate template = new RedisTemplate(); template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); @@ -64,8 +65,17 @@ public class RedisHttpSessionConfiguration implements ImportAware, BeanClassLoad } @Bean - public RedisOperationsSessionRepository sessionRepository(RedisTemplate redisTemplate) { - RedisOperationsSessionRepository sessionRepository = new RedisOperationsSessionRepository(redisTemplate); + public RedisTemplate expirationRedisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate(); + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setConnectionFactory(connectionFactory); + return template; + } + + @Bean + public RedisOperationsSessionRepository sessionRepository(RedisTemplate sessionRedisTemplate, @Qualifier("expirationRedisTemplate") RedisTemplate expirationRedisTemplate) { + RedisOperationsSessionRepository sessionRepository = new RedisOperationsSessionRepository(sessionRedisTemplate,expirationRedisTemplate); sessionRepository.setDefaultMaxInactiveInterval(maxInactiveIntervalInSeconds); return sessionRepository; } 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 9b6c52c..0815954 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 @@ -1,12 +1,20 @@ package org.springframework.session.data.redis; import static org.fest.assertions.Assertions.assertThat; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.springframework.session.data.redis.RedisOperationsSessionRepository.*; +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; +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; +import static org.springframework.session.data.redis.RedisOperationsSessionRepository.getKey; +import static org.springframework.session.data.redis.RedisOperationsSessionRepository.getSessionAttrNameKey; +import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; import org.junit.Before; import org.junit.Test; @@ -16,6 +24,7 @@ import org.mockito.Captor; 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.RedisOperations; import org.springframework.session.ExpiringSession; import org.springframework.session.MapSession; @@ -27,15 +36,26 @@ public class RedisOperationsSessionRepositoryTests { @Mock RedisOperations redisOperations; @Mock + RedisOperations expirationRedisOperations; + @Mock BoundHashOperations boundHashOperations; + @Mock + BoundSetOperations boundSetOperations; @Captor ArgumentCaptor> delta; + @Captor + ArgumentCaptor> keys; private RedisOperationsSessionRepository redisRepository; @Before public void setup() { - this.redisRepository = new RedisOperationsSessionRepository(redisOperations); + this.redisRepository = new RedisOperationsSessionRepository(redisOperations,expirationRedisOperations); + } + + @Test(expected=IllegalArgumentException.class) + public void constructorNullConnectionFactory() { + new RedisOperationsSessionRepository(null); } @Test @@ -56,6 +76,7 @@ public class RedisOperationsSessionRepositoryTests { public void saveNewSession() { RedisSession session = redisRepository.createSession(); when(redisOperations.boundHashOps(getKey(session.getId()))).thenReturn(boundHashOperations); + when(expirationRedisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); redisRepository.save(session); @@ -72,6 +93,7 @@ public class RedisOperationsSessionRepositoryTests { RedisSession session = redisRepository.new RedisSession(new MapSession()); session.setLastAccessedTime(12345678L); when(redisOperations.boundHashOps(getKey(session.getId()))).thenReturn(boundHashOperations); + when(expirationRedisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); redisRepository.save(session); @@ -84,6 +106,7 @@ public class RedisOperationsSessionRepositoryTests { RedisSession session = redisRepository.new RedisSession(new MapSession()); session.setAttribute(attrName, "attrValue"); when(redisOperations.boundHashOps(getKey(session.getId()))).thenReturn(boundHashOperations); + when(expirationRedisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); redisRepository.save(session); @@ -96,6 +119,7 @@ public class RedisOperationsSessionRepositoryTests { RedisSession session = redisRepository.new RedisSession(new MapSession()); session.removeAttribute(attrName); when(redisOperations.boundHashOps(getKey(session.getId()))).thenReturn(boundHashOperations); + when(expirationRedisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); redisRepository.save(session); @@ -115,11 +139,35 @@ public class RedisOperationsSessionRepositoryTests { @Test public void delete() { - String id = "abc"; + String attrName = "attrName"; + MapSession expected = new MapSession(); + expected.setLastAccessedTime(System.currentTimeMillis() - 60000); + expected.setAttribute(attrName, "attrValue"); + when(redisOperations.boundHashOps(getKey(expected.getId()))).thenReturn(boundHashOperations); + Map map = map( + getSessionAttrNameKey(attrName), expected.getAttribute(attrName), + CREATION_TIME_ATTR, expected.getCreationTime(), + MAX_INACTIVE_ATTR, expected.getMaxInactiveInterval(), + LAST_ACCESSED_ATTR, expected.getLastAccessedTime()); + when(boundHashOperations.entries()).thenReturn(map); + when(expirationRedisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); + + String id = expected.getId(); redisRepository.delete(id); verify(redisOperations).delete(getKey(id)); } + @Test + public void deleteNullSession() { + when(expirationRedisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); + when(redisOperations.boundHashOps(anyString())).thenReturn(boundHashOperations); + + String id = "abc"; + redisRepository.delete(id); + verify(redisOperations,times(0)).delete(anyString()); + verify(expirationRedisOperations,times(0)).delete(anyString()); + } + @Test @SuppressWarnings("unchecked") public void getSessionNotFound() { @@ -156,6 +204,39 @@ public class RedisOperationsSessionRepositoryTests { } + @Test + @SuppressWarnings({ "rawtypes", "unchecked" }) + public void getSessionExpired() { + String expiredId = "expired-id"; + when(redisOperations.boundHashOps(getKey(expiredId))).thenReturn(boundHashOperations); + Map map = map( + MAX_INACTIVE_ATTR, 1, + LAST_ACCESSED_ATTR, System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5)); + when(boundHashOperations.entries()).thenReturn(map); + + assertThat(redisRepository.getSession(expiredId)).isNull(); + } + + @Test + public void cleanupExpiredSessions() { + String expiredId = "expired-id"; + when(redisOperations.boundHashOps(getKey(expiredId))).thenReturn(boundHashOperations); + when(expirationRedisOperations.boundSetOps(anyString())).thenReturn(boundSetOperations); + Set expiredIds = new HashSet(Arrays.asList("expired-key1","expired-key2")); + when(boundSetOperations.members()).thenReturn(expiredIds); + + redisRepository.cleanupExpiredSessions(); + + verify(redisOperations).delete(keys.capture()); + + for(String id : expiredIds) { + String expiredKey = RedisOperationsSessionRepository.BOUNDED_HASH_KEY_PREFIX + id; + assertThat(keys.getValue()).contains(expiredKey); + } + // the key that maps the expiration time to the expired ids + assertThat(keys.getValue().size()).isEqualTo(expiredIds.size() + 1); + } + @SuppressWarnings("rawtypes") private Map map(Object...objects) { Map result = new HashMap();