diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 63381738e..c5f9cf197 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,7 +9,6 @@ updates: - package-ecosystem: "gradle" target-branch: "main" - milestone: 154 directory: "/" schedule: interval: "daily" @@ -24,11 +23,10 @@ updates: - dependency-name: "org.mockito:mockito-bom" update-types: [ "version-update:semver-major" ] - dependency-name: "*" - update-types: [ "version-update:semver-major", "version-update:semver-minor" ] + update-types: [ "version-update:semver-major" ] - package-ecosystem: "gradle" - target-branch: "3.2.x" - milestone: 152 + target-branch: "3.3.x" directory: "/" schedule: interval: "daily" @@ -46,8 +44,7 @@ updates: update-types: [ "version-update:semver-major", "version-update:semver-minor" ] - package-ecosystem: "gradle" - target-branch: "3.1.x" - milestone: 151 + target-branch: "3.2.x" directory: "/" schedule: interval: "daily" @@ -71,13 +68,13 @@ updates: schedule: interval: weekly - package-ecosystem: github-actions - target-branch: "3.2.x" + target-branch: "3.3.x" milestone: 152 directory: "/" schedule: interval: weekly - package-ecosystem: github-actions - target-branch: "3.1.x" + target-branch: "3.2.x" milestone: 151 directory: "/" schedule: @@ -87,3 +84,9 @@ updates: directory: "/" schedule: interval: weekly + + - package-ecosystem: npm + target-branch: docs-build + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/release-scheduler.yml b/.github/workflows/release-scheduler.yml index 04f640d10..c4e6b5083 100644 --- a/.github/workflows/release-scheduler.yml +++ b/.github/workflows/release-scheduler.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: # List of active maintenance branches. - branch: [ main, 3.2.x, 3.1.x ] + branch: [ main, 3.3.x, 3.2.x ] runs-on: ubuntu-latest steps: - name: Checkout diff --git a/git/hooks/prepare-forward-merge b/git/hooks/prepare-forward-merge index dfc548995..a7e35bffd 100755 --- a/git/hooks/prepare-forward-merge +++ b/git/hooks/prepare-forward-merge @@ -4,7 +4,7 @@ require 'net/http' require 'yaml' require 'logger' -$main_branch = "3.3.x" +$main_branch = "3.4.x" $log = Logger.new(STDOUT) $log.level = Logger::WARN diff --git a/gradle.properties b/gradle.properties index b68391b42..c79736f87 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 org.gradle.parallel=true -version=3.3.3-SNAPSHOT +version=3.4.0-SNAPSHOT diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9aa77aa75..bcb706653 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ org-mongodb = "5.0.1" org-seleniumhq-selenium = "4.13.0" org-slf4j = "2.0.16" org-testcontainers = "1.19.8" -org-springframework-boot = "3.2.9" +org-springframework-boot = "3.3.3" [libraries] ch-qos-logback-logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "ch-qos-logback" } @@ -58,8 +58,8 @@ org-slf4j-jcl-over-slf4j = { module = "org.slf4j:jcl-over-slf4j", version.ref = org-slf4j-log4j-over-slf4j = { module = "org.slf4j:log4j-over-slf4j", version.ref = "org-slf4j" } org-slf4j-slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "org-slf4j" } org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2024.0.3" -org-springframework-security-spring-security-bom = "org.springframework.security:spring-security-bom:6.3.3" -org-springframework-spring-framework-bom = "org.springframework:spring-framework-bom:6.1.12" +org-springframework-security-spring-security-bom = "org.springframework.security:spring-security-bom:6.4.0-SNAPSHOT" +org-springframework-spring-framework-bom = "org.springframework:spring-framework-bom:6.2.0-M4" org-springframework-boot-spring-boot-dependencies = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "org-springframework-boot" } org-springframework-boot-spring-boot-gradle-plugin = { module = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "org-springframework-boot" } org-testcontainers-testcontainers-bom = { module = "org.testcontainers:testcontainers-bom", version.ref = "org-testcontainers" } diff --git a/spring-session-core/src/main/java/org/springframework/session/web/http/DefaultCookieSerializer.java b/spring-session-core/src/main/java/org/springframework/session/web/http/DefaultCookieSerializer.java index 7b350ac38..afb9b231a 100644 --- a/spring-session-core/src/main/java/org/springframework/session/web/http/DefaultCookieSerializer.java +++ b/spring-session-core/src/main/java/org/springframework/session/web/http/DefaultCookieSerializer.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2021 the original author or authors. + * 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. @@ -86,6 +86,8 @@ public class DefaultCookieSerializer implements CookieSerializer { private String sameSite = "Lax"; + private boolean partitioned; + /* * @see * org.springframework.session.web.http.CookieSerializer#readCookieValues(jakarta. @@ -153,6 +155,9 @@ public void writeCookieValue(CookieValue cookieValue) { if (this.sameSite != null) { sb.append("; SameSite=").append(this.sameSite); } + if (this.partitioned) { + sb.append("; Partitioned"); + } response.addHeader("Set-Cookie", sb.toString()); } @@ -444,4 +449,13 @@ public String getRememberMeRequestAttribute() { return this.rememberMeRequestAttribute; } + /** + * Allows defining whether the generated cookie carries the Partitioned attribute. + * @param partitioned whether the generate cookie is partitioned + * @since 3.4 + */ + public void setPartitioned(boolean partitioned) { + this.partitioned = partitioned; + } + } diff --git a/spring-session-core/src/test/java/org/springframework/session/web/http/DefaultCookieSerializerTests.java b/spring-session-core/src/test/java/org/springframework/session/web/http/DefaultCookieSerializerTests.java index 358b884c1..890fe53f8 100644 --- a/spring-session-core/src/test/java/org/springframework/session/web/http/DefaultCookieSerializerTests.java +++ b/spring-session-core/src/test/java/org/springframework/session/web/http/DefaultCookieSerializerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2021 the original author or authors. + * 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. @@ -460,6 +460,13 @@ void writeCookieSetSameSiteNull() { assertThat(getCookie().getSameSite()).isNull(); } + @Test + void writeCookieWhenPartitionedTrueThenSetPartitionedAttribute() { + this.serializer.setPartitioned(true); + this.serializer.writeCookieValue(cookieValue(this.sessionId)); + assertThat(getCookie().isPartitioned()).isTrue(); + } + void setCookieName(String cookieName) { this.cookieName = cookieName; this.serializer.setCookieName(cookieName); diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/JacksonMongoSessionConverter.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/JacksonMongoSessionConverter.java index 986634200..8dfdb5abb 100644 --- a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/JacksonMongoSessionConverter.java +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/JacksonMongoSessionConverter.java @@ -16,11 +16,6 @@ package org.springframework.session.data.mongo; -import java.io.IOException; -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; - import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; @@ -37,7 +32,6 @@ import org.bson.Document; import org.bson.json.JsonMode; import org.bson.json.JsonWriterSettings; - import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; import org.springframework.lang.Nullable; @@ -45,6 +39,11 @@ import org.springframework.session.FindByIndexNameSessionRepository; import org.springframework.util.Assert; +import java.io.IOException; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; + /** * {@code AbstractMongoSessionConverter} implementation using Jackson. * @@ -76,11 +75,22 @@ public JacksonMongoSessionConverter(Iterable modules) { } public JacksonMongoSessionConverter(ObjectMapper objectMapper) { - - Assert.notNull(objectMapper, "ObjectMapper can NOT be null!"); + Assert.notNull(objectMapper, "ObjectMapper can not be null!"); this.objectMapper = objectMapper; } + public JacksonMongoSessionConverter(ObjectMapper objectMapper, boolean copyToUse) { + Assert.notNull(objectMapper, "ObjectMapper can not be null!"); + if (!copyToUse) { + configureObjectMapper(objectMapper); + this.objectMapper = objectMapper; + return; + } + var objectMapperCopy = objectMapper.copy(); + configureObjectMapper(objectMapperCopy); + this.objectMapper = objectMapperCopy; + } + @Nullable protected Query getQueryForIndex(String indexName, Object indexValue) { @@ -93,9 +103,12 @@ protected Query getQueryForIndex(String indexName, Object indexValue) { } private ObjectMapper buildObjectMapper() { - ObjectMapper objectMapper = new ObjectMapper(); + this.configureObjectMapper(objectMapper); + return objectMapper; + } + private void configureObjectMapper(ObjectMapper objectMapper) { // serialize fields instead of properties objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); objectMapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); @@ -108,8 +121,6 @@ private ObjectMapper buildObjectMapper() { objectMapper.registerModules(SecurityJackson2Modules.getModules(getClass().getClassLoader())); objectMapper.addMixIn(MongoSession.class, MongoSessionMixin.class); objectMapper.addMixIn(HashMap.class, HashMapMixin.class); - - return objectMapper; } @Override diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoSession.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoSession.java index d3bb813e8..8be09ea46 100644 --- a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoSession.java +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoSession.java @@ -39,7 +39,7 @@ * @author Greg Turnquist * @since 1.2 */ -class MongoSession implements Session { +public final class MongoSession implements Session { /** * Mongo doesn't support {@literal dot} in field names. We replace it with a unicode @@ -74,20 +74,19 @@ class MongoSession implements Session { * @param sessionId the session id to use * @since 3.2 */ - MongoSession(String sessionId) { + public MongoSession(String sessionId) { this(sessionId, MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS); } - MongoSession() { + public MongoSession() { this(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS); } - MongoSession(long maxInactiveIntervalInSeconds) { + public MongoSession(long maxInactiveIntervalInSeconds) { this(UuidSessionIdGenerator.getInstance().generate(), maxInactiveIntervalInSeconds); } - MongoSession(String id, long maxInactiveIntervalInSeconds) { - + public MongoSession(String id, long maxInactiveIntervalInSeconds) { this.id = id; this.originalSessionId = id; this.intervalSeconds = maxInactiveIntervalInSeconds; @@ -99,7 +98,7 @@ class MongoSession implements Session { * @param sessionIdGenerator the {@link SessionIdGenerator} to use * @since 3.2 */ - MongoSession(SessionIdGenerator sessionIdGenerator) { + public MongoSession(SessionIdGenerator sessionIdGenerator) { this(sessionIdGenerator.generate(), MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS); this.sessionIdGenerator = sessionIdGenerator; } 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 ea07f330e..94ddee55e 100644 --- a/spring-session-docs/modules/ROOT/pages/whats-new.adoc +++ b/spring-session-docs/modules/ROOT/pages/whats-new.adoc @@ -1,4 +1,4 @@ -= What's New += What's New in 3.4 -- xref:configuration/common.adoc#changing-how-session-ids-are-generated[docs] - https://github.com/spring-projects/spring-session/issues/11[gh-11] - Introduce `SessionIdGenerator` to allow custom session id generation -- xref:configuration/redis.adoc#configuring-redis-session-mapper[docs] - https://github.com/spring-projects/spring-session/issues/2021[gh-2021] - Allow safe deserialization of Redis sessions +- 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`