From ffc9e72375e88825cc25c7eeaf445decfcf58264 Mon Sep 17 00:00:00 2001 From: Marcus Hert Da Coregio Date: Mon, 5 Aug 2024 14:32:19 -0300 Subject: [PATCH] Introduce RedisSessionExpirationStore With this commit it is now possible to customize the expiration policy in RedisIndexedHttpSession Closes gh-2906 --- .../RedisIndexedSessionRepositoryITests.java | 17 ++ ...dSetRedisSessionExpirationStoreITests.java | 121 +++++++++++++ .../redis/RedisIndexedSessionRepository.java | 165 +++++++++++++++++- .../redis/RedisSessionExpirationStore.java | 53 ++++++ .../SortedSetRedisSessionExpirationStore.java | 141 +++++++++++++++ .../RedisIndexedHttpSessionConfiguration.java | 11 ++ .../RedisIndexedSessionRepositoryTests.java | 22 ++- ...edSetRedisSessionExpirationStoreTests.java | 86 +++++++++ .../ROOT/pages/configuration/redis.adoc | 63 +++++++ .../modules/ROOT/pages/whats-new.adoc | 1 + 10 files changed, 665 insertions(+), 15 deletions(-) create mode 100644 spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStoreITests.java create mode 100644 spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisSessionExpirationStore.java create mode 100644 spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStore.java create mode 100644 spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStoreTests.java diff --git a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/RedisIndexedSessionRepositoryITests.java b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/RedisIndexedSessionRepositoryITests.java index 29b603dca..b9798a95f 100644 --- a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/RedisIndexedSessionRepositoryITests.java +++ b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/RedisIndexedSessionRepositoryITests.java @@ -19,6 +19,7 @@ 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 @@ void saves() throws InterruptedException { .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(); diff --git a/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStoreITests.java b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStoreITests.java new file mode 100644 index 000000000..9ee2582a1 --- /dev/null +++ b/spring-session-data-redis/src/integration-test/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStoreITests.java @@ -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 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 redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setKeySerializer(RedisSerializer.string()); + redisTemplate.setHashKeySerializer(RedisSerializer.string()); + redisTemplate.setConnectionFactory(redisConnectionFactory); + return redisTemplate; + } + + @Bean + RedisSessionExpirationStore redisSessionExpirationStore(RedisTemplate redisTemplate) { + SortedSetRedisSessionExpirationStore store = new SortedSetRedisSessionExpirationStore(redisTemplate, + RedisIndexedSessionRepository.DEFAULT_NAMESPACE); + store.setClock(SortedSetRedisSessionExpirationStoreITests.clock); + return store; + } + + } + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisIndexedSessionRepository.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisIndexedSessionRepository.java index 9a311b027..13c9c5714 100644 --- a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisIndexedSessionRepository.java +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisIndexedSessionRepository.java @@ -18,12 +18,15 @@ 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.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.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 sessionRedisOperations; - private final RedisSessionExpirationPolicy expirationPolicy; + private RedisSessionExpirationStore expirationStore; private ApplicationEventPublisher eventPublisher = (event) -> { }; @@ -337,8 +343,8 @@ public class RedisIndexedSessionRepository public RedisIndexedSessionRepository(RedisOperations 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 void save(RedisSession session) { } public void cleanUpExpiredSessions() { - this.expirationPolicy.cleanExpiredSessions(); + this.expirationStore.cleanupExpiredSessions(); } @Override @@ -543,7 +549,7 @@ public void deleteById(String sessionId) { } cleanupPrincipalIndex(session); - this.expirationPolicy.onDelete(session); + this.expirationStore.remove(sessionId); String expireKey = getExpiredKey(session.getId()); this.sessionRedisOperations.delete(expireKey); @@ -604,6 +610,7 @@ public void onMessage(Message message, byte[] pattern) { } cleanupPrincipalIndex(session); + this.expirationStore.remove(session.getId()); if (isDeleted) { handleDeleted(session); @@ -650,6 +657,18 @@ public void setRedisKeyNamespace(String namespace) { 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 void setRedisSessionMapper(BiFunction, MapSes * * @author Rob Winch */ - final class RedisSession implements Session { + public final class RedisSession implements Session { private final MapSession cached; @@ -905,10 +924,41 @@ private void saveDelta() { 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 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 valueOps = RedisIndexedSessionRepository.this.sessionRedisOperations + .boundValueOps(sessionKey); + valueOps.append(""); + valueOps.expire(sessionExpireInSeconds, TimeUnit.SECONDS); + } } private void saveChangeSessionId() { @@ -941,6 +991,7 @@ private void saveChangeSessionId() { RedisIndexedSessionRepository.this.sessionRedisOperations.boundSetOps(originalPrincipalRedisKey) .add(sessionId); } + RedisIndexedSessionRepository.this.expirationStore.remove(this.originalSessionId); } this.originalSessionId = sessionId; } @@ -954,4 +1005,100 @@ private void handleErrNoSuchKeyError(NonTransientDataAccessException ex) { } + private final class MinuteBasedRedisSessionExpirationStore implements RedisSessionExpirationStore { + + private static final String SESSION_EXPIRES_PREFIX = "expires:"; + + private final RedisOperations redis; + + private final Function lookupExpirationKey; + + MinuteBasedRedisSessionExpirationStore(RedisOperations redis, + Function 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 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 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 + * gh-93 + * @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(); + } + + } + } diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisSessionExpirationStore.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisSessionExpirationStore.java new file mode 100644 index 000000000..6d35e994e --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/RedisSessionExpirationStore.java @@ -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 + * how + * keys expire. 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(); + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStore.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStore.java new file mode 100644 index 000000000..37dc163c4 --- /dev/null +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStore.java @@ -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 redisOps; + + private String namespace; + + private int cleanupCount = 100; + + private Clock clock = Clock.systemUTC(); + + private String expirationsKey; + + public SortedSetRedisSessionExpirationStore(RedisOperations 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 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 + * gh-93 + * @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; + } + +} diff --git a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisIndexedHttpSessionConfiguration.java b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisIndexedHttpSessionConfiguration.java index 6e8531cf9..62ef93b77 100644 --- a/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisIndexedHttpSessionConfiguration.java +++ b/spring-session-data-redis/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisIndexedHttpSessionConfiguration.java @@ -47,6 +47,7 @@ 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 RedisIndexedSessionRepository sessionRepository() { 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 void setRedisSubscriptionExecutor(Executor redisSubscriptionExecutor) { this.redisSubscriptionExecutor = redisSubscriptionExecutor; } + @Autowired(required = false) + public void setExpirationStore(RedisSessionExpirationStore expirationStore) { + this.expirationStore = expirationStore; + } + @Override public void setEmbeddedValueResolver(StringValueResolver resolver) { this.embeddedValueResolver = resolver; diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisIndexedSessionRepositoryTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisIndexedSessionRepositoryTests.java index 3303eebbf..f1bc11cbb 100644 --- a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisIndexedSessionRepositoryTests.java +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/RedisIndexedSessionRepositoryTests.java @@ -504,11 +504,16 @@ void onMessageDeletedSessionFound() { String deletedId = "deleted-id"; given(this.redisOperations.boundHashOps(getKey(deletedId))) .willReturn(this.boundHashOperations); + long lastAccessedTimeMillis = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5); Map 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 @@ void onMessageDeletedSessionFound() { 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 @@ void onMessageExpiredSessionFound() { String expiredId = "expired-id"; given(this.redisOperations.boundHashOps(getKey(expiredId))) .willReturn(this.boundHashOperations); + long lastAccessedTimeMillis = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5); Map 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 @@ void onMessageExpiredSessionFound() { 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); diff --git a/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStoreTests.java b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStoreTests.java new file mode 100644 index 000000000..f276bdfb3 --- /dev/null +++ b/spring-session-data-redis/src/test/java/org/springframework/session/data/redis/SortedSetRedisSessionExpirationStoreTests.java @@ -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 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"); + } + +} diff --git a/spring-session-docs/modules/ROOT/pages/configuration/redis.adoc b/spring-session-docs/modules/ROOT/pages/configuration/redis.adoc index 182e874e3..458f53aa6 100644 --- a/spring-session-docs/modules/ROOT/pages/configuration/redis.adoc +++ b/spring-session-docs/modules/ROOT/pages/configuration/redis.adoc @@ -10,6 +10,7 @@ Now that you have your application configured, you might want to start customizi - I want to <>. - I want to <> - I want to <> +- Customizing the <> [[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 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. +==== diff --git a/spring-session-docs/modules/ROOT/pages/whats-new.adoc b/spring-session-docs/modules/ROOT/pages/whats-new.adoc index 5fd76654f..94ddee55e 100644 --- a/spring-session-docs/modules/ROOT/pages/whats-new.adoc +++ b/spring-session-docs/modules/ROOT/pages/whats-new.adoc @@ -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`