From dddf3530b9088fe6fb8a30e90be02b845041241f 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 --- src/main/asciidoc/reference/redis-cache.adoc | 159 ++++++++++++++++-- .../redis/cache/DefaultRedisCacheWriter.java | 9 +- .../data/redis/cache/RedisCache.java | 17 +- .../redis/cache/RedisCacheConfiguration.java | 81 +++++++-- .../data/redis/cache/RedisCacheManager.java | 60 +++---- .../data/redis/cache/RedisCacheWriter.java | 43 +++-- .../data/redis/core/types/Expiration.java | 21 +++ .../DefaultRedisCacheWriterUnitTests.java | 99 +++++++++++ .../RedisCacheConfigurationUnitTests.java | 18 +- .../data/redis/cache/RedisCacheTests.java | 83 ++++++++- .../cache/RedisCacheWriterUnitTests.java | 59 +++++++ .../redis/core/types/ExpirationUnitTests.java | 41 ++++- 12 files changed, 600 insertions(+), 90 deletions(-) create mode 100644 src/test/java/org/springframework/data/redis/cache/DefaultRedisCacheWriterUnitTests.java create mode 100644 src/test/java/org/springframework/data/redis/cache/RedisCacheWriterUnitTests.java diff --git a/src/main/asciidoc/reference/redis-cache.adoc b/src/main/asciidoc/reference/redis-cache.adoc index 0ce34e1fc..d0011a04e 100644 --- a/src/main/asciidoc/reference/redis-cache.adoc +++ b/src/main/asciidoc/reference/redis-cache.adoc @@ -3,7 +3,7 @@ NOTE: Changed in 2.0 -Spring Redis provides an implementation for the Spring {spring-framework-reference}/integration.html#cache[cache abstraction] through the `org.springframework.data.redis.cache` package. To use Redis as a backing implementation, add `RedisCacheManager` to your configuration, as follows: +Spring Data Redis provides an implementation of Spring Framework's {spring-framework-reference}/integration.html#cache[Cache Abstraction] in the `org.springframework.data.redis.cache` package. To use Redis as a backing implementation, add `RedisCacheManager` to your configuration, as follows: [source,java] ---- @@ -17,27 +17,28 @@ public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) [source,java] ---- -RedisCacheManager cm = RedisCacheManager.builder(connectionFactory) - .cacheDefaults(defaultCacheConfig()) - .withInitialCacheConfigurations(singletonMap("predefined", defaultCacheConfig().disableCachingNullValues())) +RedisCacheManager cacheManager = RedisCacheManager.builder(connectionFactory) + .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()) .transactionAware() + .withInitialCacheConfigurations(Collections.singletonMap("predefined", + RedisCacheConfiguration.defaultCacheConfig().disableCachingNullValues())) .build(); ---- -As shown in the preceding example, `RedisCacheManager` allows definition of configurations on a per-cache basis. +As shown in the preceding example, `RedisCacheManager` allows custom configuration on a per-cache basis. -The behavior of `RedisCache` created with `RedisCacheManager` is defined with `RedisCacheConfiguration`. The configuration lets you set key expiration times, prefixes, and `RedisSerializer` implementations for converting to and from the binary storage format, as shown in the following example: +The behavior of `RedisCache` created by `RedisCacheManager` is defined with `RedisCacheConfiguration`. The configuration lets you set key expiration times, prefixes, and `RedisSerializer` implementations for converting to and from the binary storage format, as shown in the following example: [source,java] ---- -RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() +RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofSeconds(1)) .disableCachingNullValues(); ---- `RedisCacheManager` defaults to a lock-free `RedisCacheWriter` for reading and writing binary values. Lock-free caching improves throughput. -The lack of entry locking can lead to overlapping, non-atomic commands for the `putIfAbsent` and `clean` methods, as those require multiple commands to be sent to Redis. The locking counterpart prevents command overlap by setting an explicit lock key and checking against presence of this key, which leads to additional requests and potential command wait times. +The lack of entry locking can lead to overlapping, non-atomic commands for the `Cache` `putIfAbsent` and `clean` operations, as those require multiple commands to be sent to Redis. The locking counterpart prevents command overlap by setting an explicit lock key and checking against presence of this key, which leads to additional requests and potential command wait times. Locking applies on the *cache level*, not per *cache entry*. @@ -45,12 +46,13 @@ It is possible to opt in to the locking behavior as follows: [source,java] ---- -RedisCacheManager cm = RedisCacheManager.build(RedisCacheWriter.lockingRedisCacheWriter(connectionFactory)) - .cacheDefaults(defaultCacheConfig()) +RedisCacheManager cacheMangager = RedisCacheManager + .build(RedisCacheWriter.lockingRedisCacheWriter(connectionFactory)) + .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()) ... ---- -By default, any `key` for a cache entry gets prefixed with the actual cache name followed by two colons. +By default, any `key` for a cache entry gets prefixed with the actual cache name followed by two colons (`::`). This behavior can be changed to a static as well as a computed prefix. The following example shows how to set a static prefix: @@ -58,20 +60,22 @@ The following example shows how to set a static prefix: [source,java] ---- // static key prefix -RedisCacheConfiguration.defaultCacheConfig().prefixKeysWith("( ͡° ᴥ ͡°)"); +RedisCacheConfiguration.defaultCacheConfig().prefixKeysWith("(͡° ᴥ ͡°)"); The following example shows how to set a computed prefix: // computed key prefix -RedisCacheConfiguration.defaultCacheConfig().computePrefixWith(cacheName -> "¯\_(ツ)_/¯" + cacheName); +RedisCacheConfiguration.defaultCacheConfig() + .computePrefixWith(cacheName -> "¯\_(ツ)_/¯" + cacheName); ---- -The cache implementation defaults to use `KEYS` and `DEL` to clear the cache. `KEYS` can cause performance issues with large keyspaces. Therefore, the default `RedisCacheWriter` can be created with a `BatchStrategy` to switch to a `SCAN`-based batch strategy. The `SCAN` strategy requires a batch size to avoid excessive Redis command roundtrips: +The cache implementation defaults to use `KEYS` and `DEL` to clear the cache. `KEYS` can cause performance issues with large keyspaces. Therefore, the default `RedisCacheWriter` can be created with a `BatchStrategy` to switch to a `SCAN`-based batch strategy. The `SCAN` strategy requires a batch size to avoid excessive Redis command round trips: [source,java] ---- -RedisCacheManager cm = RedisCacheManager.build(RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory, BatchStrategies.scan(1000))) - .cacheDefaults(defaultCacheConfig()) +RedisCacheManager cacheManager = RedisCacheManager + .build(RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory, BatchStrategies.scan(1000))) + .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()) ... ---- @@ -130,3 +134,126 @@ The following table lists the default settings for `RedisCacheConfiguration`: By default `RedisCache`, statistics are disabled. Use `RedisCacheManagerBuilder.enableStatistics()` to collect local _hits_ and _misses_ through `RedisCache#getStatistics()`, returning a snapshot of the collected data. ==== + +[[redis:support:cache-abstraction:expiration]] +== Redis Cache Expiration + +Spring Data Redis's `Cache` implementation supports _time-to-live_ (TTL) expiration on cache entries. Users can either configure the TTL expiration timeout with a fixed `Duration` or a dynamically computed `Duration` per cache entry by supplying an implementation of the new `RedisCacheWriter.TtlFunction` interface. + +> TIP: The `RedisCacheWriter.TtlFunction` interface was introduced in Spring Data Redis `3.2.0`. + +If all cache entries should expire after a set duration of time, then simply configure a TTL expiration timeout with a fixed `Duration`, as follows: + +[source,java] +---- +RedisCacheConfiguration fiveMinuteTtlExpirationCacheConfiguration = + RedisCacheConfiguration.defaultCacheConfig().enableTtl(Duration.ofMinutes(5)); +---- + +However, if the TTL expiration timeout should vary by cache entry, then you must provide a custom implementation of the `RedisCacheWriter.TtlFunction` interface: + +[source,java] +---- +class MyCustomTtlFunction implements TtlFunction { + + static final MyCustomTtlFunction INSTANCE = new MyCustomTtlFunction(); + + public Duration getTimeToLive(Object key, @Nullable Object value) { + // compute a TTL expiration timeout (Duration) based on the cache entry key and/or value + } +} +---- + +> NOTE: Under-the-hood, a fixed `Duration` TTL expiration is wrapped in a `TtlFunction` implementation returning the provided `Duration`. + +Then, you can either configure the fixed `Duration` or the dynamic, per-cache entry `Duration` TTL expiration on a global basis using: + +.Global fixed Duration TTL expiration timeout +[source,java] +---- +RedisCacheManager cacheManager = RedisCacheManager.builder(redisConnectionFactory) + .cacheDefaults(fiveMinuteTtlExpirationCacheConfiguration) + .build(); +---- + +Or, alternatively: + +.Global, dynamically computed per-cache entry Duration TTL expiration timeout +[source,java] +---- +RedisCacheManager cacheManager = RedisCacheManager.builder(redisConnectionFactory) + .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(MyCustomTtlFunction.INSTANCE)) + .build(); +---- + +> WARNING: If you try to set both a fixed `Duration` and dynamic, per-cache entry `Duration` TTL expiration using a custom `TtlFunction`, then last one wins! + +Of course, you can combine both global and per-cache configuration using: + +.Global fixed Duration TTL expiration timeout +[source,java] +---- +RedisCacheManager cacheManager = RedisCacheManager.builder(redisConnectionFactory) + .cacheDefaults(fiveMinuteTtlExpirationCacheConfiguration) + .withInitialCacheConfiguration(Collections.singletonMap("predefined", + RedisCacheConfiguration.defaultCacheConfig().entryTtl(MyCustomTtlFunction.INSTANCE))) + .build(); +---- + +[[redis:support:cache-abstraction:expiration:tti]] +=== Time-To-Idle (TTI) Expiration + +Redis itself does not support the concept of true, time-to-idle (TTI) expiration. Even across different data stores, the implementation of time-to-idle (TTI) as well as time-to-live (TTL) varies in definition and behavior. + +In general: + +* _time-to-live_ (TTL) _expiration_ - TTL is only set and reset by a create or update data access operation. As long as the entry is written before the TTL expiration timeout, including on creation, an entry's timeout will reset to the configured duration of the TTL expiration timeout. For example, if the TTL expiration timeout is set to 5 minutes, then the timeout will be set to 5 minutes on entry creation and reset to 5 minutes anytime the entry is updated thereafter and before the 5-minute interval expires. If no update occurs within 5 minutes, even if the entry was read several times, or even just read once during the 5-minute interval, the entry will still expire. The entry must be written to prevent the entry from expiring when declaring a TTL expiration policy. + +* _time-to-idle_ (TTI) _expiration_ - TTI is reset anytime the entry is also read as well as for entry updates, and is effectively and extension to the TTL expiration policy. + +> NOTE: Some data stores expire an entry when TTL is configured no matter what type of data access operation occurs on the entry (reads, writes, or otherwise). After the set, configured TTL expiration timeout, the entry is evicted from the data store regardless. Eviction actions (for example: destroy, invalidate, overflow-to-disk (for persistent stores), etc.) are data store specific. + +Using Spring Data Redis's Cache implementation, it is possible to achieve time-to-idle (TTI) expiration-like behavior. + +The configuration of TTI in Spring Data Redis's Cache implementation must be explicitly enabled, that is, is opt-in. Additionally, you must also provide TTL configuration using either a fixed `Duration` or a custom implementation of the `TtlFunction` interface as described above in <>. + +For example: + +[source,java] +---- +@Configuration +@EnableCaching +class RedisConfiguration { + + @Bean + RedisConnectionFactory redisConnectionFactory() { + // ... + } + + @Bean + RedisCacheConfiguration redisCacheConfiguration() { + + return RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(5)) + .enableTimeToIdle(); + } + + @Bean + RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory, + RedisCacheConfiguraton cacheConfiguraton) { + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(cacheConfiguration) + .build(); + } +} +---- + +Because Redis servers do not implement a proper notion of TTI, then TTI can only be achieved with Redis commands accepting expiration options. In Redis, the "expiration" is technically a time-to-live (TTL) policy. However, TTL expiration can be passed when reading the value of a key thereby effectively resetting the TTL expiration timeout, as is now the case in Spring Data Redis's `Cache.get(key)` operation. + +`RedisCache.get(key)` is implemented by calling the Redis `GETEX` command. + +> WARNING: The Redis https://redis.io/commands/getex[`GETEX`] command is only available in Redis version `6.2.0` and later. Therefore, if you are not using Redis `6.2.0` or later, then it is not possible to use Spring Data Redis's TTI expiration. A command execution exception will be thrown if you enable TTI against an incompatible Redis (server) version. No attempt is made to determine if the Redis server version is correct and supports the `GETEX` command. + +> WARNING: In order to achieve true time-to-idle (TTI) expiration-like behavior in your Spring Data Redis application, then an entry must be consistently accessed with (TTL) expiration on every read or write operation. There are no exceptions to this rule. If you are mixing and matching different data access patterns across your Spring Data Redis application (for example: caching, invoking operations using `RedisTemplate` and possibly, or especially when using Spring Data Repository CRUD operations), then accessing an entry may not necessarily prevent the entry from expiring if TTL expiration was set. For example, an entry maybe "put" in (written to) the cache during a `@Cacheable` service method invocation with a TTL expiration (i.e. `SET `) and later read using a Spring Data Redis Repository before the expiration timeout (using `GET` without expiration options). A simple `GET` without specifying expiration options will not reset the TTL expiration timeout on an entry. Therefore, the entry may expire before the next data access operation, even though it was just read. Since this cannot be enforced in the Redis server, then it is the responsibility of your application to consistently access an entry when time-to-idle expiration is configured, in and outside of caching, where appropriate. 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 226184842..36121249d 100644 --- a/src/main/java/org/springframework/data/redis/cache/DefaultRedisCacheWriter.java +++ b/src/main/java/org/springframework/data/redis/cache/DefaultRedisCacheWriter.java @@ -125,11 +125,18 @@ class DefaultRedisCacheWriter implements RedisCacheWriter { @Override public byte[] get(String name, byte[] key) { + return get(name, key, null); + } + + @Override + 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 41982b67b..476e7da1d 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 @@ package org.springframework.data.redis.cache; 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 @@ public class RedisCache extends AbstractValueAdaptingCache { @Override protected Object lookup(Object key) { - byte[] value = getCacheWriter().get(getName(), createAndConvertCacheKey(key)); + byte[] value = getCacheConfiguration().isTimeToIdleEnabled() + ? 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 class RedisCache extends AbstractValueAdaptingCache { } getCacheWriter().put(getName(), createAndConvertCacheKey(key), serializeCacheValue(cacheValue), - getCacheConfiguration().getTtlFunction().getTimeToLive(key, value)); + getTimeToLive(key, value)); } @Override @@ -221,7 +232,7 @@ public class RedisCache extends AbstractValueAdaptingCache { } 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 cdae34f7c..8870261e1 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 @@ import org.springframework.util.Assert; public class RedisCacheConfiguration { protected static final boolean DEFAULT_CACHE_NULL_VALUES = true; + protected static final boolean DEFAULT_ENABLE_TIME_TO_IDLE_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 USE_TIME_TO_IDLE_EXPIRATION = true; /** * Default {@link RedisCacheConfiguration} using the following: @@ -108,7 +110,10 @@ public class RedisCacheConfiguration { 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_TIME_TO_IDLE_EXPIRATION, + DEFAULT_USE_PREFIX, CacheKeyPrefix.simple(), SerializationPair.fromSerializer(RedisSerializer.string()), SerializationPair.fromSerializer(RedisSerializer.java(classLoader)), @@ -116,6 +121,7 @@ public class RedisCacheConfiguration { } private final boolean cacheNullValues; + private final boolean enableTimeToIdle; private final boolean usePrefix; private final CacheKeyPrefix keyPrefix; @@ -128,12 +134,13 @@ public class RedisCacheConfiguration { 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 enableTimeToIdle, + Boolean usePrefix, CacheKeyPrefix keyPrefix, SerializationPair keySerializationPair, SerializationPair valueSerializationPair, ConversionService conversionService) { this.ttlFunction = ttlFunction; this.cacheNullValues = cacheNullValues; + this.enableTimeToIdle = enableTimeToIdle; this.usePrefix = usePrefix; this.keyPrefix = keyPrefix; this.keySerializationPair = keySerializationPair; @@ -168,8 +175,9 @@ public class RedisCacheConfiguration { 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(), isTimeToIdleEnabled(), + DEFAULT_USE_PREFIX, cacheKeyPrefix, getKeySerializationPair(), getValueSerializationPair(), + getConversionService()); } /** @@ -181,8 +189,9 @@ public class RedisCacheConfiguration { * @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, isTimeToIdleEnabled(), + usePrefix(), getKeyPrefix(), getKeySerializationPair(), getValueSerializationPair(), + getConversionService()); } /** @@ -193,8 +202,31 @@ public class RedisCacheConfiguration { * @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(), isTimeToIdleEnabled(), + 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 + * @since 3.2.0 + */ + public RedisCacheConfiguration enableTimeToIdle() { + return new RedisCacheConfiguration(getTtlFunction(), getAllowCacheNullValues(), USE_TIME_TO_IDLE_EXPIRATION, + usePrefix(), getKeyPrefix(), getKeySerializationPair(), getValueSerializationPair(), + getConversionService()); } /** @@ -222,8 +254,9 @@ public class RedisCacheConfiguration { Assert.notNull(ttlFunction, "TtlFunction must not be null"); - return new RedisCacheConfiguration(ttlFunction, getAllowCacheNullValues(), usePrefix(), getKeyPrefix(), - getKeySerializationPair(), getValueSerializationPair(), getConversionService()); + return new RedisCacheConfiguration(ttlFunction, getAllowCacheNullValues(), isTimeToIdleEnabled(), + usePrefix(), getKeyPrefix(), getKeySerializationPair(), getValueSerializationPair(), + getConversionService()); } /** @@ -236,8 +269,8 @@ public class RedisCacheConfiguration { Assert.notNull(keySerializationPair, "KeySerializationPair must not be null"); - return new RedisCacheConfiguration(getTtlFunction(), getAllowCacheNullValues(), usePrefix(), getKeyPrefix(), - keySerializationPair, getValueSerializationPair(), getConversionService()); + return new RedisCacheConfiguration(getTtlFunction(), getAllowCacheNullValues(), isTimeToIdleEnabled(), + usePrefix(), getKeyPrefix(), keySerializationPair, getValueSerializationPair(), getConversionService()); } /** @@ -250,8 +283,8 @@ public class RedisCacheConfiguration { Assert.notNull(valueSerializationPair, "ValueSerializationPair must not be null"); - return new RedisCacheConfiguration(getTtlFunction(), getAllowCacheNullValues(), usePrefix(), getKeyPrefix(), - getKeySerializationPair(), valueSerializationPair, getConversionService()); + return new RedisCacheConfiguration(getTtlFunction(), getAllowCacheNullValues(), isTimeToIdleEnabled(), + usePrefix(), getKeyPrefix(), getKeySerializationPair(), valueSerializationPair, getConversionService()); } /** @@ -264,8 +297,8 @@ public class RedisCacheConfiguration { Assert.notNull(conversionService, "ConversionService must not be null"); - return new RedisCacheConfiguration(getTtlFunction(), getAllowCacheNullValues(), usePrefix(), getKeyPrefix(), - getKeySerializationPair(), getValueSerializationPair(), conversionService); + return new RedisCacheConfiguration(getTtlFunction(), getAllowCacheNullValues(), isTimeToIdleEnabled(), + usePrefix(), getKeyPrefix(), getKeySerializationPair(), getValueSerializationPair(), conversionService); } /** @@ -275,6 +308,20 @@ public class RedisCacheConfiguration { return this.cacheNullValues; } + /** + * Determines whether {@literal time-to-idle (TTI) expiration} has been enabled for caching. + *

+ * Use {@link #enableTimeToIdle()} 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 + * @since 3.2.0 + */ + public boolean isTimeToIdleEnabled() { + return this.enableTimeToIdle; + } + /** * @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/RedisCacheManager.java b/src/main/java/org/springframework/data/redis/cache/RedisCacheManager.java index 101ef98e3..9f0d42fa3 100644 --- a/src/main/java/org/springframework/data/redis/cache/RedisCacheManager.java +++ b/src/main/java/org/springframework/data/redis/cache/RedisCacheManager.java @@ -22,7 +22,6 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.stream.Collectors; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; @@ -67,10 +66,10 @@ public class RedisCacheManager extends AbstractTransactionSupportingCacheManager /** * Factory method returning a {@literal Builder} used to construct and configure a {@link RedisCacheManager} - * using the given {@link RedisCacheWriter}. + * initialized with the given {@link RedisCacheWriter}. * - * @param cacheWriter {@link RedisCacheWriter} used to perform {@link RedisCache} operations by executing - * appropriate Redis commands; must not be {@literal null}. + * @param cacheWriter {@link RedisCacheWriter} used to perform {@link RedisCache} operations + * by executing appropriate Redis commands; must not be {@literal null}. * @return new {@link RedisCacheManagerBuilder}. * @throws IllegalArgumentException if the given {@link RedisCacheWriter} is {@literal null}. * @see org.springframework.data.redis.cache.RedisCacheWriter @@ -84,7 +83,7 @@ public class RedisCacheManager extends AbstractTransactionSupportingCacheManager /** * Factory method returning a {@literal Builder} used to construct and configure a {@link RedisCacheManager} - * using the given {@link RedisConnectionFactory}. + * initialized with the given {@link RedisConnectionFactory}. * * @param connectionFactory {@link RedisConnectionFactory} used by the {@link RedisCacheManager} * to acquire connections to Redis when performing {@link RedisCache} operations; must not be {@literal null}. @@ -100,8 +99,8 @@ public class RedisCacheManager extends AbstractTransactionSupportingCacheManager } /** - * Factory method used to construct a new {@link RedisCacheManager} using the given {@link RedisConnectionFactory} - * with caching defaults applied. + * Factory method used to construct a new {@link RedisCacheManager} initialized with + * the given {@link RedisConnectionFactory} and using the defaults for caching. * *

*
locking
@@ -143,8 +142,8 @@ public class RedisCacheManager extends AbstractTransactionSupportingCacheManager /** * Creates a new {@link RedisCacheManager} initialized with the given {@link RedisCacheWriter} * and a default {@link RedisCacheConfiguration}. - * - * Allows cache creation at runtime. + *

+ * Allows {@link RedisCache cache} creation at runtime. * * @param cacheWriter {@link RedisCacheWriter} used to perform {@link RedisCache} operations * by executing appropriate Redis commands; must not be {@literal null}. @@ -161,13 +160,13 @@ public class RedisCacheManager extends AbstractTransactionSupportingCacheManager /** * Creates a new {@link RedisCacheManager} initialized with the given {@link RedisCacheWriter} - * and a default {@link RedisCacheConfiguration}, and whether to allow cache creation at runtime. + * and default {@link RedisCacheConfiguration}, and whether to allow cache creation at runtime. * * @param cacheWriter {@link RedisCacheWriter} used to perform {@link RedisCache} operations * by executing appropriate Redis commands; must not be {@literal null}. * @param defaultCacheConfiguration {@link RedisCacheConfiguration} applied to new {@link RedisCache Redis caches} * by default when no cache-specific {@link RedisCacheConfiguration} is provided; must not be {@literal null}. - * @param allowRuntimeCacheCreation boolean to allow creation of undeclared caches at runtime; + * @param allowRuntimeCacheCreation boolean specifying whether to allow creation of undeclared caches at runtime; * {@literal true} by default. Maybe just use {@link RedisCacheConfiguration#defaultCacheConfig()}. * @throws IllegalArgumentException if either the given {@link RedisCacheWriter} or {@link RedisCacheConfiguration} * are {@literal null}. @@ -190,8 +189,8 @@ public class RedisCacheManager extends AbstractTransactionSupportingCacheManager * Creates a new {@link RedisCacheManager} initialized with the given {@link RedisCacheWriter} * and a default {@link RedisCacheConfiguration}, along with an optional, initial set of {@link String cache names} * used to create {@link RedisCache Redis caches} on startup. - * - * Allows cache creation at runtime. + *

+ * Allows {@link RedisCache cache} creation at runtime. * * @param cacheWriter {@link RedisCacheWriter} used to perform {@link RedisCache} operations * by executing appropriate Redis commands; must not be {@literal null}. @@ -213,15 +212,15 @@ public class RedisCacheManager extends AbstractTransactionSupportingCacheManager /** * Creates a new {@link RedisCacheManager} initialized with the given {@link RedisCacheWriter} * and default {@link RedisCacheConfiguration}, and whether to allow cache creation at runtime. - * - * Additionally, the optional, initial set of {@link String cache names} witll be used to create + *

+ * Additionally, the optional, initial set of {@link String cache names} will be used to create * {@link RedisCache Redis caches} on startup. * * @param cacheWriter {@link RedisCacheWriter} used to perform {@link RedisCache} operations * by executing appropriate Redis commands; must not be {@literal null}. * @param defaultCacheConfiguration {@link RedisCacheConfiguration} applied to new {@link RedisCache Redis caches} * by default when no cache-specific {@link RedisCacheConfiguration} is provided; must not be {@literal null}. - * @param allowRuntimeCacheCreation boolean to allow creation of undeclared caches at runtime; + * @param allowRuntimeCacheCreation boolean specifying whether to allow creation of undeclared caches at runtime; * {@literal true} by default. Maybe just use {@link RedisCacheConfiguration#defaultCacheConfig()}. * @param initialCacheNames optional set of {@link String cache names} used to create {@link RedisCache Redis caches} * on startup. The default {@link RedisCacheConfiguration} will be applied to each cache. @@ -244,11 +243,11 @@ public class RedisCacheManager extends AbstractTransactionSupportingCacheManager /** * Creates new {@link RedisCacheManager} using given {@link RedisCacheWriter} and default * {@link RedisCacheConfiguration}. - * + *

* Additionally, an initial {@link RedisCache} will be created and configured using the associated * {@link RedisCacheConfiguration} for each {@link String named} {@link RedisCache} in the given {@link Map}. - * - * Allows cache creation at runtime. + *

+ * Allows {@link RedisCache cache} creation at runtime. * * @param cacheWriter {@link RedisCacheWriter} used to perform {@link RedisCache} operations * by executing appropriate Redis commands; must not be {@literal null}. @@ -271,7 +270,7 @@ public class RedisCacheManager extends AbstractTransactionSupportingCacheManager /** * Creates a new {@link RedisCacheManager} initialized with the given {@link RedisCacheWriter} * and a default {@link RedisCacheConfiguration}, and whether to allow {@link RedisCache} creation at runtime. - * + *

* Additionally, an initial {@link RedisCache} will be created and configured using the associated * {@link RedisCacheConfiguration} for each {@link String named} {@link RedisCache} in the given {@link Map}. * @@ -279,11 +278,11 @@ public class RedisCacheManager extends AbstractTransactionSupportingCacheManager * by executing appropriate Redis commands; must not be {@literal null}. * @param defaultCacheConfiguration {@link RedisCacheConfiguration} applied to new {@link RedisCache Redis caches} * by default when no cache-specific {@link RedisCacheConfiguration} is provided; must not be {@literal null}. - * @param allowRuntimeCacheCreation boolean to allow creation of undeclared caches at runtime; + * @param allowRuntimeCacheCreation boolean specifying whether to allow creation of undeclared caches at runtime; * {@literal true} by default. Maybe just use {@link RedisCacheConfiguration#defaultCacheConfig()}. - * @param initialCacheConfigurations {@link Map} of declared, known {@link String cache names} along with associated - * {@link RedisCacheConfiguration} used to create and configure {@link RedisCache Redis caches} on startup; - * must not be {@literal null}. + * @param initialCacheConfigurations {@link Map} of declared, known {@link String cache names} along with + * the associated {@link RedisCacheConfiguration} used to create and configure {@link RedisCache Redis caches} + * on startup; must not be {@literal null}. * @throws IllegalArgumentException if either the given {@link RedisCacheWriter} or {@link RedisCacheConfiguration} * are {@literal null}. * @see org.springframework.data.redis.cache.RedisCacheConfiguration @@ -333,7 +332,8 @@ public class RedisCacheManager extends AbstractTransactionSupportingCacheManager getCacheNames().forEach(cacheName -> { RedisCache cache = (RedisCache) lookupCache(cacheName); - cacheConfigurationMap.put(cacheName, cache != null ? cache.getCacheConfiguration() : null); + RedisCacheConfiguration cacheConfiguration = cache != null ? cache.getCacheConfiguration() : null; + cacheConfigurationMap.put(cacheName, cacheConfiguration); }); return Collections.unmodifiableMap(cacheConfigurationMap); @@ -381,7 +381,7 @@ public class RedisCacheManager extends AbstractTransactionSupportingCacheManager return getInitialCacheConfiguration().entrySet().stream() .map(entry -> createRedisCache(entry.getKey(), entry.getValue())) - .collect(Collectors.toList()); + .toList(); } private RedisCacheConfiguration resolveCacheConfiguration(@Nullable RedisCacheConfiguration cacheConfiguration) { @@ -462,10 +462,10 @@ public class RedisCacheManager extends AbstractTransactionSupportingCacheManager } /** - * Disable {@link RedisCache} creation at runtime for unconfigured, undeclared caches. - * - * {@link RedisCacheManager#getMissingCache(String)} returns {@literal null} for any unconfigured {@link Cache} - * instead of a new {@link RedisCache} instance. This allows the + * Disable {@link RedisCache} creation at runtime for non-configured, undeclared caches. + *

+ * {@link RedisCacheManager#getMissingCache(String)} returns {@literal null} for any non-configured, undeclared + * {@link Cache} instead of a new {@link RedisCache} instance. This allows the * {@link org.springframework.cache.support.CompositeCacheManager} to participate. * * @return this {@link RedisCacheManagerBuilder}. 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 fb1adc6e4..3ad842678 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 @@ public interface RedisCacheWriter extends CacheStatisticsProvider { */ 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,8 +106,33 @@ public interface RedisCacheWriter extends CacheStatisticsProvider { 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); + } + + /** + * Get the binary value representation from Redis stored for the given key. + * + * @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) + */ + @Nullable + byte[] get(String name, byte[] 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}. + * @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 + default byte[] get(String name, byte[] key, @Nullable Duration ttl) { + return get(name, key); } /** @@ -118,16 +145,6 @@ public interface RedisCacheWriter extends CacheStatisticsProvider { */ void put(String name, byte[] key, byte[] value, @Nullable Duration ttl); - /** - * Get the binary value representation from Redis stored for the given key. - * - * @param name must not be {@literal null}. - * @param key must not be {@literal null}. - * @return {@literal null} if key does not exist. - */ - @Nullable - byte[] get(String name, byte[] key); - /** * Write the given value to Redis if the key does not already exist. * diff --git a/src/main/java/org/springframework/data/redis/core/types/Expiration.java b/src/main/java/org/springframework/data/redis/core/types/Expiration.java index f21b46f38..c5df3ae8f 100644 --- a/src/main/java/org/springframework/data/redis/core/types/Expiration.java +++ b/src/main/java/org/springframework/data/redis/core/types/Expiration.java @@ -16,10 +16,12 @@ package org.springframework.data.redis.core.types; import java.time.Duration; +import java.util.Objects; import java.util.concurrent.TimeUnit; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; /** * {@link Expiration} holds a {@link Long numeric value} with an associated {@link TimeUnit}. @@ -219,6 +221,25 @@ public class Expiration { return false; } + @Override + public boolean equals(Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof Expiration that)) { + return false; + } + + return this.getTimeUnit().toMillis(getExpirationTime()) == that.getTimeUnit().toMillis(that.getExpirationTime()); + } + + @Override + public int hashCode() { + return ObjectUtils.nullSafeHashCode(new Object[] { getExpirationTime(), getTimeUnit() }); + } + /** * @author Christoph Strobl * @since 2.4 diff --git a/src/test/java/org/springframework/data/redis/cache/DefaultRedisCacheWriterUnitTests.java b/src/test/java/org/springframework/data/redis/cache/DefaultRedisCacheWriterUnitTests.java new file mode 100644 index 000000000..47f79b771 --- /dev/null +++ b/src/test/java/org/springframework/data/redis/cache/DefaultRedisCacheWriterUnitTests.java @@ -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); + } +} diff --git a/src/test/java/org/springframework/data/redis/cache/RedisCacheConfigurationUnitTests.java b/src/test/java/org/springframework/data/redis/cache/RedisCacheConfigurationUnitTests.java index 4f54eb144..62ac41b05 100644 --- a/src/test/java/org/springframework/data/redis/cache/RedisCacheConfigurationUnitTests.java +++ b/src/test/java/org/springframework/data/redis/cache/RedisCacheConfigurationUnitTests.java @@ -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 { } 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 326fc78a3..dc8da3695 100644 --- a/src/test/java/org/springframework/data/redis/cache/RedisCacheTests.java +++ b/src/test/java/org/springframework/data/redis/cache/RedisCacheTests.java @@ -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 customizer) { + + return customizer.apply(RedisCacheConfiguration.defaultCacheConfig() + .serializeValuesWith(SerializationPair.fromSerializer(this.serializer))); } @ParameterizedRedisTest // DATAREDIS-481 @@ -455,6 +474,7 @@ public class RedisCacheTests { 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); @@ -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 withTtiExpiration() { + + Function entryTtlFunction = + cacheConfiguration -> cacheConfiguration.entryTtl(Duration.ofMillis(100)); + + return entryTtlFunction.andThen(RedisCacheConfiguration::enableTimeToIdle); + } + void doWithConnection(Consumer callback) { RedisConnection connection = connectionFactory.getConnection(); try { diff --git a/src/test/java/org/springframework/data/redis/cache/RedisCacheWriterUnitTests.java b/src/test/java/org/springframework/data/redis/cache/RedisCacheWriterUnitTests.java new file mode 100644 index 000000000..eb563e791 --- /dev/null +++ b/src/test/java/org/springframework/data/redis/cache/RedisCacheWriterUnitTests.java @@ -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); + } +} diff --git a/src/test/java/org/springframework/data/redis/core/types/ExpirationUnitTests.java b/src/test/java/org/springframework/data/redis/core/types/ExpirationUnitTests.java index e2098d8a1..bd4773dc7 100644 --- a/src/test/java/org/springframework/data/redis/core/types/ExpirationUnitTests.java +++ b/src/test/java/org/springframework/data/redis/core/types/ExpirationUnitTests.java @@ -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)); + } }