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:
John Blum
2023-07-20 15:37:38 -07:00
committed by Christoph Strobl
parent 862e3446bc
commit dddf3530b9
12 changed files with 600 additions and 90 deletions

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

View File

@@ -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 {
}

View File

@@ -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 {

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

View File

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