Add support for TTI expiration in Redis Cache implementation.
We now support time-to-idle (TTI) expiration policies for cache reads. The TTI implementation is achieved with the use of the Redis GETEX command on Cache.get(key) operations as well as consistently using the same TTL configuration for all cache operations when TTI is enabled and TTL expiration has been configured, with the use of a TtlFunction or fixed Duration. Closes #2351 Original pull request: #2643
This commit is contained in:
committed by
Christoph Strobl
parent
862e3446bc
commit
dddf3530b9
99
src/test/java/org/springframework/data/redis/cache/DefaultRedisCacheWriterUnitTests.java
vendored
Normal file
99
src/test/java/org/springframework/data/redis/cache/DefaultRedisCacheWriterUnitTests.java
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2023 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.data.redis.cache;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import org.springframework.data.redis.connection.RedisConnection;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.types.Expiration;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link DefaultRedisCacheWriter}
|
||||
*
|
||||
* @author John Blum
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DefaultRedisCacheWriterUnitTests {
|
||||
|
||||
@Mock
|
||||
private RedisConnection mockConnection;
|
||||
|
||||
@Mock
|
||||
private RedisConnectionFactory mockConnectionFactory;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
doReturn(this.mockConnection).when(this.mockConnectionFactory).getConnection();
|
||||
}
|
||||
|
||||
private RedisCacheWriter newRedisCacheWriter() {
|
||||
return new DefaultRedisCacheWriter(this.mockConnectionFactory, mock(BatchStrategy.class))
|
||||
.withStatisticsCollector(mock(CacheStatisticsCollector.class));
|
||||
}
|
||||
|
||||
@Test // GH-2351
|
||||
void getWithNonNullTtl() {
|
||||
|
||||
byte[] key = "TestKey".getBytes();
|
||||
byte[] value = "TestValue".getBytes();
|
||||
|
||||
Duration ttl = Duration.ofSeconds(15);
|
||||
Expiration expiration = Expiration.from(ttl);
|
||||
|
||||
doReturn(value).when(this.mockConnection).getEx(any(), any());
|
||||
|
||||
RedisCacheWriter cacheWriter = newRedisCacheWriter();
|
||||
|
||||
assertThat(cacheWriter.get("TestCache", key, ttl)).isEqualTo(value);
|
||||
|
||||
verify(this.mockConnection, times(1)).getEx(eq(key), eq(expiration));
|
||||
verify(this.mockConnection).close();
|
||||
verifyNoMoreInteractions(this.mockConnection);
|
||||
}
|
||||
|
||||
@Test // GH-2351
|
||||
void getWithNullTtl() {
|
||||
|
||||
byte[] key = "TestKey".getBytes();
|
||||
byte[] value = "TestValue".getBytes();
|
||||
|
||||
doReturn(value).when(this.mockConnection).get(any());
|
||||
|
||||
RedisCacheWriter cacheWriter = newRedisCacheWriter();
|
||||
|
||||
assertThat(cacheWriter.get("TestCache", key, null)).isEqualTo(value);
|
||||
|
||||
verify(this.mockConnection, times(1)).get(eq(key));
|
||||
verify(this.mockConnection).close();
|
||||
verifyNoMoreInteractions(this.mockConnection);
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,6 @@ class RedisCacheConfigurationUnitTests {
|
||||
RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
|
||||
.entryTtl(sixtySeconds);
|
||||
|
||||
|
||||
assertThat(cacheConfiguration).isNotNull();
|
||||
assertThat(cacheConfiguration.getTtl()).isEqualByComparingTo(sixtySeconds);
|
||||
assertThat(cacheConfiguration.getTtl()).isEqualByComparingTo(sixtySeconds); // does not change!
|
||||
@@ -83,7 +82,7 @@ class RedisCacheConfigurationUnitTests {
|
||||
|
||||
@Test // GH-2628
|
||||
@SuppressWarnings("deprecation")
|
||||
public void getTtlCanReturnDynamicDuration() {
|
||||
public void getTtlReturnsDynamicDuration() {
|
||||
|
||||
Duration thirtyMinutes = Duration.ofMinutes(30);
|
||||
Duration twoHours = Duration.ofHours(2);
|
||||
@@ -102,6 +101,21 @@ class RedisCacheConfigurationUnitTests {
|
||||
verifyNoMoreInteractions(mockTtlFunction);
|
||||
}
|
||||
|
||||
@Test // GH-2351
|
||||
public void enableTtiExpirationShouldConfigureTti() {
|
||||
|
||||
RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig();
|
||||
|
||||
assertThat(cacheConfiguration).isNotNull();
|
||||
assertThat(cacheConfiguration.isTimeToIdleEnabled()).isFalse();
|
||||
|
||||
RedisCacheConfiguration ttiEnabledCacheConfiguration = cacheConfiguration.enableTimeToIdle();
|
||||
|
||||
assertThat(ttiEnabledCacheConfiguration).isNotNull();
|
||||
assertThat(ttiEnabledCacheConfiguration).isNotSameAs(cacheConfiguration);
|
||||
assertThat(ttiEnabledCacheConfiguration.isTimeToIdleEnabled()).isTrue();
|
||||
}
|
||||
|
||||
private static class DomainType {
|
||||
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
*/
|
||||
package org.springframework.data.redis.cache;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.assertj.core.api.Assumptions.*;
|
||||
|
||||
import io.netty.util.concurrent.DefaultThreadFactory;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
|
||||
import static org.assertj.core.api.Assumptions.assumeThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -34,9 +34,11 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
|
||||
import org.springframework.cache.Cache.ValueWrapper;
|
||||
import org.springframework.cache.interceptor.SimpleKey;
|
||||
import org.springframework.cache.interceptor.SimpleKeyGenerator;
|
||||
@@ -46,10 +48,13 @@ import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
|
||||
import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;
|
||||
import org.springframework.data.redis.serializer.RedisSerializer;
|
||||
import org.springframework.data.redis.test.condition.EnabledOnCommand;
|
||||
import org.springframework.data.redis.test.extension.parametrized.MethodSource;
|
||||
import org.springframework.data.redis.test.extension.parametrized.ParameterizedRedisTest;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
import io.netty.util.concurrent.DefaultThreadFactory;
|
||||
|
||||
/**
|
||||
* Tests for {@link RedisCache} with {@link DefaultRedisCacheWriter} using different {@link RedisSerializer} and
|
||||
* {@link RedisConnectionFactory} pairs.
|
||||
@@ -92,8 +97,22 @@ public class RedisCacheTests {
|
||||
|
||||
doWithConnection(RedisConnection::flushAll);
|
||||
|
||||
cache = new RedisCache("cache", RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory),
|
||||
RedisCacheConfiguration.defaultCacheConfig().serializeValuesWith(SerializationPair.fromSerializer(serializer)));
|
||||
this.cache = new RedisCache("cache", usingRedisCacheWriter(), usingRedisCacheConfiguration());
|
||||
}
|
||||
|
||||
private RedisCacheWriter usingRedisCacheWriter() {
|
||||
return RedisCacheWriter.nonLockingRedisCacheWriter(this.connectionFactory);
|
||||
}
|
||||
|
||||
private RedisCacheConfiguration usingRedisCacheConfiguration() {
|
||||
return usingRedisCacheConfiguration(Function.identity());
|
||||
}
|
||||
|
||||
private RedisCacheConfiguration usingRedisCacheConfiguration(
|
||||
Function<RedisCacheConfiguration, RedisCacheConfiguration> customizer) {
|
||||
|
||||
return customizer.apply(RedisCacheConfiguration.defaultCacheConfig()
|
||||
.serializeValuesWith(SerializationPair.fromSerializer(this.serializer)));
|
||||
}
|
||||
|
||||
@ParameterizedRedisTest // DATAREDIS-481
|
||||
@@ -455,6 +474,7 @@ public class RedisCacheTests {
|
||||
AtomicReference<byte[]> storage = new AtomicReference<>();
|
||||
|
||||
cache = new RedisCache("foo", new RedisCacheWriter() {
|
||||
|
||||
@Override
|
||||
public void put(String name, byte[] key, byte[] value, @Nullable Duration ttl) {
|
||||
storage.set(value);
|
||||
@@ -462,6 +482,11 @@ public class RedisCacheTests {
|
||||
|
||||
@Override
|
||||
public byte[] get(String name, byte[] key) {
|
||||
return get(name, key, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] get(String name, byte[] key, @Nullable Duration ttl) {
|
||||
|
||||
prepare.countDown();
|
||||
try {
|
||||
@@ -525,6 +550,52 @@ public class RedisCacheTests {
|
||||
assertThat(retrievals).hasValue(1);
|
||||
}
|
||||
|
||||
@EnabledOnCommand("GETEX")
|
||||
@ParameterizedRedisTest // GH-2351
|
||||
void cacheGetWithTimeToIdleExpirationWhenEntryNotExpiredShouldReturnValue() {
|
||||
|
||||
doWithConnection(connection -> connection.set(this.binaryCacheKey, this.binarySample));
|
||||
|
||||
RedisCache cache = new RedisCache("cache", usingRedisCacheWriter(),
|
||||
usingRedisCacheConfiguration(withTtiExpiration()));
|
||||
|
||||
assertThat(unwrap(cache.get(this.key))).isEqualTo(this.sample);
|
||||
|
||||
for (int count = 0; count < 5; count++) {
|
||||
await().atMost(Duration.ofMillis(100));
|
||||
assertThat(unwrap(cache.get(this.key))).isEqualTo(this.sample);
|
||||
}
|
||||
}
|
||||
|
||||
@EnabledOnCommand("GETEX")
|
||||
@ParameterizedRedisTest // GH-2351
|
||||
void cacheGetWithTimeToIdleExpirationAfterEntryExpiresShouldReturnNull() {
|
||||
|
||||
doWithConnection(connection -> connection.set(this.binaryCacheKey, this.binarySample));
|
||||
|
||||
RedisCache cache = new RedisCache("cache", usingRedisCacheWriter(),
|
||||
usingRedisCacheConfiguration(withTtiExpiration()));
|
||||
|
||||
assertThat(unwrap(cache.get(this.key))).isEqualTo(this.sample);
|
||||
|
||||
await().atMost(Duration.ofMillis(200));
|
||||
|
||||
assertThat(cache.get(this.cacheKey, Person.class)).isNull();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Object unwrap(@Nullable Object value) {
|
||||
return value instanceof ValueWrapper wrapper ? wrapper.get() : value;
|
||||
}
|
||||
|
||||
private Function<RedisCacheConfiguration, RedisCacheConfiguration> withTtiExpiration() {
|
||||
|
||||
Function<RedisCacheConfiguration, RedisCacheConfiguration> entryTtlFunction =
|
||||
cacheConfiguration -> cacheConfiguration.entryTtl(Duration.ofMillis(100));
|
||||
|
||||
return entryTtlFunction.andThen(RedisCacheConfiguration::enableTimeToIdle);
|
||||
}
|
||||
|
||||
void doWithConnection(Consumer<RedisConnection> callback) {
|
||||
RedisConnection connection = connectionFactory.getConnection();
|
||||
try {
|
||||
|
||||
59
src/test/java/org/springframework/data/redis/cache/RedisCacheWriterUnitTests.java
vendored
Normal file
59
src/test/java/org/springframework/data/redis/cache/RedisCacheWriterUnitTests.java
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2023 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.data.redis.cache;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doCallRealMethod;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link RedisCacheWriter}.
|
||||
*
|
||||
* @author John Blum
|
||||
*/
|
||||
class RedisCacheWriterUnitTests {
|
||||
|
||||
@Test // GH-2351
|
||||
void defaultGetWithNameKeyAndTtlCallsGetWithNameAndKeyDiscardingTtl() {
|
||||
|
||||
byte[] key = "TestKey".getBytes();
|
||||
byte[] value = "TestValue".getBytes();
|
||||
|
||||
Duration thirtyMinutes = Duration.ofMinutes(30);
|
||||
|
||||
RedisCacheWriter cacheWriter = mock(RedisCacheWriter.class);
|
||||
|
||||
doCallRealMethod().when(cacheWriter).get(anyString(), any(), any());
|
||||
doReturn(value).when(cacheWriter).get(anyString(), any());
|
||||
|
||||
assertThat(cacheWriter.get("TestCacheName", key, thirtyMinutes)).isEqualTo(value);
|
||||
|
||||
verify(cacheWriter, times(1)).get(eq("TestCacheName"), eq(key), eq(thirtyMinutes));
|
||||
verify(cacheWriter, times(1)).get(eq("TestCacheName"), eq(key));
|
||||
verifyNoMoreInteractions(cacheWriter);
|
||||
}
|
||||
}
|
||||
@@ -13,17 +13,20 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.data.redis.core.types;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link Expiration}.
|
||||
*
|
||||
* @author Mark Paluch
|
||||
* @author John Blum
|
||||
*/
|
||||
class ExpirationUnitTests {
|
||||
|
||||
@@ -53,4 +56,38 @@ class ExpirationUnitTests {
|
||||
assertThat(expiration.getExpirationTime()).isEqualTo(5L * 60);
|
||||
assertThat(expiration.getTimeUnit()).isEqualTo(TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Test // GH-2351
|
||||
void equalValuedExpirationsAreEqual() {
|
||||
|
||||
Expiration sixtyThousandMilliseconds = Expiration.milliseconds(60_000L);
|
||||
Expiration sixtySeconds = Expiration.seconds(60L);
|
||||
Expiration oneMinute = Expiration.from(1L, TimeUnit.MINUTES);
|
||||
|
||||
assertThat(sixtyThousandMilliseconds).isEqualTo(sixtySeconds);
|
||||
assertThat(sixtySeconds).isEqualTo(oneMinute);
|
||||
assertThat(oneMinute).isEqualTo(sixtyThousandMilliseconds);
|
||||
}
|
||||
|
||||
@Test // GH-2351
|
||||
void unequalValuedExpirationsAreNotEqual() {
|
||||
|
||||
Expiration sixtySeconds = Expiration.seconds(60L);
|
||||
Expiration sixtyMilliseconds = Expiration.milliseconds(60L);
|
||||
|
||||
assertThat(sixtySeconds).isNotEqualTo(sixtyMilliseconds);
|
||||
}
|
||||
|
||||
@Test // GH-2351
|
||||
void hashCodeIsCorrect() {
|
||||
|
||||
Expiration expiration = Expiration.seconds(60);
|
||||
|
||||
assertThat(expiration).hasSameHashCodeAs(Expiration.seconds(60));
|
||||
assertThat(expiration).hasSameHashCodeAs(Expiration.from(Duration.ofSeconds(60L)));
|
||||
assertThat(expiration).hasSameHashCodeAs(Expiration.from(1, TimeUnit.MINUTES));
|
||||
assertThat(expiration).doesNotHaveSameHashCodeAs(60L);
|
||||
assertThat(expiration).doesNotHaveSameHashCodeAs(Duration.ofSeconds(60L));
|
||||
assertThat(expiration).doesNotHaveSameHashCodeAs(Expiration.from(60L, TimeUnit.MINUTES));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user