Introduce RedisSessionExpirationStore

With this commit it is now possible to customize the expiration policy in RedisIndexedHttpSession

Closes gh-2906
This commit is contained in:
Marcus Hert Da Coregio
2024-08-05 14:32:19 -03:00
parent af37d934ca
commit 84f4afcaf1
10 changed files with 665 additions and 15 deletions

View File

@@ -19,6 +19,7 @@ package org.springframework.session.data.redis;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -135,6 +136,22 @@ class RedisIndexedSessionRepositoryITests extends AbstractRedisITests {
.isEqualTo(expectedAttributeValue);
}
@Test
void saveThenSaveSessionKeyAndShadowKeyWith5MinutesDifference() {
RedisSession toSave = this.repository.createSession();
String expectedAttributeName = "a";
String expectedAttributeValue = "b";
toSave.setAttribute(expectedAttributeName, expectedAttributeValue);
this.repository.save(toSave);
Long sessionKeyExpire = this.redis.getExpire("RedisIndexedSessionRepositoryITests:sessions:" + toSave.getId(),
TimeUnit.SECONDS);
Long shadowKeyExpire = this.redis
.getExpire("RedisIndexedSessionRepositoryITests:sessions:expires:" + toSave.getId(), TimeUnit.SECONDS);
long differenceInSeconds = sessionKeyExpire - shadowKeyExpire;
assertThat(differenceInSeconds).isEqualTo(300);
}
@Test
void putAllOnSingleAttrDoesNotRemoveOld() {
RedisSession toSave = this.repository.createSession();

View File

@@ -0,0 +1,121 @@
/*
* Copyright 2014-2024 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
*
* https://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.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.session.data.redis.RedisIndexedSessionRepository.RedisSession;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.context.web.WebAppConfiguration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link SortedSetRedisSessionExpirationStore}
*
* @author Marcus da Coregio
*/
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = SortedSetRedisSessionExpirationStoreITests.Config.class)
@WebAppConfiguration
class SortedSetRedisSessionExpirationStoreITests {
@Autowired
private SortedSetRedisSessionExpirationStore expirationStore;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final Instant mockedTime = LocalDateTime.of(2024, 5, 8, 10, 30, 0)
.atZone(ZoneOffset.UTC)
.toInstant();
private static final Clock clock;
static {
clock = Clock.fixed(mockedTime, ZoneOffset.UTC);
}
@Test
void saveThenStoreSessionWithItsExpiration() {
Instant expireAt = mockedTime.plusSeconds(5);
RedisSession session = createSession("123", expireAt);
this.expirationStore.save(session);
Double score = this.redisTemplate.opsForZSet().score("spring:session:sessions:expirations", "123");
assertThat(score).isEqualTo(expireAt.toEpochMilli());
}
@Test
void removeWhenSessionIdExistsThenRemoved() {
RedisSession session = createSession("toBeRemoved", mockedTime);
this.expirationStore.save(session);
Double score = this.redisTemplate.opsForZSet().score("spring:session:sessions:expirations", "toBeRemoved");
assertThat(score).isEqualTo(mockedTime.toEpochMilli());
this.expirationStore.remove("toBeRemoved");
score = this.redisTemplate.opsForZSet().score("spring:session:sessions:expirations", "toBeRemoved");
assertThat(score).isNull();
}
private RedisSession createSession(String sessionId, Instant expireAt) {
RedisSession session = mock();
given(session.getId()).willReturn(sessionId);
given(session.getLastAccessedTime()).willReturn(expireAt);
given(session.getMaxInactiveInterval()).willReturn(Duration.ZERO);
return session;
}
@Configuration(proxyBeanMethods = false)
@Import(AbstractRedisITests.BaseConfig.class)
static class Config {
@Bean
RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setHashKeySerializer(RedisSerializer.string());
redisTemplate.setConnectionFactory(redisConnectionFactory);
return redisTemplate;
}
@Bean
RedisSessionExpirationStore redisSessionExpirationStore(RedisTemplate<String, Object> redisTemplate) {
SortedSetRedisSessionExpirationStore store = new SortedSetRedisSessionExpirationStore(redisTemplate,
RedisIndexedSessionRepository.DEFAULT_NAMESPACE);
store.setClock(SortedSetRedisSessionExpirationStoreITests.clock);
return store;
}
}
}

View File

@@ -18,12 +18,15 @@ package org.springframework.session.data.redis;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.function.BiFunction;
import java.util.function.Function;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
@@ -37,6 +40,8 @@ import org.springframework.dao.NonTransientDataAccessException;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
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.data.redis.serializer.JdkSerializationRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
@@ -61,6 +66,7 @@ import org.springframework.session.events.SessionDestroyedEvent;
import org.springframework.session.events.SessionExpiredEvent;
import org.springframework.session.web.http.SessionRepositoryFilter;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
/**
@@ -306,7 +312,7 @@ public class RedisIndexedSessionRepository
private final RedisOperations<String, Object> sessionRedisOperations;
private final RedisSessionExpirationPolicy expirationPolicy;
private RedisSessionExpirationStore expirationStore;
private ApplicationEventPublisher eventPublisher = (event) -> {
};
@@ -337,8 +343,8 @@ public class RedisIndexedSessionRepository
public RedisIndexedSessionRepository(RedisOperations<String, Object> sessionRedisOperations) {
Assert.notNull(sessionRedisOperations, "sessionRedisOperations cannot be null");
this.sessionRedisOperations = sessionRedisOperations;
this.expirationPolicy = new RedisSessionExpirationPolicy(sessionRedisOperations, this::getExpirationsKey,
this::getSessionKey);
this.expirationStore = new MinuteBasedRedisSessionExpirationStore(sessionRedisOperations,
this::getExpirationsKey);
configureSessionChannels();
}
@@ -486,7 +492,7 @@ public class RedisIndexedSessionRepository
}
public void cleanUpExpiredSessions() {
this.expirationPolicy.cleanExpiredSessions();
this.expirationStore.cleanupExpiredSessions();
}
@Override
@@ -543,7 +549,7 @@ public class RedisIndexedSessionRepository
}
cleanupPrincipalIndex(session);
this.expirationPolicy.onDelete(session);
this.expirationStore.remove(sessionId);
String expireKey = getExpiredKey(session.getId());
this.sessionRedisOperations.delete(expireKey);
@@ -604,6 +610,7 @@ public class RedisIndexedSessionRepository
}
cleanupPrincipalIndex(session);
this.expirationStore.remove(session.getId());
if (isDeleted) {
handleDeleted(session);
@@ -650,6 +657,18 @@ public class RedisIndexedSessionRepository
configureSessionChannels();
}
/**
* Set the {@link RedisSessionExpirationStore} to use, defaults to
* {@link MinuteBasedRedisSessionExpirationStore}.
* @param expirationStore the {@link RedisSessionExpirationStore} to use, cannot be
* null
* @since 3.4
*/
public void setExpirationStore(RedisSessionExpirationStore expirationStore) {
Assert.notNull(expirationStore, "expirationStore cannot be null");
this.expirationStore = expirationStore;
}
/**
* Gets the Hash key for this session by prefixing it appropriately.
* @param sessionId the session id
@@ -754,7 +773,7 @@ public class RedisIndexedSessionRepository
*
* @author Rob Winch
*/
final class RedisSession implements Session {
public final class RedisSession implements Session {
private final MapSession cached;
@@ -905,10 +924,41 @@ public class RedisIndexedSessionRepository
RedisIndexedSessionRepository.this.sessionRedisOperations.convertAndSend(sessionCreatedKey, this.delta);
this.isNew = false;
}
long sessionExpireInSeconds = getMaxInactiveInterval().getSeconds();
createShadowKey(sessionExpireInSeconds);
long fiveMinutesAfterExpires = sessionExpireInSeconds + TimeUnit.MINUTES.toSeconds(5);
RedisIndexedSessionRepository.this.sessionRedisOperations.boundHashOps(getSessionKey(getId()))
.expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
RedisIndexedSessionRepository.this.expirationStore.save(this);
this.delta = new HashMap<>(this.delta.size());
Long originalExpiration = (this.originalLastAccessTime != null)
? this.originalLastAccessTime.plus(getMaxInactiveInterval()).toEpochMilli() : null;
RedisIndexedSessionRepository.this.expirationPolicy.onExpirationUpdated(originalExpiration, this);
}
private void createShadowKey(long sessionExpireInSeconds) {
String keyToExpire = "expires:" + getId();
String sessionKey = getSessionKey(keyToExpire);
if (sessionExpireInSeconds < 0) {
BoundValueOperations<String, Object> valueOps = RedisIndexedSessionRepository.this.sessionRedisOperations
.boundValueOps(sessionKey);
valueOps.append("");
valueOps.persist();
RedisIndexedSessionRepository.this.sessionRedisOperations.boundHashOps(getSessionKey(getId()))
.persist();
}
if (sessionExpireInSeconds == 0) {
RedisIndexedSessionRepository.this.sessionRedisOperations.delete(sessionKey);
}
else {
BoundValueOperations<String, Object> valueOps = RedisIndexedSessionRepository.this.sessionRedisOperations
.boundValueOps(sessionKey);
valueOps.append("");
valueOps.expire(sessionExpireInSeconds, TimeUnit.SECONDS);
}
}
private void saveChangeSessionId() {
@@ -941,6 +991,7 @@ public class RedisIndexedSessionRepository
RedisIndexedSessionRepository.this.sessionRedisOperations.boundSetOps(originalPrincipalRedisKey)
.add(sessionId);
}
RedisIndexedSessionRepository.this.expirationStore.remove(this.originalSessionId);
}
this.originalSessionId = sessionId;
}
@@ -954,4 +1005,100 @@ public class RedisIndexedSessionRepository
}
private final class MinuteBasedRedisSessionExpirationStore implements RedisSessionExpirationStore {
private static final String SESSION_EXPIRES_PREFIX = "expires:";
private final RedisOperations<String, Object> redis;
private final Function<Long, String> lookupExpirationKey;
MinuteBasedRedisSessionExpirationStore(RedisOperations<String, Object> redis,
Function<Long, String> lookupExpirationKey) {
this.redis = redis;
this.lookupExpirationKey = lookupExpirationKey;
}
@Override
public void save(RedisSession session) {
Long originalExpiration = (session.originalLastAccessTime != null)
? session.originalLastAccessTime.plus(session.getMaxInactiveInterval()).toEpochMilli() : null;
String keyToExpire = SESSION_EXPIRES_PREFIX + session.getId();
long toExpire = roundUpToNextMinute(expiresInMillis(session));
if (originalExpiration != null) {
long originalRoundedUp = roundUpToNextMinute(originalExpiration);
if (toExpire != originalRoundedUp) {
String expireKey = getExpirationKey(originalRoundedUp);
this.redis.boundSetOps(expireKey).remove(keyToExpire);
}
}
String expirationsKey = getExpirationsKey(toExpire);
long sessionExpireInSeconds = session.getMaxInactiveInterval().getSeconds();
long fiveMinutesAfterExpires = sessionExpireInSeconds + TimeUnit.MINUTES.toSeconds(5);
this.redis.boundSetOps(expirationsKey).expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
String expireKey = getExpirationKey(toExpire);
BoundSetOperations<String, Object> expireOperations = this.redis.boundSetOps(expireKey);
expireOperations.add(keyToExpire);
}
@Override
public void remove(String sessionId) {
RedisSession session = getSession(sessionId, true);
if (session != null) {
long toExpire = roundUpToNextMinute(expiresInMillis(session));
String expireKey = getExpirationKey(toExpire);
String entryToRemove = SESSION_EXPIRES_PREFIX + session.getId();
this.redis.boundSetOps(expireKey).remove(entryToRemove);
}
}
@Override
public void cleanupExpiredSessions() {
long now = System.currentTimeMillis();
long prevMin = roundDownMinute(now);
String expirationKey = getExpirationKey(prevMin);
Set<Object> sessionsToExpire = this.redis.boundSetOps(expirationKey).members();
this.redis.delete(expirationKey);
if (CollectionUtils.isEmpty(sessionsToExpire)) {
return;
}
for (Object sessionId : sessionsToExpire) {
touch(getSessionKey((String) sessionId));
}
}
/**
* By trying to access the session we only trigger a deletion if the TTL is
* expired. This is done to handle
* <a href="https://github.com/spring-projects/spring-session/issues/93">gh-93</a>
* @param sessionKey the key
*/
private void touch(String sessionKey) {
RedisIndexedSessionRepository.this.sessionRedisOperations.hasKey(sessionKey);
}
String getExpirationKey(long expires) {
return this.lookupExpirationKey.apply(expires);
}
private static long expiresInMillis(Session session) {
return session.getLastAccessedTime().plus(session.getMaxInactiveInterval()).toEpochMilli();
}
private static long roundUpToNextMinute(long timeInMs) {
Instant instant = Instant.ofEpochMilli(timeInMs).plus(1, ChronoUnit.MINUTES);
Instant nextMinute = instant.truncatedTo(ChronoUnit.MINUTES);
return nextMinute.toEpochMilli();
}
private static long roundDownMinute(long timeInMs) {
Instant downMinute = Instant.ofEpochMilli(timeInMs).truncatedTo(ChronoUnit.MINUTES);
return downMinute.toEpochMilli();
}
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2014-2024 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
*
* https://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;
/**
* An interface for storing {@link RedisIndexedSessionRepository.RedisSession} instances
* with their expected expiration time. This approach is necessary because Redis does not
* guarantee when the expired event will be fired if the key has not been accessed. For
* more details, see the Redis documentation on
* <a href="https://redis.io/commands/expire/#:~:text=How%20Redis%20expires%20keys"> how
* keys expire</a>. To address the uncertainty of expired events, sessions can be stored
* with their expected expiration time, ensuring each key is accessed when it is expected
* to expire. This interface defines common operations for tracking sessions and their
* expiration times, and allows for a strategy to clean up expired sessions.
*
* @author Marcus da Coregio
* @since 3.4
*/
public interface RedisSessionExpirationStore {
/**
* Saves the session and its expected expiration time, so it can be found later on by
* its expiration time in order for clean up to happen.
* @param session the session to save
*/
void save(RedisIndexedSessionRepository.RedisSession session);
/**
* Removes the session id from the expiration store.
* @param sessionId the session id
*/
void remove(String sessionId);
/**
* Performs clean up on the expired sessions.
*/
void cleanupExpiredSessions();
}

View File

@@ -0,0 +1,141 @@
/*
* Copyright 2014-2024 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
*
* https://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.time.Clock;
import java.time.Instant;
import java.util.Set;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.session.Session;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
/**
* Uses a sorted set to store the expiration times for sessions. The score of each entry
* is the expiration time of the session (calculated via
* {@link Session#getLastAccessedTime()} + {@link Session#getMaxInactiveInterval()}). The
* value is the session id. Note that {@link #cleanupExpiredSessions()} only retrieves up
* to 100 sessions at a time by default, use {@link #setCleanupCount(int)} to increase it
* if needed.
*
* @author Marcus da Coregio
* @since 3.4
*/
public class SortedSetRedisSessionExpirationStore implements RedisSessionExpirationStore {
private final RedisOperations<String, Object> redisOps;
private String namespace;
private int cleanupCount = 100;
private Clock clock = Clock.systemUTC();
private String expirationsKey;
public SortedSetRedisSessionExpirationStore(RedisOperations<String, Object> redisOps, String namespace) {
Assert.notNull(redisOps, "redisOps cannot be null");
this.redisOps = redisOps;
setNamespace(namespace);
}
/**
* Save the session id associated with the expiration time into the sorted set.
* @param session the session to save
*/
@Override
public void save(RedisIndexedSessionRepository.RedisSession session) {
long expirationInMillis = getExpirationTime(session).toEpochMilli();
this.redisOps.opsForZSet().add(this.expirationsKey, session.getId(), expirationInMillis);
}
/**
* Remove the session id from the sorted set.
* @param sessionId the session id
*/
@Override
public void remove(String sessionId) {
this.redisOps.opsForZSet().remove(this.expirationsKey, sessionId);
}
/**
* Retrieves the sessions that are expected to be expired and invoke
* {@link #touch(String)} on each of the session keys, resolved via
* {@link #getSessionKey(String)}.
*/
@Override
public void cleanupExpiredSessions() {
Set<Object> sessionIds = this.redisOps.opsForZSet()
.reverseRangeByScore(this.expirationsKey, 0, this.clock.millis(), 0, this.cleanupCount);
if (CollectionUtils.isEmpty(sessionIds)) {
return;
}
for (Object sessionId : sessionIds) {
String sessionKey = getSessionKey((String) sessionId);
touch(sessionKey);
}
}
private Instant getExpirationTime(RedisIndexedSessionRepository.RedisSession session) {
return session.getLastAccessedTime().plus(session.getMaxInactiveInterval());
}
/**
* Checks if the session exists. By trying to access the session we only trigger a
* deletion if the TTL is expired. This is done to handle
* <a href="https://github.com/spring-projects/spring-session/issues/93">gh-93</a>
* @param sessionKey the key
*/
private void touch(String sessionKey) {
this.redisOps.hasKey(sessionKey);
}
private String getSessionKey(String sessionId) {
return this.namespace + ":sessions:" + sessionId;
}
/**
* Set the namespace for the keys.
* @param namespace the namespace
*/
public void setNamespace(String namespace) {
Assert.hasText(namespace, "namespace cannot be null or empty");
this.namespace = namespace;
this.expirationsKey = this.namespace + ":sessions:expirations";
}
/**
* Configure the clock used when retrieving expired sessions for clean-up.
* @param clock the clock
*/
public void setClock(Clock clock) {
Assert.notNull(clock, "clock cannot be null");
this.clock = clock;
}
/**
* Configures how many sessions will be queried at a time to be cleaned up. Defaults
* to 100.
* @param cleanupCount how many sessions to be queried, must be bigger than 0.
*/
public void setCleanupCount(int cleanupCount) {
Assert.state(cleanupCount > 0, "cleanupCount must be greater than 0");
this.cleanupCount = cleanupCount;
}
}

View File

@@ -47,6 +47,7 @@ import org.springframework.session.Session;
import org.springframework.session.SessionIdGenerator;
import org.springframework.session.UuidSessionIdGenerator;
import org.springframework.session.data.redis.RedisIndexedSessionRepository;
import org.springframework.session.data.redis.RedisSessionExpirationStore;
import org.springframework.session.data.redis.config.ConfigureNotifyKeyspaceEventsAction;
import org.springframework.session.data.redis.config.ConfigureRedisAction;
import org.springframework.session.web.http.SessionRepositoryFilter;
@@ -84,6 +85,8 @@ public class RedisIndexedHttpSessionConfiguration
private SessionIdGenerator sessionIdGenerator = UuidSessionIdGenerator.getInstance();
private RedisSessionExpirationStore expirationStore;
@Bean
@Override
public RedisIndexedSessionRepository sessionRepository() {
@@ -106,6 +109,9 @@ public class RedisIndexedHttpSessionConfiguration
int database = resolveDatabase();
sessionRepository.setDatabase(database);
sessionRepository.setSessionIdGenerator(this.sessionIdGenerator);
if (this.expirationStore != null) {
sessionRepository.setExpirationStore(this.expirationStore);
}
getSessionRepositoryCustomizers()
.forEach((sessionRepositoryCustomizer) -> sessionRepositoryCustomizer.customize(sessionRepository));
return sessionRepository;
@@ -171,6 +177,11 @@ public class RedisIndexedHttpSessionConfiguration
this.redisSubscriptionExecutor = redisSubscriptionExecutor;
}
@Autowired(required = false)
public void setExpirationStore(RedisSessionExpirationStore expirationStore) {
this.expirationStore = expirationStore;
}
@Override
public void setEmbeddedValueResolver(StringValueResolver resolver) {
this.embeddedValueResolver = resolver;

View File

@@ -504,11 +504,16 @@ class RedisIndexedSessionRepositoryTests {
String deletedId = "deleted-id";
given(this.redisOperations.<String, Object>boundHashOps(getKey(deletedId)))
.willReturn(this.boundHashOperations);
long lastAccessedTimeMillis = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5);
Map<String, Object> map = map(RedisSessionMapper.CREATION_TIME_KEY, Instant.EPOCH.toEpochMilli(),
RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, 0, RedisSessionMapper.LAST_ACCESSED_TIME_KEY,
System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5));
lastAccessedTimeMillis);
given(this.boundHashOperations.entries()).willReturn(map);
String backgroundExpireKey = "spring:session:expirations:"
+ RedisSessionExpirationPolicy.roundUpToNextMinute(lastAccessedTimeMillis);
given(this.redisOperations.boundSetOps(backgroundExpireKey)).willReturn(this.boundSetOperations);
String channel = "__keyevent@0__:del";
String body = "spring:session:sessions:expires:" + deletedId;
DefaultMessage message = new DefaultMessage(channel.getBytes(StandardCharsets.UTF_8),
@@ -517,8 +522,8 @@ class RedisIndexedSessionRepositoryTests {
this.redisRepository.setApplicationEventPublisher(this.publisher);
this.redisRepository.onMessage(message, "".getBytes(StandardCharsets.UTF_8));
verify(this.redisOperations).boundHashOps(eq(getKey(deletedId)));
verify(this.boundHashOperations).entries();
verify(this.redisOperations, times(2)).boundHashOps(eq(getKey(deletedId)));
verify(this.boundHashOperations, times(2)).entries();
verify(this.publisher).publishEvent(this.event.capture());
assertThat(this.event.getValue().getSessionId()).isEqualTo(deletedId);
verifyNoMoreInteractions(this.defaultSerializer);
@@ -555,11 +560,16 @@ class RedisIndexedSessionRepositoryTests {
String expiredId = "expired-id";
given(this.redisOperations.<String, Object>boundHashOps(getKey(expiredId)))
.willReturn(this.boundHashOperations);
long lastAccessedTimeMillis = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5);
Map<String, Object> map = map(RedisSessionMapper.CREATION_TIME_KEY, Instant.EPOCH.toEpochMilli(),
RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, 1, RedisSessionMapper.LAST_ACCESSED_TIME_KEY,
System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5));
lastAccessedTimeMillis);
given(this.boundHashOperations.entries()).willReturn(map);
String backgroundExpireKey = "spring:session:expirations:"
+ RedisSessionExpirationPolicy.roundUpToNextMinute(lastAccessedTimeMillis + 1000);
given(this.redisOperations.boundSetOps(backgroundExpireKey)).willReturn(this.boundSetOperations);
String channel = "__keyevent@0__:expired";
String body = "spring:session:sessions:expires:" + expiredId;
DefaultMessage message = new DefaultMessage(channel.getBytes(StandardCharsets.UTF_8),
@@ -568,8 +578,8 @@ class RedisIndexedSessionRepositoryTests {
this.redisRepository.setApplicationEventPublisher(this.publisher);
this.redisRepository.onMessage(message, "".getBytes(StandardCharsets.UTF_8));
verify(this.redisOperations).boundHashOps(eq(getKey(expiredId)));
verify(this.boundHashOperations).entries();
verify(this.redisOperations, times(2)).boundHashOps(eq(getKey(expiredId)));
verify(this.boundHashOperations, times(2)).entries();
verify(this.publisher).publishEvent(this.event.capture());
assertThat(this.event.getValue().getSessionId()).isEqualTo(expiredId);
verifyNoMoreInteractions(this.defaultSerializer);

View File

@@ -0,0 +1,86 @@
/*
* Copyright 2014-2024 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
*
* https://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.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Answers;
import org.springframework.data.redis.core.RedisTemplate;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* Tests for {@link SortedSetRedisSessionExpirationStore}
*
* @author Marcus da Coregio
*/
class SortedSetRedisSessionExpirationStoreTests {
private SortedSetRedisSessionExpirationStore expirationStore;
private final RedisTemplate<String, Object> redisTemplate = mock(Answers.RETURNS_DEEP_STUBS);
@BeforeEach
void setup() {
this.expirationStore = new SortedSetRedisSessionExpirationStore(this.redisTemplate,
RedisIndexedSessionRepository.DEFAULT_NAMESPACE);
}
@Test
void setNamespaceWhenNullOrEmptyThenException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.expirationStore.setNamespace(null))
.withMessage("namespace cannot be null or empty");
assertThatIllegalArgumentException().isThrownBy(() -> this.expirationStore.setNamespace(""))
.withMessage("namespace cannot be null or empty");
}
@Test
void setClockWhenNullThenException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.expirationStore.setClock(null))
.withMessage("clock cannot be null");
}
@Test
void setCleanupCountWhenZeroOrNegativeThenException() {
assertThatIllegalStateException().isThrownBy(() -> this.expirationStore.setCleanupCount(0))
.withMessage("cleanupCount must be greater than 0");
assertThatIllegalStateException().isThrownBy(() -> this.expirationStore.setCleanupCount(-1))
.withMessage("cleanupCount must be greater than 0");
}
@Test
void cleanupExpiredSessionsThenTouchExpiredSessions() {
given(this.redisTemplate.opsForZSet()
.reverseRangeByScore(anyString(), anyDouble(), anyDouble(), anyLong(), anyLong()))
.willReturn(Set.of("1", "2", "3"));
this.expirationStore.cleanupExpiredSessions();
verify(this.redisTemplate).hasKey("spring:session:sessions:1");
verify(this.redisTemplate).hasKey("spring:session:sessions:2");
verify(this.redisTemplate).hasKey("spring:session:sessions:3");
}
}

View File

@@ -10,6 +10,7 @@ Now that you have your application configured, you might want to start customizi
- I want to <<listening-session-events,know when a session is created, deleted, destroyed or expires>>.
- I want to <<finding-all-user-sessions, find all sessions of a specific user>>
- I want to <<configuring-redis-session-mapper,safe deserialize Redis sessions>>
- Customizing the <<customizing-session-expiration-store,session expiration store>>
[[serializing-session-using-json]]
== Serializing the Session using JSON
@@ -407,3 +408,65 @@ public class SessionConfig {
}
----
======
[[customizing-session-expiration-store]]
== Customizing the Session Expiration Store
Due to the nature of Redis, there is no guarantee on when an expired event will be fired if the key has not been accessed.
For more details, refer to the Redis documentation https://redis.io/docs/latest/commands/expire/#:~:text=How%20Redis%20expires%20keys[on key expiration].
To mitigate the uncertainty of expired events, sessions are also stored with their expected expiration times.
This ensures that each key can be accessed when it is expected to expire.
The `RedisSessionExpirationStore` interface defines the common operations for tracking sessions and their expiration times, and it provides a strategy for cleaning up expired sessions.
By default, each session expiration is 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:
[source]
----
SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe
EXPIRE spring:session:expirations:1439245080000 2100
----
The background task will then use these mappings to explicitly request each session expires key.
By accessing the key, rather than deleting it, we ensure that Redis deletes the key for us only if the TTL is expired.
By customizing the session expiration store, you can manage session expiration more effectively based on your needs.
To do that, you should provide a bean of type `RedisSessionExpirationStore` that will be picked up by Spring Session Data Redis configuration:
[tabs]
======
SessionConfig::
+
[source,java,role="primary"]
----
import org.springframework.session.data.redis.SortedSetRedisSessionExpirationStore;
@Configuration
@EnableRedisIndexedHttpSession
public class SessionConfig {
@Bean
public RedisSessionExpirationStore redisSessionExpirationStore(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setHashKeySerializer(RedisSerializer.string());
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.afterPropertiesSet();
return new SortedSetRedisSessionExpirationStore(redisTemplate, RedisIndexedSessionRepository.DEFAULT_NAMESPACE);
}
}
----
======
In the code above, the `SortedSetRedisSessionExpirationStore` implementation is being used, which uses a https://redis.io/docs/latest/develop/data-types/sorted-sets/[Sorted Set] to store the session ids with their expiration time as the score.
[NOTE]
====
We do not explicitly delete the keys since in some instances there may be a race condition that incorrectly identifies a key as expired when it is not.
Short of using distributed locks (which would kill 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.
However, for your implementations you can choose the strategy that best fits.
====

View File

@@ -1,3 +1,4 @@
= What's New in 3.4
- https://github.com/spring-projects/spring-session/issues/2787[gh-2787] - Add Partitioned Cookie Support to `DefaultCookieSerializer`
- https://github.com/spring-projects/spring-session/issues/2906[gh-2906] - xref:configuration/redis.adoc#customizing-session-expiration-store[docs] - Allow Customization of Expiration Policy in `RedisIndexedHttpSession`