Introduce RedisSessionExpirationStore

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

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

View File

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

View File

@@ -0,0 +1,121 @@
/*
* Copyright 2014-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.session.data.redis;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.session.data.redis.RedisIndexedSessionRepository.RedisSession;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.context.web.WebAppConfiguration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link SortedSetRedisSessionExpirationStore}
*
* @author Marcus da Coregio
*/
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = SortedSetRedisSessionExpirationStoreITests.Config.class)
@WebAppConfiguration
class SortedSetRedisSessionExpirationStoreITests {
@Autowired
private SortedSetRedisSessionExpirationStore expirationStore;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final Instant mockedTime = LocalDateTime.of(2024, 5, 8, 10, 30, 0)
.atZone(ZoneOffset.UTC)
.toInstant();
private static final Clock clock;
static {
clock = Clock.fixed(mockedTime, ZoneOffset.UTC);
}
@Test
void saveThenStoreSessionWithItsExpiration() {
Instant expireAt = mockedTime.plusSeconds(5);
RedisSession session = createSession("123", expireAt);
this.expirationStore.save(session);
Double score = this.redisTemplate.opsForZSet().score("spring:session:sessions:expirations", "123");
assertThat(score).isEqualTo(expireAt.toEpochMilli());
}
@Test
void removeWhenSessionIdExistsThenRemoved() {
RedisSession session = createSession("toBeRemoved", mockedTime);
this.expirationStore.save(session);
Double score = this.redisTemplate.opsForZSet().score("spring:session:sessions:expirations", "toBeRemoved");
assertThat(score).isEqualTo(mockedTime.toEpochMilli());
this.expirationStore.remove("toBeRemoved");
score = this.redisTemplate.opsForZSet().score("spring:session:sessions:expirations", "toBeRemoved");
assertThat(score).isNull();
}
private RedisSession createSession(String sessionId, Instant expireAt) {
RedisSession session = mock();
given(session.getId()).willReturn(sessionId);
given(session.getLastAccessedTime()).willReturn(expireAt);
given(session.getMaxInactiveInterval()).willReturn(Duration.ZERO);
return session;
}
@Configuration(proxyBeanMethods = false)
@Import(AbstractRedisITests.BaseConfig.class)
static class Config {
@Bean
RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setHashKeySerializer(RedisSerializer.string());
redisTemplate.setConnectionFactory(redisConnectionFactory);
return redisTemplate;
}
@Bean
RedisSessionExpirationStore redisSessionExpirationStore(RedisTemplate<String, Object> redisTemplate) {
SortedSetRedisSessionExpirationStore store = new SortedSetRedisSessionExpirationStore(redisTemplate,
RedisIndexedSessionRepository.DEFAULT_NAMESPACE);
store.setClock(SortedSetRedisSessionExpirationStoreITests.clock);
return store;
}
}
}

View File

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

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2014-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.session.data.redis;
/**
* An interface for storing {@link RedisIndexedSessionRepository.RedisSession} instances
* with their expected expiration time. This approach is necessary because Redis does not
* guarantee when the expired event will be fired if the key has not been accessed. For
* more details, see the Redis documentation on
* <a href="https://redis.io/commands/expire/#:~:text=How%20Redis%20expires%20keys"> how
* keys expire</a>. To address the uncertainty of expired events, sessions can be stored
* with their expected expiration time, ensuring each key is accessed when it is expected
* to expire. This interface defines common operations for tracking sessions and their
* expiration times, and allows for a strategy to clean up expired sessions.
*
* @author Marcus da Coregio
* @since 3.4
*/
public interface RedisSessionExpirationStore {
/**
* Saves the session and its expected expiration time, so it can be found later on by
* its expiration time in order for clean up to happen.
* @param session the session to save
*/
void save(RedisIndexedSessionRepository.RedisSession session);
/**
* Removes the session id from the expiration store.
* @param sessionId the session id
*/
void remove(String sessionId);
/**
* Performs clean up on the expired sessions.
*/
void cleanupExpiredSessions();
}

View File

@@ -0,0 +1,141 @@
/*
* Copyright 2014-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.session.data.redis;
import java.time.Clock;
import java.time.Instant;
import java.util.Set;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.session.Session;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
/**
* Uses a sorted set to store the expiration times for sessions. The score of each entry
* is the expiration time of the session (calculated via
* {@link Session#getLastAccessedTime()} + {@link Session#getMaxInactiveInterval()}). The
* value is the session id. Note that {@link #cleanupExpiredSessions()} only retrieves up
* to 100 sessions at a time by default, use {@link #setCleanupCount(int)} to increase it
* if needed.
*
* @author Marcus da Coregio
* @since 3.4
*/
public class SortedSetRedisSessionExpirationStore implements RedisSessionExpirationStore {
private final RedisOperations<String, Object> redisOps;
private String namespace;
private int cleanupCount = 100;
private Clock clock = Clock.systemUTC();
private String expirationsKey;
public SortedSetRedisSessionExpirationStore(RedisOperations<String, Object> redisOps, String namespace) {
Assert.notNull(redisOps, "redisOps cannot be null");
this.redisOps = redisOps;
setNamespace(namespace);
}
/**
* Save the session id associated with the expiration time into the sorted set.
* @param session the session to save
*/
@Override
public void save(RedisIndexedSessionRepository.RedisSession session) {
long expirationInMillis = getExpirationTime(session).toEpochMilli();
this.redisOps.opsForZSet().add(this.expirationsKey, session.getId(), expirationInMillis);
}
/**
* Remove the session id from the sorted set.
* @param sessionId the session id
*/
@Override
public void remove(String sessionId) {
this.redisOps.opsForZSet().remove(this.expirationsKey, sessionId);
}
/**
* Retrieves the sessions that are expected to be expired and invoke
* {@link #touch(String)} on each of the session keys, resolved via
* {@link #getSessionKey(String)}.
*/
@Override
public void cleanupExpiredSessions() {
Set<Object> sessionIds = this.redisOps.opsForZSet()
.reverseRangeByScore(this.expirationsKey, 0, this.clock.millis(), 0, this.cleanupCount);
if (CollectionUtils.isEmpty(sessionIds)) {
return;
}
for (Object sessionId : sessionIds) {
String sessionKey = getSessionKey((String) sessionId);
touch(sessionKey);
}
}
private Instant getExpirationTime(RedisIndexedSessionRepository.RedisSession session) {
return session.getLastAccessedTime().plus(session.getMaxInactiveInterval());
}
/**
* Checks if the session exists. By trying to access the session we only trigger a
* deletion if the TTL is expired. This is done to handle
* <a href="https://github.com/spring-projects/spring-session/issues/93">gh-93</a>
* @param sessionKey the key
*/
private void touch(String sessionKey) {
this.redisOps.hasKey(sessionKey);
}
private String getSessionKey(String sessionId) {
return this.namespace + ":sessions:" + sessionId;
}
/**
* Set the namespace for the keys.
* @param namespace the namespace
*/
public void setNamespace(String namespace) {
Assert.hasText(namespace, "namespace cannot be null or empty");
this.namespace = namespace;
this.expirationsKey = this.namespace + ":sessions:expirations";
}
/**
* Configure the clock used when retrieving expired sessions for clean-up.
* @param clock the clock
*/
public void setClock(Clock clock) {
Assert.notNull(clock, "clock cannot be null");
this.clock = clock;
}
/**
* Configures how many sessions will be queried at a time to be cleaned up. Defaults
* to 100.
* @param cleanupCount how many sessions to be queried, must be bigger than 0.
*/
public void setCleanupCount(int cleanupCount) {
Assert.state(cleanupCount > 0, "cleanupCount must be greater than 0");
this.cleanupCount = cleanupCount;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,86 @@
/*
* Copyright 2014-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.session.data.redis;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Answers;
import org.springframework.data.redis.core.RedisTemplate;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* Tests for {@link SortedSetRedisSessionExpirationStore}
*
* @author Marcus da Coregio
*/
class SortedSetRedisSessionExpirationStoreTests {
private SortedSetRedisSessionExpirationStore expirationStore;
private final RedisTemplate<String, Object> redisTemplate = mock(Answers.RETURNS_DEEP_STUBS);
@BeforeEach
void setup() {
this.expirationStore = new SortedSetRedisSessionExpirationStore(this.redisTemplate,
RedisIndexedSessionRepository.DEFAULT_NAMESPACE);
}
@Test
void setNamespaceWhenNullOrEmptyThenException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.expirationStore.setNamespace(null))
.withMessage("namespace cannot be null or empty");
assertThatIllegalArgumentException().isThrownBy(() -> this.expirationStore.setNamespace(""))
.withMessage("namespace cannot be null or empty");
}
@Test
void setClockWhenNullThenException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.expirationStore.setClock(null))
.withMessage("clock cannot be null");
}
@Test
void setCleanupCountWhenZeroOrNegativeThenException() {
assertThatIllegalStateException().isThrownBy(() -> this.expirationStore.setCleanupCount(0))
.withMessage("cleanupCount must be greater than 0");
assertThatIllegalStateException().isThrownBy(() -> this.expirationStore.setCleanupCount(-1))
.withMessage("cleanupCount must be greater than 0");
}
@Test
void cleanupExpiredSessionsThenTouchExpiredSessions() {
given(this.redisTemplate.opsForZSet()
.reverseRangeByScore(anyString(), anyDouble(), anyDouble(), anyLong(), anyLong()))
.willReturn(Set.of("1", "2", "3"));
this.expirationStore.cleanupExpiredSessions();
verify(this.redisTemplate).hasKey("spring:session:sessions:1");
verify(this.redisTemplate).hasKey("spring:session:sessions:2");
verify(this.redisTemplate).hasKey("spring:session:sessions:3");
}
}