Add Sesison to SessionExpiredEvent

Fixes gh-260
This commit is contained in:
Rob Winch
2015-08-11 10:32:09 -05:00
parent 690fbc8d3f
commit 6e1ecadcc4
10 changed files with 470 additions and 123 deletions

View File

@@ -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<S extends Session> {
private SessionRepository<S> repository;
@Autowired
private SessionDeletedEventRegistry registry;
private SessionDestroyedEventRegistry registry;
private final Object lock = new Object();
@@ -58,7 +58,9 @@ public class RedisOperationsSessionRepositoryITests<S extends Session> {
@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<S extends Session> {
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<S extends Session> {
lock.wait(3000);
}
assertThat(registry.receivedEvent()).isTrue();
assertThat(registry.getEvent().getSession().getAttribute(expectedAttributeName)).isEqualTo(expectedAttributeValue);
}
@Test
@@ -100,19 +105,23 @@ public class RedisOperationsSessionRepositoryITests<S extends Session> {
assertThat(session.getAttribute("1")).isEqualTo("2");
}
static class SessionDeletedEventRegistry implements ApplicationListener<SessionDeletedEvent> {
private boolean receivedEvent;
static class SessionDestroyedEventRegistry implements ApplicationListener<SessionDestroyedEvent> {
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<S extends Session> {
}
@Bean
public SessionDeletedEventRegistry sessionDestroyedEventRegistry() {
return new SessionDeletedEventRegistry();
public SessionDestroyedEventRegistry sessionDestroyedEventRegistry() {
return new SessionDestroyedEventRegistry();
}
}
}

View File

@@ -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}.
* </p>
*
* <h2>Creating a new instance</h2>
@@ -52,36 +58,61 @@ import org.springframework.util.Assert;
* <pre>
* JedisConnectionFactory factory = new JedisConnectionFactory();
*
* RedisOperationsSessionRepository redisSessionRepository = new RedisOperationsSessionRepository(
* factory);
* RedisOperationsSessionRepository redisSessionRepository = new RedisOperationsSessionRepository(factory);
* </pre>
*
* <p>
* For additional information on how to create a RedisTemplate, refer to the <a
* href =
* "http://docs.spring.io/spring-data/data-redis/docs/current/reference/html/"
* >Spring Data Redis Reference</a>.
* For additional information on how to create a RedisTemplate, refer to the
* <a href =
* "http://docs.spring.io/spring-data/data-redis/docs/current/reference/html/" >
* Spring Data Redis Reference</a>.
* </p>
*
* <h2>Storage Details</h2>
*
* 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.
*
* <pre>
* 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
* </pre>
*
* <h3>Saving a Session</h3>
*
* <p>
* Each session is stored in Redis as a <a
* href="http://redis.io/topics/data-types#hashes">Hash</a>. Each session is set
* and updated using the <a href="http://redis.io/commands/hmset">HMSET
* Each session is stored in Redis as a
* <a href="http://redis.io/topics/data-types#hashes">Hash</a>. Each session is
* set and updated using the <a href="http://redis.io/commands/hmset">HMSET
* command</a>. An example of how each session is stored can be seen below.
* </p>
*
* <pre>HMSET spring:session:sessions:&lt;session-id&gt; creationTime 1404360000000 maxInactiveInterval 1800 lastAccessedTime 1404360000000 sessionAttr:&lt;attrName&gt; someAttrValue sessionAttr2:&lt;attrName&gt; someAttrValue2</pre>
* <pre>
* HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe creationTime 1404360000000 maxInactiveInterval 1800 lastAccessedTime 1404360000000 sessionAttr:attrName someAttrValue sessionAttr:attrName2 someAttrValue2
* </pre>
*
* <p>
* An expiration is associated to each session using the <a
* href="http://redis.io/commands/expire">EXPIRE command</a> 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:
* </p>
* <ul>
* <li>The session id is 33fdd1b6-b496-4b33-9f7d-df96679d32fe</li>
* <li>The session was created at 1404360000000 in milliseconds since midnight
* of 1/1/1970 GMT.</li>
* <li>The session expires in 1800 seconds (30 minutes).</li>
* <li>The session was last accessed at 1404360000000 in milliseconds since
* midnight of 1/1/1970 GMT.</li>
* <li>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".</li>
* </ul>
*
* <pre>EXPIRE spring:session:sessions:&lt;session-id&gt; 1800</pre>
*
* <h3>Optimized Writes</h3>
*
* <p>
* The {@link RedisSession} keeps track of the properties that have changed and
@@ -92,54 +123,118 @@ import org.springframework.util.Assert;
* </p>
*
* <pre>
* HMSET spring:session:sessions:&lt;session-id&gt; sessionAttr2:&lt;attrName&gt; newValue
* EXPIRE spring:session:sessions:&lt;session-id&gt; 1800
* HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe sessionAttr:attrName2 newValue
* </pre>
*
* <p>
* Spring Session relies on the expired and delete <a href="http://redis.io/topics/notifications">keyspace notifications</a> from Redis to fire a &lt;&lt;SessionDestroyedEvent&gt;&gt;.
* 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.
* </p>
* <h3>Expiration</h3>
*
* <p>
* 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 <a href="http://redis.io/topics/notifications">Timing of expired events</a> section in the Redis documentation.
* </p>
*
* <p>
* 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.
* </p>
*
* <p>
* 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
* <a href="http://redis.io/commands/expire">EXPIRE command</a> based upon the
* {@link org.springframework.session.data.redis.RedisOperationsSessionRepository.RedisSession#getMaxInactiveIntervalInSeconds()}
* . For example:
* </p>
*
* <pre>
* SADD spring:session:expirations:&lt;expire-rounded-up-to-nearest-minute&gt; &lt;session-id&gt;
* EXPIRE spring:session:expirations:&lt;expire-rounded-up-to-nearest-minute&gt; 1800
* EXPIRE spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe 2100
* </pre>
*
* <p>
* 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.
* </p>
*
* <p>
* <b>NOTE:</b> 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
* </p>
*
* <p>
* Spring Session relies on the expired and delete
* <a href="http://redis.io/topics/notifications">keyspace notifications</a>
* 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.
* </p>
*
* <p>
* 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:
* </p>
*
* <pre>
* APPEND spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe ""
* EXPIRE spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe 1800
* </pre>
*
* <p>
* 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.
* </p>
*
* <p>
* 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
* <a href="http://redis.io/topics/notifications">Timing of expired events</a>
* section in the Redis documentation.
* </p>
*
* <p>
* 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.
* </p>
*
* <p>
* 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:
* </p>
*
* <pre>
* SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe
* EXPIRE spring:session:expirations1439245080000 2100
* </pre>
*
* <p>
* 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.
* </p>
* <p>
* <b>NOTE</b>: 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.
* <b>NOTE</b>: 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.
* </p>
*
* @since 1.0
*
* @author Rob Winch
*/
public class RedisOperationsSessionRepository implements SessionRepository<RedisOperationsSessionRepository.RedisSession> {
public class RedisOperationsSessionRepository implements SessionRepository<RedisOperationsSessionRepository.RedisSession>, 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<Redis
this.expirationPolicy = new RedisSessionExpirationPolicy(sessionRedisOperations);
}
/**
* Sets the {@link ApplicationEventPublisher} that is used to publish
* {@link SessionDestroyedEvent}. The default is to not publish a
* {@link SessionDestroyedEvent}.
*
* @param applicationEventPublisher
* the {@link ApplicationEventPublisher} that is used to publish
* {@link SessionDestroyedEvent}. Cannot be null.
*/
public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
Assert.notNull(applicationEventPublisher, "applicationEventPublisher cannot be null");
this.eventPublisher = applicationEventPublisher;
}
/**
* Sets the maximum inactive interval in seconds between requests before newly created sessions will be
* invalidated. A negative time indicates that the session will never timeout. The default is 1800 (30 minutes).
@@ -258,16 +367,22 @@ public class RedisOperationsSessionRepository implements SessionRepository<Redis
}
public void delete(String sessionId) {
ExpiringSession session = getSession(sessionId, true);
RedisSession session = getSession(sessionId, true);
if(session == null) {
return;
}
String key = getKey(sessionId);
expirationPolicy.onDelete(session);
// always delete they key since session may be null if just expired
this.sessionRedisOperations.delete(key);
String expireKey = getExpiredKey(session.getId());
this.sessionRedisOperations.delete(expireKey);
session.setMaxInactiveIntervalInSeconds(0);
save(session);
}
private String getExpiredKey(String sessionId) {
return getKey("expires:" + sessionId);
}
public RedisSession createSession() {
@@ -278,6 +393,68 @@ public class RedisOperationsSessionRepository implements SessionRepository<Redis
return redisSession;
}
public void onMessage(Message message, byte[] pattern) {
byte[] messageChannel = message.getChannel();
byte[] messageBody = message.getBody();
if(messageChannel == null || messageBody == null) {
return;
}
String channel = new String(messageChannel);
String body = new String(messageBody);
if(!body.startsWith("spring:session:sessions:expires")) {
return;
}
boolean isDeleted = channel.endsWith(":del");
if(isDeleted || channel.endsWith(":expired")) {
int beginIndex = body.lastIndexOf(":") + 1;
int endIndex = body.length();
String sessionId = body.substring(beginIndex, endIndex);
RedisSession session = getSession(sessionId, true);
if(logger.isDebugEnabled()) {
logger.debug("Publishing SessionDestroyedEvent for session " + sessionId);
}
if(isDeleted) {
handleDeleted(sessionId, session);
} else {
handleExpired(sessionId, session);
}
return;
}
}
private void handleDeleted(String sessionId, RedisSession session) {
if(session == null) {
publishEvent(new SessionDeletedEvent(this, sessionId));
} else {
publishEvent(new SessionDeletedEvent(this, session));
}
}
private void handleExpired(String sessionId, RedisSession session) {
if(session == null) {
publishEvent(new SessionExpiredEvent(this, sessionId));
} else {
publishEvent(new SessionExpiredEvent(this, session));
}
}
private void publishEvent(ApplicationEvent event) {
try {
this.eventPublisher.publishEvent(event);
}
catch (Throwable ex) {
logger.error("Error publishing " + event + ".", ex);
}
}
/**
* Gets the Hash key for this session by prefixing it appropriately.
*
@@ -342,6 +519,8 @@ public class RedisOperationsSessionRepository implements SessionRepository<Redis
delta.put(LAST_ACCESSED_ATTR, getLastAccessedTime());
}
/**
* Creates a new instance from the provided {@link MapSession}
*

View File

@@ -68,23 +68,27 @@ final class RedisSessionExpirationPolicy {
}
public void onExpirationUpdated(Long originalExpirationTimeInMilli, ExpiringSession session) {
String keyToExpire = "expires:" + session.getId();
if(originalExpirationTimeInMilli != null) {
long originalRoundedUp = roundUpToNextMinute(originalExpirationTimeInMilli);
String expireKey = getExpirationKey(originalRoundedUp);
redis.boundSetOps(expireKey).remove(expireKey);
redis.boundSetOps(expireKey).remove(keyToExpire);
}
long toExpire = roundUpToNextMinute(expiresInMillis(session));
String expireKey = getExpirationKey(toExpire);
BoundSetOperations<Object, Object> 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) {

View File

@@ -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<Object,Object> sessionRedisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<Object, Object> template = new RedisTemplate<Object, Object>();
@@ -97,8 +91,9 @@ public class RedisHttpSessionConfiguration implements ImportAware, BeanClassLoad
}
@Bean
public RedisOperationsSessionRepository sessionRepository(@Qualifier("sessionRedisTemplate") RedisOperations<Object, Object> sessionRedisTemplate) {
public RedisOperationsSessionRepository sessionRepository(@Qualifier("sessionRedisTemplate") RedisOperations<Object, Object> sessionRedisTemplate, ApplicationEventPublisher applicationEventPublisher) {
RedisOperationsSessionRepository sessionRepository = new RedisOperationsSessionRepository(sessionRedisTemplate);
sessionRepository.setApplicationEventPublisher(applicationEventPublisher);
sessionRepository.setDefaultMaxInactiveInterval(maxInactiveIntervalInSeconds);
return sessionRepository;
}

View File

@@ -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);
}
}

View File

@@ -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 extends Session> S getSession() {
return (S) session;
}
public String getSessionId() {

View File

@@ -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);
}
}

View File

@@ -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<Object,Object> redisOperations;
@Mock
BoundHashOperations<String, Object, Object> boundHashOperations;
BoundValueOperations<Object, Object> boundValueOperations;
@Mock
BoundSetOperations<String, String> boundSetOperations;
BoundHashOperations<Object, Object, Object> boundHashOperations;
@Mock
BoundSetOperations<Object, Object> boundSetOperations;
@Captor
ArgumentCaptor<Map<String,Object>> 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<String,Object> 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<String> expiredIds = new HashSet<String>(Arrays.asList("expired-key1","expired-key2"));
Set<Object> expiredIds = new HashSet<Object>(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);

View File

@@ -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<Object,Object> sessionRedisOperations;
@Mock
BoundSetOperations setOperations;
BoundSetOperations<Object,Object> setOperations;
@Mock
BoundHashOperations hashOperations;
BoundHashOperations<Object,Object, Object> hashOperations;
@Mock
BoundValueOperations<Object, Object> 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);
}
}