Skip to content

Commit 9aa2eb4

Browse files
mp911dejxblum
authored andcommitted
Allow configuration of cache lock TTL.
RedisCacheWriter now can now issue locks that expire using TtlFunction to prevent eternal locks. Closes spring-projects#2300 Pull request: spring-projects#2597
1 parent 14571a5 commit 9aa2eb4

File tree

7 files changed

+196
-37
lines changed

7 files changed

+196
-37
lines changed

src/main/java/org/springframework/data/redis/cache/DefaultRedisCacheWriter.java

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ class DefaultRedisCacheWriter implements RedisCacheWriter {
5050

5151
private final RedisConnectionFactory connectionFactory;
5252
private final Duration sleepTime;
53+
54+
private final TtlFunction lockTtl;
5355
private final CacheStatisticsCollector statistics;
5456
private final BatchStrategy batchStrategy;
5557

@@ -68,26 +70,29 @@ class DefaultRedisCacheWriter implements RedisCacheWriter {
6870
* @param batchStrategy must not be {@literal null}.
6971
*/
7072
DefaultRedisCacheWriter(RedisConnectionFactory connectionFactory, Duration sleepTime, BatchStrategy batchStrategy) {
71-
this(connectionFactory, sleepTime, CacheStatisticsCollector.none(), batchStrategy);
73+
this(connectionFactory, sleepTime, TtlFunction.persistent(), CacheStatisticsCollector.none(), batchStrategy);
7274
}
7375

7476
/**
7577
* @param connectionFactory must not be {@literal null}.
7678
* @param sleepTime sleep time between lock request attempts. Must not be {@literal null}. Use {@link Duration#ZERO}
7779
* to disable locking.
80+
* @param lockTtl Lock TTL function must not be {@literal null}.
7881
* @param cacheStatisticsCollector must not be {@literal null}.
7982
* @param batchStrategy must not be {@literal null}.
8083
*/
81-
DefaultRedisCacheWriter(RedisConnectionFactory connectionFactory, Duration sleepTime,
84+
DefaultRedisCacheWriter(RedisConnectionFactory connectionFactory, Duration sleepTime, TtlFunction lockTtl,
8285
CacheStatisticsCollector cacheStatisticsCollector, BatchStrategy batchStrategy) {
8386

8487
Assert.notNull(connectionFactory, "ConnectionFactory must not be null");
8588
Assert.notNull(sleepTime, "SleepTime must not be null");
89+
Assert.notNull(lockTtl, "Lock TTL Function must not be null");
8690
Assert.notNull(cacheStatisticsCollector, "CacheStatisticsCollector must not be null");
8791
Assert.notNull(batchStrategy, "BatchStrategy must not be null");
8892

8993
this.connectionFactory = connectionFactory;
9094
this.sleepTime = sleepTime;
95+
this.lockTtl = lockTtl;
9196
this.statistics = cacheStatisticsCollector;
9297
this.batchStrategy = batchStrategy;
9398
}
@@ -142,7 +147,7 @@ public byte[] putIfAbsent(String name, byte[] key, byte[] value, @Nullable Durat
142147
return execute(name, connection -> {
143148

144149
if (isLockingCacheWriter()) {
145-
doLock(name, connection);
150+
doLock(name, key, value, connection);
146151
}
147152

148153
try {
@@ -193,7 +198,7 @@ public void clean(String name, byte[] pattern) {
193198
try {
194199

195200
if (isLockingCacheWriter()) {
196-
doLock(name, connection);
201+
doLock(name, name, pattern, connection);
197202
wasLocked = true;
198203
}
199204

@@ -227,7 +232,8 @@ public void clearStatistics(String name) {
227232

228233
@Override
229234
public RedisCacheWriter withStatisticsCollector(CacheStatisticsCollector cacheStatisticsCollector) {
230-
return new DefaultRedisCacheWriter(connectionFactory, sleepTime, cacheStatisticsCollector, this.batchStrategy);
235+
return new DefaultRedisCacheWriter(connectionFactory, sleepTime, lockTtl, cacheStatisticsCollector,
236+
this.batchStrategy);
231237
}
232238

233239
/**
@@ -236,7 +242,7 @@ public RedisCacheWriter withStatisticsCollector(CacheStatisticsCollector cacheSt
236242
* @param name the name of the cache to lock.
237243
*/
238244
void lock(String name) {
239-
execute(name, connection -> doLock(name, connection));
245+
execute(name, connection -> doLock(name, name, null, connection));
240246
}
241247

242248
/**
@@ -248,8 +254,12 @@ void unlock(String name) {
248254
executeLockFree(connection -> doUnlock(name, connection));
249255
}
250256

251-
private Boolean doLock(String name, RedisConnection connection) {
252-
return connection.setNX(createCacheLockKey(name), new byte[0]);
257+
private Boolean doLock(String name, Object contextualKey, Object contextualValue, RedisConnection connection) {
258+
259+
Expiration expiration = lockTtl == null ? Expiration.persistent()
260+
: Expiration.from(lockTtl.getTimeToLive(contextualKey, contextualValue));
261+
262+
return connection.set(createCacheLockKey(name), new byte[0], expiration, SetOption.SET_IF_ABSENT);
253263
}
254264

255265
private Long doUnlock(String name, RedisConnection connection) {

src/main/java/org/springframework/data/redis/cache/RedisCache.java

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,10 @@ public class RedisCache extends AbstractValueAdaptingCache {
6767
* Create a new {@link RedisCache}.
6868
*
6969
* @param name {@link String name} for this {@link Cache}; must not be {@literal null}.
70-
* @param cacheWriter {@link RedisCacheWriter} used to perform {@link RedisCache} operations
71-
* by executing appropriate Redis commands; must not be {@literal null}.
72-
* @param cacheConfiguration {@link RedisCacheConfiguration} applied to this {@link RedisCache on creation;
73-
* must not be {@literal null}.
70+
* @param cacheWriter {@link RedisCacheWriter} used to perform {@link RedisCache} operations by executing appropriate
71+
* Redis commands; must not be {@literal null}.
72+
* @param cacheConfiguration {@link RedisCacheConfiguration} applied to this {@link RedisCache on creation; must not
73+
* be {@literal null}.
7474
* @throws IllegalArgumentException if either the given {@link RedisCacheWriter} or {@link RedisCacheConfiguration}
7575
* are {@literal null} or the given {@link String} name for this {@link RedisCache} is {@literal null}.
7676
*/
@@ -87,7 +87,6 @@ protected RedisCache(String name, RedisCacheWriter cacheWriter, RedisCacheConfig
8787
this.cacheConfiguration = cacheConfiguration;
8888
}
8989

90-
9190
/**
9291
* Get {@link RedisCacheConfiguration} used.
9392
*
@@ -132,8 +131,7 @@ public <T> T get(Object key, Callable<T> valueLoader) {
132131

133132
ValueWrapper result = get(key);
134133

135-
return result != null ? (T) result.get()
136-
: getSynchronized(key, valueLoader);
134+
return result != null ? (T) result.get() : getSynchronized(key, valueLoader);
137135
}
138136

139137
@SuppressWarnings("unchecked")
@@ -142,8 +140,7 @@ private synchronized <T> T getSynchronized(Object key, Callable<T> valueLoader)
142140

143141
ValueWrapper result = get(key);
144142

145-
return result != null ? (T) result.get()
146-
: loadCacheValue(key, valueLoader);
143+
return result != null ? (T) result.get() : loadCacheValue(key, valueLoader);
147144
}
148145

149146
protected <T> T loadCacheValue(Object key, Callable<T> valueLoader) {
@@ -152,7 +149,8 @@ protected <T> T loadCacheValue(Object key, Callable<T> valueLoader) {
152149

153150
try {
154151
value = valueLoader.call();
155-
} catch (Exception cause) {
152+
}
153+
catch (Exception cause) {
156154
throw new ValueRetrievalException(key, valueLoader, cause);
157155
}
158156

@@ -177,9 +175,8 @@ public void put(Object key, @Nullable Object value) {
177175
if (!isAllowNullValues() && cacheValue == null) {
178176

179177
String message = String.format("Cache '%s' does not allow 'null' values; Avoid storing null"
180-
+ " via '@Cacheable(unless=\"#result == null\")' or configure RedisCache to allow 'null'"
181-
+ " via RedisCacheConfiguration",
182-
getName());
178+
+ " via '@Cacheable(unless=\"#result == null\")' or configure RedisCache to allow 'null'"
179+
+ " via RedisCacheConfiguration", getName());
183180

184181
throw new IllegalArgumentException(message);
185182
}
@@ -244,9 +241,7 @@ public void evict(Object key) {
244241
@Nullable
245242
protected Object preProcessCacheValue(@Nullable Object value) {
246243

247-
return value != null ? value
248-
: isAllowNullValues() ? NullValue.INSTANCE
249-
: null;
244+
return value != null ? value : isAllowNullValues() ? NullValue.INSTANCE : null;
250245
}
251246

252247
/**
@@ -327,7 +322,8 @@ protected String convertKey(Object key) {
327322
if (conversionService.canConvert(source, TypeDescriptor.valueOf(String.class))) {
328323
try {
329324
return conversionService.convert(key, String.class);
330-
} catch (ConversionFailedException cause) {
325+
}
326+
catch (ConversionFailedException cause) {
331327

332328
// May fail if the given key is a collection
333329
if (isCollectionLikeOrMap(source)) {
@@ -342,7 +338,8 @@ protected String convertKey(Object key) {
342338
return key.toString();
343339
}
344340

345-
String message = String.format("Cannot convert cache key %s to String; Please register a suitable Converter"
341+
String message = String.format(
342+
"Cannot convert cache key %s to String; Please register a suitable Converter"
346343
+ " via 'RedisCacheConfiguration.configureKeyConverters(...)' or override '%s.toString()'",
347344
source, key.getClass().getName());
348345

@@ -380,12 +377,13 @@ private String convertCollectionLikeOrMapKey(Object key, TypeDescriptor source)
380377
target.append("}");
381378

382379
return target.toString();
383-
} else if (source.isCollection() || source.isArray()) {
380+
}
381+
else if (source.isCollection() || source.isArray()) {
384382

385383
StringJoiner stringJoiner = new StringJoiner(",");
386384

387385
Collection<?> collection = source.isCollection() ? (Collection<?>) key
388-
: Arrays.asList(ObjectUtils.toObjectArray(key));
386+
: Arrays.asList(ObjectUtils.toObjectArray(key));
389387

390388
for (Object collectedKey : collection) {
391389
stringJoiner.add(convertKey(collectedKey));

src/main/java/org/springframework/data/redis/cache/RedisCacheConfiguration.java

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.springframework.core.convert.ConversionService;
2525
import org.springframework.core.convert.converter.Converter;
2626
import org.springframework.core.convert.converter.ConverterRegistry;
27+
import org.springframework.data.redis.cache.RedisCacheWriter.TtlFunction;
2728
import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;
2829
import org.springframework.data.redis.serializer.RedisSerializer;
2930
import org.springframework.format.support.DefaultFormattingConversionService;
@@ -119,16 +120,24 @@ public static RedisCacheConfiguration defaultCacheConfig(@Nullable ClassLoader c
119120

120121
private final ConversionService conversionService;
121122

122-
private final Duration ttl;
123+
private final TtlFunction ttl;
123124

124125
private final SerializationPair<String> keySerializationPair;
125126
private final SerializationPair<Object> valueSerializationPair;
126127

127-
@SuppressWarnings("unchecked")
128128
private RedisCacheConfiguration(Duration ttl, Boolean cacheNullValues, Boolean usePrefix, CacheKeyPrefix keyPrefix,
129129
SerializationPair<String> keySerializationPair, SerializationPair<?> valueSerializationPair,
130130
ConversionService conversionService) {
131131

132+
this(TtlFunction.just(ttl), cacheNullValues, usePrefix, keyPrefix, keySerializationPair, valueSerializationPair,
133+
conversionService);
134+
}
135+
136+
@SuppressWarnings("unchecked")
137+
private RedisCacheConfiguration(TtlFunction ttl, Boolean cacheNullValues, Boolean usePrefix, CacheKeyPrefix keyPrefix,
138+
SerializationPair<String> keySerializationPair, SerializationPair<?> valueSerializationPair,
139+
ConversionService conversionService) {
140+
132141
this.ttl = ttl;
133142
this.cacheNullValues = cacheNullValues;
134143
this.usePrefix = usePrefix;
@@ -205,8 +214,23 @@ public RedisCacheConfiguration entryTtl(Duration ttl) {
205214

206215
Assert.notNull(ttl, "TTL duration must not be null");
207216

208-
return new RedisCacheConfiguration(ttl, cacheNullValues, usePrefix, keyPrefix, keySerializationPair,
209-
valueSerializationPair, conversionService);
217+
return entryTtl(TtlFunction.just(ttl));
218+
}
219+
220+
/**
221+
* Set the {@link TtlFunction TTL function} to compute the time to live for cache entries.
222+
*
223+
* @param ttlFunction the {@link TtlFunction} to compute the time to live for cache entries, must not be
224+
* {@literal null}.
225+
* @return new {@link RedisCacheConfiguration}.
226+
* @since 3.2
227+
*/
228+
public RedisCacheConfiguration entryTtl(TtlFunction ttlFunction) {
229+
230+
Assert.notNull(ttlFunction, "TtlFunction must not be null");
231+
232+
return new RedisCacheConfiguration(ttlFunction, cacheNullValues, usePrefix, keyPrefix, keySerializationPair,
233+
valueSerializationPair, conversionService);
210234
}
211235

212236
/**
@@ -304,7 +328,11 @@ public SerializationPair<Object> getValueSerializationPair() {
304328
* @return The expiration time (ttl) for cache entries. Never {@literal null}.
305329
*/
306330
public Duration getTtl() {
307-
return ttl;
331+
return getTtlFunction().getTimeToLive(null, null);
332+
}
333+
334+
public TtlFunction getTtlFunction() {
335+
return this.ttl;
308336
}
309337

310338
/**

src/main/java/org/springframework/data/redis/cache/RedisCacheWriter.java

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,31 @@ static RedisCacheWriter lockingRedisCacheWriter(RedisConnectionFactory connectio
8383
*/
8484
static RedisCacheWriter lockingRedisCacheWriter(RedisConnectionFactory connectionFactory,
8585
BatchStrategy batchStrategy) {
86+
return lockingRedisCacheWriter(connectionFactory, Duration.ofMillis(50), TtlFunction.persistent(), batchStrategy);
87+
}
88+
89+
/**
90+
* Create new {@link RedisCacheWriter} with locking behavior.
91+
*
92+
* @param connectionFactory must not be {@literal null}.
93+
* @param sleepTime sleep time between lock access attempts, must not be {@literal null}.
94+
* @param lockTtlFunction TTL function to compute the Lock TTL. The function is called with contextual keys and values
95+
* (such as the cache name on cleanup or the actual key/value on put requests). Must not be {@literal null}.
96+
* @param batchStrategy must not be {@literal null}.
97+
* @return new instance of {@link DefaultRedisCacheWriter}.
98+
* @since 3.2
99+
*/
100+
static RedisCacheWriter lockingRedisCacheWriter(RedisConnectionFactory connectionFactory, Duration sleepTime,
101+
TtlFunction lockTtlFunction, BatchStrategy batchStrategy) {
86102

87103
Assert.notNull(connectionFactory, "ConnectionFactory must not be null");
88104

89-
return new DefaultRedisCacheWriter(connectionFactory, Duration.ofMillis(50), batchStrategy);
105+
return new DefaultRedisCacheWriter(connectionFactory, sleepTime, lockTtlFunction, CacheStatisticsCollector.none(),
106+
batchStrategy);
90107
}
91108

92109
/**
93-
* Write the given key/value pair to Redis an set the expiration time if defined.
110+
* Write the given key/value pair to Redis and set the expiration time if defined.
94111
*
95112
* @param name The cache name must not be {@literal null}.
96113
* @param key The key for the cache entry. Must not be {@literal null}.
@@ -152,4 +169,48 @@ static RedisCacheWriter lockingRedisCacheWriter(RedisConnectionFactory connectio
152169
*/
153170
RedisCacheWriter withStatisticsCollector(CacheStatisticsCollector cacheStatisticsCollector);
154171

172+
/**
173+
* Function to compute the time to live from the cache {@code key} and {@code value}.
174+
*
175+
* @author Mark Paluch
176+
* @since 3.2
177+
*/
178+
@FunctionalInterface
179+
interface TtlFunction {
180+
181+
/**
182+
* Creates a singleton {@link TtlFunction} using the given {@link Duration}.
183+
*
184+
* @param duration the time to live. Can be {@link Duration#ZERO} for persistent values (i.e. cache entry does not
185+
* expire).
186+
* @return a singleton {@link TtlFunction} using {@link Duration}.
187+
*/
188+
static TtlFunction just(Duration duration) {
189+
190+
Assert.notNull(duration, "TTL Duration must not be null");
191+
192+
return new SingletonTtlFunction(duration);
193+
}
194+
195+
/**
196+
* Returns a {@link TtlFunction} to create persistent entires that do not expire.
197+
*
198+
* @return a {@link TtlFunction} to create persistent entires that do not expire.
199+
*/
200+
static TtlFunction persistent() {
201+
return just(Duration.ZERO);
202+
}
203+
204+
/**
205+
* Compute a {@link Duration time to live duration} using the cache {@code key} and {@code value}. The time to live
206+
* is computed on each write operation. Redis uses milliseconds granularity for timeouts. Any more granular values
207+
* (e.g. micros or nanos) are not considered and are truncated due to rounding. Returning {@link Duration#ZERO} (or
208+
* a value less than {@code Duration.ofMillis(1)}) results in a persistent value that does not expire.
209+
*
210+
* @param key the cache key.
211+
* @param value the cache value. Can be {@code null} if the cache supports {@code null} value caching.
212+
* @return the time to live. Can be {@link Duration#ZERO} for persistent values (i.e. cache entry does not expire).
213+
*/
214+
Duration getTimeToLive(Object key, @Nullable Object value);
215+
}
155216
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright 2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.redis.cache;
17+
18+
import java.time.Duration;
19+
20+
import org.springframework.data.redis.cache.RedisCacheWriter.TtlFunction;
21+
import org.springframework.lang.Nullable;
22+
23+
/**
24+
* Singleton implementation of {@link TtlFunction}.
25+
*
26+
* @author Mark Paluch
27+
* @since 3.2
28+
*/
29+
public record SingletonTtlFunction(Duration duration) implements TtlFunction {
30+
31+
@Override
32+
public Duration getTimeToLive(Object key, @Nullable Object value) {
33+
return this.duration;
34+
}
35+
}

0 commit comments

Comments
 (0)