Introduce RedisSessionExpirationStore
With this commit it is now possible to customize the expiration policy in RedisIndexedHttpSession Closes gh-2906
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
====
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user