Skip to content

Commit

Permalink
Introduce RedisSessionExpirationStore
Browse files Browse the repository at this point in the history
With this commit it is now possible to customize the expiration policy in RedisIndexedHttpSession

Issue spring-projectsgh-2906
  • Loading branch information
marcusdacoregio committed Aug 6, 2024
1 parent 6b07fcf commit 736a315
Show file tree
Hide file tree
Showing 7 changed files with 622 additions and 9 deletions.
Original file line number Diff line number Diff line change
@@ -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;
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;

/**
Expand Down Expand Up @@ -306,7 +312,7 @@ public class RedisIndexedSessionRepository

private final RedisOperations<String, Object> sessionRedisOperations;

private final RedisSessionExpirationPolicy expirationPolicy;
private RedisSessionExpirationStore expirationStore;

private ApplicationEventPublisher eventPublisher = (event) -> {
};
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -486,7 +492,7 @@ public void save(RedisSession session) {
}

public void cleanUpExpiredSessions() {
this.expirationPolicy.cleanExpiredSessions();
this.expirationStore.cleanupExpiredSessions();
}

@Override
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -604,6 +610,7 @@ public void onMessage(Message message, byte[] pattern) {
}

cleanupPrincipalIndex(session);
this.expirationStore.remove(session.getId());

if (isDeleted) {
handleDeleted(session);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -754,7 +773,7 @@ public void setRedisSessionMapper(BiFunction<String, Map<String, Object>, MapSes
*
* @author Rob Winch
*/
final class RedisSession implements Session {
public final class RedisSession implements Session {

private final MapSession cached;

Expand Down Expand Up @@ -905,10 +924,35 @@ private void saveDelta() {
RedisIndexedSessionRepository.this.sessionRedisOperations.convertAndSend(sessionCreatedKey, this.delta);
this.isNew = false;
}
String keyToExpire = "expires:" + getId();
long sessionExpireInSeconds = getMaxInactiveInterval().getSeconds();
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();
return;
}

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);
}
long fiveMinutesAfterExpires = sessionExpireInSeconds + TimeUnit.MINUTES.toSeconds(5);
RedisIndexedSessionRepository.this.sessionRedisOperations.boundHashOps(getSessionKey(getId()))
.expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);

this.delta = new HashMap<>(this.delta.size());
Long originalExpiration = (this.originalLastAccessTime != null)
? this.originalLastAccessTime.plus(getMaxInactiveInterval()).toEpochMilli() : null;
RedisIndexedSessionRepository.this.expirationPolicy.onExpirationUpdated(originalExpiration, this);
RedisIndexedSessionRepository.this.expirationStore.save(this);
}

private void saveChangeSessionId() {
Expand Down Expand Up @@ -941,6 +985,7 @@ private void saveChangeSessionId() {
RedisIndexedSessionRepository.this.sessionRedisOperations.boundSetOps(originalPrincipalRedisKey)
.add(sessionId);
}
RedisIndexedSessionRepository.this.expirationStore.remove(this.originalSessionId);
}
this.originalSessionId = sessionId;
}
Expand All @@ -954,4 +999,95 @@ private void handleErrNoSuchKeyError(NonTransientDataAccessException ex) {

}

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 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();
}

}

}
Loading

0 comments on commit 736a315

Please sign in to comment.