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();