From 924db01d336681bb70368e988cd3496cc368d717 Mon Sep 17 00:00:00 2001 From: John Blum Date: Thu, 20 Jul 2023 15:37:38 -0700 Subject: [PATCH] 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 --- .../redis/cache/DefaultRedisCacheWriter.java | 6 +- .../data/redis/cache/RedisCache.java | 17 +++- .../redis/cache/RedisCacheConfiguration.java | 79 +++++++++++++++---- .../data/redis/cache/RedisCacheWriter.java | 42 +++++++--- .../data/redis/cache/RedisCacheTests.java | 3 +- 5 files changed, 111 insertions(+), 36 deletions(-) diff --git a/src/main/java/org/springframework/data/redis/cache/DefaultRedisCacheWriter.java b/src/main/java/org/springframework/data/redis/cache/DefaultRedisCacheWriter.java index 2261848429..a2dace8fc9 100644 --- a/src/main/java/org/springframework/data/redis/cache/DefaultRedisCacheWriter.java +++ b/src/main/java/org/springframework/data/redis/cache/DefaultRedisCacheWriter.java @@ -124,12 +124,14 @@ public void put(String name, byte[] key, byte[] value, @Nullable Duration ttl) { } @Override - public byte[] get(String name, byte[] key) { + public byte[] get(String name, byte[] key, @Nullable Duration ttl) { Assert.notNull(name, "Name must not be null"); Assert.notNull(key, "Key must not be null"); - byte[] result = execute(name, connection -> connection.get(key)); + byte[] result = shouldExpireWithin(ttl) + ? execute(name, connection -> connection.getEx(key, Expiration.from(ttl))) + : execute(name, connection -> connection.get(key)); statistics.incGets(name); diff --git a/src/main/java/org/springframework/data/redis/cache/RedisCache.java b/src/main/java/org/springframework/data/redis/cache/RedisCache.java index 41982b67b9..59702403d9 100644 --- a/src/main/java/org/springframework/data/redis/cache/RedisCache.java +++ b/src/main/java/org/springframework/data/redis/cache/RedisCache.java @@ -17,6 +17,7 @@ import java.lang.reflect.Method; import java.nio.ByteBuffer; +import java.time.Duration; import java.util.Arrays; import java.util.Collection; import java.util.Map; @@ -188,11 +189,21 @@ protected T loadCacheValue(Object key, Callable valueLoader) { @Override protected Object lookup(Object key) { - byte[] value = getCacheWriter().get(getName(), createAndConvertCacheKey(key)); + byte[] value = getCacheConfiguration().isTtiExpirationEnabled() + ? getCacheWriter().get(getName(), createAndConvertCacheKey(key), getTimeToLive(key)) + : getCacheWriter().get(getName(), createAndConvertCacheKey(key)); return value != null ? deserializeCacheValue(value) : null; } + private Duration getTimeToLive(Object key) { + return getTimeToLive(key, null); + } + + private Duration getTimeToLive(Object key, @Nullable Object value) { + return getCacheConfiguration().getTtlFunction().getTimeToLive(key, value); + } + @Override public void put(Object key, @Nullable Object value) { @@ -208,7 +219,7 @@ public void put(Object key, @Nullable Object value) { } getCacheWriter().put(getName(), createAndConvertCacheKey(key), serializeCacheValue(cacheValue), - getCacheConfiguration().getTtlFunction().getTimeToLive(key, value)); + getTimeToLive(key, value)); } @Override @@ -221,7 +232,7 @@ public ValueWrapper putIfAbsent(Object key, @Nullable Object value) { } byte[] result = getCacheWriter().putIfAbsent(getName(), createAndConvertCacheKey(key), - serializeCacheValue(cacheValue), getCacheConfiguration().getTtlFunction().getTimeToLive(key, value)); + serializeCacheValue(cacheValue), getTimeToLive(key, value)); return result != null ? new SimpleValueWrapper(fromStoreValue(deserializeCacheValue(result))) : null; } diff --git a/src/main/java/org/springframework/data/redis/cache/RedisCacheConfiguration.java b/src/main/java/org/springframework/data/redis/cache/RedisCacheConfiguration.java index cdae34f7c8..3b43622e62 100644 --- a/src/main/java/org/springframework/data/redis/cache/RedisCacheConfiguration.java +++ b/src/main/java/org/springframework/data/redis/cache/RedisCacheConfiguration.java @@ -47,9 +47,11 @@ public class RedisCacheConfiguration { protected static final boolean DEFAULT_CACHE_NULL_VALUES = true; + protected static final boolean DEFAULT_ENABLE_TTI_EXPIRATION = false; protected static final boolean DEFAULT_USE_PREFIX = true; protected static final boolean DO_NOT_CACHE_NULL_VALUES = false; protected static final boolean DO_NOT_USE_PREFIX = false; + protected static final boolean ENABLE_IDLE_TIME_EXPIRATION = true; /** * Default {@link RedisCacheConfiguration} using the following: @@ -108,7 +110,10 @@ public static RedisCacheConfiguration defaultCacheConfig(@Nullable ClassLoader c registerDefaultConverters(conversionService); - return new RedisCacheConfiguration(TtlFunction.persistent(), DEFAULT_CACHE_NULL_VALUES, DEFAULT_USE_PREFIX, + return new RedisCacheConfiguration(TtlFunction.persistent(), + DEFAULT_CACHE_NULL_VALUES, + DEFAULT_ENABLE_TTI_EXPIRATION, + DEFAULT_USE_PREFIX, CacheKeyPrefix.simple(), SerializationPair.fromSerializer(RedisSerializer.string()), SerializationPair.fromSerializer(RedisSerializer.java(classLoader)), @@ -116,6 +121,7 @@ public static RedisCacheConfiguration defaultCacheConfig(@Nullable ClassLoader c } private final boolean cacheNullValues; + private final boolean enableTtiExpiration; private final boolean usePrefix; private final CacheKeyPrefix keyPrefix; @@ -128,12 +134,13 @@ public static RedisCacheConfiguration defaultCacheConfig(@Nullable ClassLoader c private final TtlFunction ttlFunction; @SuppressWarnings("unchecked") - private RedisCacheConfiguration(TtlFunction ttlFunction, Boolean cacheNullValues, Boolean usePrefix, - CacheKeyPrefix keyPrefix, SerializationPair keySerializationPair, + private RedisCacheConfiguration(TtlFunction ttlFunction, Boolean cacheNullValues, Boolean enableTtiExpiration, + Boolean usePrefix, CacheKeyPrefix keyPrefix, SerializationPair keySerializationPair, SerializationPair valueSerializationPair, ConversionService conversionService) { this.ttlFunction = ttlFunction; this.cacheNullValues = cacheNullValues; + this.enableTtiExpiration = enableTtiExpiration; this.usePrefix = usePrefix; this.keyPrefix = keyPrefix; this.keySerializationPair = keySerializationPair; @@ -168,8 +175,9 @@ public RedisCacheConfiguration computePrefixWith(CacheKeyPrefix cacheKeyPrefix) Assert.notNull(cacheKeyPrefix, "Function used to compute prefix must not be null"); - return new RedisCacheConfiguration(getTtlFunction(), getAllowCacheNullValues(), DEFAULT_USE_PREFIX, - cacheKeyPrefix, getKeySerializationPair(), getValueSerializationPair(), getConversionService()); + return new RedisCacheConfiguration(getTtlFunction(), getAllowCacheNullValues(), isTtiExpirationEnabled(), + DEFAULT_USE_PREFIX, cacheKeyPrefix, getKeySerializationPair(), getValueSerializationPair(), + getConversionService()); } /** @@ -181,8 +189,9 @@ public RedisCacheConfiguration computePrefixWith(CacheKeyPrefix cacheKeyPrefix) * @return new {@link RedisCacheConfiguration}. */ public RedisCacheConfiguration disableCachingNullValues() { - return new RedisCacheConfiguration(getTtlFunction(), DO_NOT_CACHE_NULL_VALUES, usePrefix(), getKeyPrefix(), - getKeySerializationPair(), getValueSerializationPair(), getConversionService()); + return new RedisCacheConfiguration(getTtlFunction(), DO_NOT_CACHE_NULL_VALUES, isTtiExpirationEnabled(), + usePrefix(), getKeyPrefix(), getKeySerializationPair(), getValueSerializationPair(), + getConversionService()); } /** @@ -193,8 +202,30 @@ public RedisCacheConfiguration disableCachingNullValues() { * @return new {@link RedisCacheConfiguration}. */ public RedisCacheConfiguration disableKeyPrefix() { - return new RedisCacheConfiguration(getTtlFunction(), getAllowCacheNullValues(), DO_NOT_USE_PREFIX, - getKeyPrefix(), getKeySerializationPair(), getValueSerializationPair(), getConversionService()); + return new RedisCacheConfiguration(getTtlFunction(), getAllowCacheNullValues(), isTtiExpirationEnabled(), + DO_NOT_USE_PREFIX, getKeyPrefix(), getKeySerializationPair(), getValueSerializationPair(), getConversionService()); + } + + /** + * Enables {@literal time-to-idle (TTI) expiration} on {@link Cache} read operations, + * such as {@link Cache#get(Object)}. + *

+ * Enabling this option applies the same {@link #getTtlFunction() TTL expiration policy} to {@link Cache} read + * operations as it does for {@link Cache} write operations. In effect, this will invoke the Redis {@literal GETEX} + * command in place of {@literal GET}. + *

+ * Redis does not support the concept of {@literal TTI}, only {@literal TTL}. However, if {@literal TTL} expiration + * is applied to all {@link Cache} operations, both read and write alike, and {@link Cache} operations passed with + * expiration are used consistently across the application, then in effect, an application can achieve + * {@literal TTI} expiration-like behavior. + * + * @return this {@link RedisCacheConfiguration}. + * @see GETEX + */ + public RedisCacheConfiguration enableTtiExpiration() { + return new RedisCacheConfiguration(getTtlFunction(), getAllowCacheNullValues(), ENABLE_IDLE_TIME_EXPIRATION, + usePrefix(), getKeyPrefix(), getKeySerializationPair(), getValueSerializationPair(), + getConversionService()); } /** @@ -222,8 +253,9 @@ public RedisCacheConfiguration entryTtl(TtlFunction ttlFunction) { Assert.notNull(ttlFunction, "TtlFunction must not be null"); - return new RedisCacheConfiguration(ttlFunction, getAllowCacheNullValues(), usePrefix(), getKeyPrefix(), - getKeySerializationPair(), getValueSerializationPair(), getConversionService()); + return new RedisCacheConfiguration(ttlFunction, getAllowCacheNullValues(), isTtiExpirationEnabled(), + usePrefix(), getKeyPrefix(), getKeySerializationPair(), getValueSerializationPair(), + getConversionService()); } /** @@ -236,8 +268,8 @@ public RedisCacheConfiguration serializeKeysWith(SerializationPair keySe Assert.notNull(keySerializationPair, "KeySerializationPair must not be null"); - return new RedisCacheConfiguration(getTtlFunction(), getAllowCacheNullValues(), usePrefix(), getKeyPrefix(), - keySerializationPair, getValueSerializationPair(), getConversionService()); + return new RedisCacheConfiguration(getTtlFunction(), getAllowCacheNullValues(), isTtiExpirationEnabled(), + usePrefix(), getKeyPrefix(), keySerializationPair, getValueSerializationPair(), getConversionService()); } /** @@ -250,8 +282,8 @@ public RedisCacheConfiguration serializeValuesWith(SerializationPair valueSer Assert.notNull(valueSerializationPair, "ValueSerializationPair must not be null"); - return new RedisCacheConfiguration(getTtlFunction(), getAllowCacheNullValues(), usePrefix(), getKeyPrefix(), - getKeySerializationPair(), valueSerializationPair, getConversionService()); + return new RedisCacheConfiguration(getTtlFunction(), getAllowCacheNullValues(), isTtiExpirationEnabled(), + usePrefix(), getKeyPrefix(), getKeySerializationPair(), valueSerializationPair, getConversionService()); } /** @@ -264,8 +296,8 @@ public RedisCacheConfiguration withConversionService(ConversionService conversio Assert.notNull(conversionService, "ConversionService must not be null"); - return new RedisCacheConfiguration(getTtlFunction(), getAllowCacheNullValues(), usePrefix(), getKeyPrefix(), - getKeySerializationPair(), getValueSerializationPair(), conversionService); + return new RedisCacheConfiguration(getTtlFunction(), getAllowCacheNullValues(), isTtiExpirationEnabled(), + usePrefix(), getKeyPrefix(), getKeySerializationPair(), getValueSerializationPair(), conversionService); } /** @@ -275,6 +307,19 @@ public boolean getAllowCacheNullValues() { return this.cacheNullValues; } + /** + * Determines whether {@literal time-to-idle (TTI) expiration} has been enabled for caching. + *

+ * Use {@link #enableTtiExpiration()} to opt-in and enable {@literal time-to-idle (TTI) expiration} for caching. + * + * @return {@literal true} if {@literal time-to-idle (TTI) expiration} was configured and enabled for caching. + * Defaults to {@literal false}. + * @see GETEX + */ + public boolean isTtiExpirationEnabled() { + return this.enableTtiExpiration; + } + /** * @return {@literal true} if cache keys need to be prefixed with the {@link #getKeyPrefixFor(String)} if present or * the default which resolves to {@link Cache#getName()}. diff --git a/src/main/java/org/springframework/data/redis/cache/RedisCacheWriter.java b/src/main/java/org/springframework/data/redis/cache/RedisCacheWriter.java index fb1adc6e46..4c54186e21 100644 --- a/src/main/java/org/springframework/data/redis/cache/RedisCacheWriter.java +++ b/src/main/java/org/springframework/data/redis/cache/RedisCacheWriter.java @@ -85,7 +85,9 @@ static RedisCacheWriter lockingRedisCacheWriter(RedisConnectionFactory connectio */ static RedisCacheWriter lockingRedisCacheWriter(RedisConnectionFactory connectionFactory, BatchStrategy batchStrategy) { - return lockingRedisCacheWriter(connectionFactory, Duration.ofMillis(50), TtlFunction.persistent(), batchStrategy); + + return lockingRedisCacheWriter(connectionFactory, Duration.ofMillis(50), TtlFunction.persistent(), + batchStrategy); } /** @@ -104,29 +106,43 @@ static RedisCacheWriter lockingRedisCacheWriter(RedisConnectionFactory connectio Assert.notNull(connectionFactory, "ConnectionFactory must not be null"); - return new DefaultRedisCacheWriter(connectionFactory, sleepTime, lockTtlFunction, CacheStatisticsCollector.none(), - batchStrategy); + return new DefaultRedisCacheWriter(connectionFactory, sleepTime, lockTtlFunction, + CacheStatisticsCollector.none(), batchStrategy); } /** - * Write the given key/value pair to Redis and set the expiration time if defined. + * Get the binary value representation from Redis stored for the given key. * - * @param name The cache name must not be {@literal null}. - * @param key The key for the cache entry. Must not be {@literal null}. - * @param value The value stored for the key. Must not be {@literal null}. - * @param ttl Optional expiration time. Can be {@literal null}. + * @param name must not be {@literal null}. + * @param key must not be {@literal null}. + * @return {@literal null} if key does not exist. + * @see #get(String, byte[], Duration) */ - void put(String name, byte[] key, byte[] value, @Nullable Duration ttl); + @Nullable + default byte[] get(String name, byte[] key) { + return get(name, key, null); + } /** - * Get the binary value representation from Redis stored for the given key. + * Get the binary value representation from Redis stored for the given key and set the given + * {@link Duration TTL expiration} for the cache entry. * * @param name must not be {@literal null}. * @param key must not be {@literal null}. - * @return {@literal null} if key does not exist. + * @param ttl {@link Duration} specifying the {@literal expiration timeout} for the cache entry. + * @return {@literal null} if key does not exist or has {@literal expired}. */ - @Nullable - byte[] get(String name, byte[] key); + byte[] get(String name, byte[] key, @Nullable Duration ttl); + + /** + * Write the given key/value pair to Redis and set the expiration time if defined. + * + * @param name The cache name must not be {@literal null}. + * @param key The key for the cache entry. Must not be {@literal null}. + * @param value The value stored for the key. Must not be {@literal null}. + * @param ttl Optional expiration time. Can be {@literal null}. + */ + void put(String name, byte[] key, byte[] value, @Nullable Duration ttl); /** * Write the given value to Redis if the key does not already exist. diff --git a/src/test/java/org/springframework/data/redis/cache/RedisCacheTests.java b/src/test/java/org/springframework/data/redis/cache/RedisCacheTests.java index 326fc78a37..aa275a105f 100644 --- a/src/test/java/org/springframework/data/redis/cache/RedisCacheTests.java +++ b/src/test/java/org/springframework/data/redis/cache/RedisCacheTests.java @@ -455,13 +455,14 @@ void multipleThreadsLoadValueOnce() throws InterruptedException { AtomicReference 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); } @Override - public byte[] get(String name, byte[] key) { + public byte[] get(String name, byte[] key, @Nullable Duration ttl) { prepare.countDown(); try {