Skip to content

Commit e64b81e

Browse files
committed
Revised handling of allowNullValues for asynchronous retrieval
Includes revised cacheNames javadoc and equals/hashCode for SimpleValueWrapper. See gh-31637
1 parent 5a3ad6b commit e64b81e

File tree

12 files changed

+147
-38
lines changed

12 files changed

+147
-38
lines changed

spring-context-support/src/main/java/org/springframework/cache/caffeine/CaffeineCache.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ public <T> T get(Object key, Callable<T> valueLoader) {
140140
public CompletableFuture<?> retrieve(Object key) {
141141
CompletableFuture<?> result = getAsyncCache().getIfPresent(key);
142142
if (result != null && isAllowNullValues()) {
143-
result = result.handle((value, ex) -> fromStoreValue(value));
143+
result = result.thenApply(this::toValueWrapper);
144144
}
145145
return result;
146146
}

spring-context-support/src/main/java/org/springframework/cache/caffeine/CaffeineCacheManager.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,10 @@
4848
* A {@link CaffeineSpec}-compliant expression value can also be applied
4949
* via the {@link #setCacheSpecification "cacheSpecification"} bean property.
5050
*
51-
* <p>Supports the {@link Cache#retrieve(Object)} and
51+
* <p>Supports the asynchronous {@link Cache#retrieve(Object)} and
5252
* {@link Cache#retrieve(Object, Supplier)} operations through Caffeine's
53-
* {@link AsyncCache}, when configured via {@link #setAsyncCacheMode}.
53+
* {@link AsyncCache}, when configured via {@link #setAsyncCacheMode},
54+
* with early-determined cache misses.
5455
*
5556
* <p>Requires Caffeine 3.0 or higher, as of Spring Framework 6.1.
5657
*
@@ -198,6 +199,11 @@ public void setAsyncCacheLoader(AsyncCacheLoader<Object, Object> cacheLoader) {
198199
* <p>By default, this cache manager builds regular native Caffeine caches.
199200
* To switch to async caches which can also be used through the synchronous API
200201
* but come with support for {@code Cache#retrieve}, set this flag to {@code true}.
202+
* <p>Note that while null values in the cache are tolerated in async cache mode,
203+
* the recommendation is to disallow null values through
204+
* {@link #setAllowNullValues setAllowNullValues(false)}. This makes the semantics
205+
* of CompletableFuture-based access simpler and optimizes retrieval performance
206+
* since a Caffeine-provided CompletableFuture handle does not have to get wrapped.
201207
* @since 6.1
202208
* @see Caffeine#buildAsync()
203209
* @see Cache#retrieve(Object)

spring-context-support/src/test/java/org/springframework/cache/caffeine/CaffeineCacheManagerTests.java

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
import org.springframework.cache.Cache;
2727
import org.springframework.cache.CacheManager;
28+
import org.springframework.cache.support.SimpleValueWrapper;
2829

2930
import static org.assertj.core.api.Assertions.assertThat;
3031
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
@@ -170,9 +171,9 @@ void asyncMode() {
170171
assertThat(cache1.get("key3", () -> (String) null)).isNull();
171172
assertThat(cache1.get("key3", () -> (String) null)).isNull();
172173

173-
assertThat(cache1.retrieve("key1").join()).isEqualTo("value1");
174-
assertThat(cache1.retrieve("key2").join()).isEqualTo(2);
175-
assertThat(cache1.retrieve("key3").join()).isNull();
174+
assertThat(cache1.retrieve("key1").join()).isEqualTo(new SimpleValueWrapper("value1"));
175+
assertThat(cache1.retrieve("key2").join()).isEqualTo(new SimpleValueWrapper(2));
176+
assertThat(cache1.retrieve("key3").join()).isEqualTo(new SimpleValueWrapper(null));
176177
cache1.evict("key3");
177178
assertThat(cache1.retrieve("key3")).isNull();
178179
assertThat(cache1.retrieve("key3", () -> CompletableFuture.completedFuture("value3")).join())
@@ -184,6 +185,44 @@ void asyncMode() {
184185
assertThat(cache1.retrieve("key3", () -> CompletableFuture.completedFuture(null)).join()).isNull();
185186
}
186187

188+
@Test
189+
void asyncModeWithoutNullValues() {
190+
CaffeineCacheManager cm = new CaffeineCacheManager();
191+
cm.setAsyncCacheMode(true);
192+
cm.setAllowNullValues(false);
193+
194+
Cache cache1 = cm.getCache("c1");
195+
assertThat(cache1).isInstanceOf(CaffeineCache.class);
196+
Cache cache1again = cm.getCache("c1");
197+
assertThat(cache1).isSameAs(cache1again);
198+
Cache cache2 = cm.getCache("c2");
199+
assertThat(cache2).isInstanceOf(CaffeineCache.class);
200+
Cache cache2again = cm.getCache("c2");
201+
assertThat(cache2).isSameAs(cache2again);
202+
Cache cache3 = cm.getCache("c3");
203+
assertThat(cache3).isInstanceOf(CaffeineCache.class);
204+
Cache cache3again = cm.getCache("c3");
205+
assertThat(cache3).isSameAs(cache3again);
206+
207+
cache1.put("key1", "value1");
208+
assertThat(cache1.get("key1").get()).isEqualTo("value1");
209+
cache1.put("key2", 2);
210+
assertThat(cache1.get("key2").get()).isEqualTo(2);
211+
cache1.evict("key3");
212+
assertThat(cache1.get("key3")).isNull();
213+
assertThat(cache1.get("key3", () -> "value3")).isEqualTo("value3");
214+
assertThat(cache1.get("key3", () -> "value3")).isEqualTo("value3");
215+
cache1.evict("key3");
216+
217+
assertThat(cache1.retrieve("key1").join()).isEqualTo("value1");
218+
assertThat(cache1.retrieve("key2").join()).isEqualTo(2);
219+
assertThat(cache1.retrieve("key3")).isNull();
220+
assertThat(cache1.retrieve("key3", () -> CompletableFuture.completedFuture("value3")).join())
221+
.isEqualTo("value3");
222+
assertThat(cache1.retrieve("key3", () -> CompletableFuture.completedFuture("value3")).join())
223+
.isEqualTo("value3");
224+
}
225+
187226
@Test
188227
void changeCaffeineRecreateCache() {
189228
CaffeineCacheManager cm = new CaffeineCacheManager("c1");

spring-context-support/src/test/java/org/springframework/cache/caffeine/CaffeineReactiveCachingTests.java

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
import java.util.concurrent.CompletableFuture;
2121
import java.util.concurrent.atomic.AtomicLong;
2222

23-
import org.junit.jupiter.api.Test;
23+
import org.junit.jupiter.params.ParameterizedTest;
24+
import org.junit.jupiter.params.provider.ValueSource;
2425
import reactor.core.publisher.Flux;
2526
import reactor.core.publisher.Mono;
2627

@@ -43,9 +44,10 @@
4344
*/
4445
public class CaffeineReactiveCachingTests {
4546

46-
@Test
47-
void withCaffeineAsyncCache() {
48-
ApplicationContext ctx = new AnnotationConfigApplicationContext(Config.class, ReactiveCacheableService.class);
47+
@ParameterizedTest
48+
@ValueSource(classes = {AsyncCacheModeConfig.class, AsyncCacheModeConfig.class})
49+
void cacheHitDetermination(Class<?> configClass) {
50+
ApplicationContext ctx = new AnnotationConfigApplicationContext(configClass, ReactiveCacheableService.class);
4951
ReactiveCacheableService service = ctx.getBean(ReactiveCacheableService.class);
5052

5153
Object key = new Object();
@@ -128,12 +130,26 @@ Flux<Long> cacheFlux(Object arg) {
128130

129131
@Configuration(proxyBeanMethods = false)
130132
@EnableCaching
131-
static class Config {
133+
static class AsyncCacheModeConfig {
134+
135+
@Bean
136+
CacheManager cacheManager() {
137+
CaffeineCacheManager cm = new CaffeineCacheManager("first");
138+
cm.setAsyncCacheMode(true);
139+
return cm;
140+
}
141+
}
142+
143+
144+
@Configuration(proxyBeanMethods = false)
145+
@EnableCaching
146+
static class AsyncCacheModeWithoutNullValuesConfig {
132147

133148
@Bean
134149
CacheManager cacheManager() {
135150
CaffeineCacheManager ccm = new CaffeineCacheManager("first");
136151
ccm.setAsyncCacheMode(true);
152+
ccm.setAllowNullValues(false);
137153
return ccm;
138154
}
139155
}

spring-context/src/main/java/org/springframework/cache/Cache.java

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -116,21 +116,23 @@ public interface Cache {
116116
* <p>Can return {@code null} if the cache can immediately determine that
117117
* it contains no mapping for this key (e.g. through an in-memory key map).
118118
* Otherwise, the cached value will be returned in the {@link CompletableFuture},
119-
* with {@code null} indicating a late-determined cache miss (and a nested
120-
* {@link ValueWrapper} potentially indicating a nullable cached value).
119+
* with {@code null} indicating a late-determined cache miss. A nested
120+
* {@link ValueWrapper} potentially indicates a nullable cached value;
121+
* the cached value may also be represented as a plain element if null
122+
* values are not supported. Calling code needs to be prepared to handle
123+
* all those variants of the result returned by this method.
121124
* @param key the key whose associated value is to be returned
122125
* @return the value to which this cache maps the specified key, contained
123126
* within a {@link CompletableFuture} which may also be empty when a cache
124127
* miss has been late-determined. A straight {@code null} being returned
125128
* means that the cache immediately determined that it contains no mapping
126129
* for this key. A {@link ValueWrapper} contained within the
127-
* {@code CompletableFuture} can indicate a cached value that is potentially
130+
* {@code CompletableFuture} indicates a cached value that is potentially
128131
* {@code null}; this is sensible in a late-determined scenario where a regular
129132
* CompletableFuture-contained {@code null} indicates a cache miss. However,
130-
* an early-determined cache will usually return the plain cached value here,
131-
* and a late-determined cache may also return a plain value if it does not
132-
* support the actual caching of {@code null} values. Spring's common cache
133-
* processing can deal with all variants of these implementation strategies.
133+
* a cache may also return a plain value if it does not support the actual
134+
* caching of {@code null} values, avoiding the extra level of value wrapping.
135+
* Spring's cache processing can deal with all such implementation strategies.
134136
* @since 6.1
135137
* @see #retrieve(Object, Supplier)
136138
*/
@@ -149,11 +151,14 @@ default CompletableFuture<?> retrieve(Object key) {
149151
* <p>If possible, implementations should ensure that the loading operation
150152
* is synchronized so that the specified {@code valueLoader} is only called
151153
* once in case of concurrent access on the same key.
152-
* <p>If the {@code valueLoader} throws an exception, it will be propagated
154+
* <p>Null values are generally not supported by this method. The provided
155+
* {@link CompletableFuture} handle produces a value or raises an exception.
156+
* If the {@code valueLoader} raises an exception, it will be propagated
153157
* to the {@code CompletableFuture} handle returned from here.
154158
* @param key the key whose associated value is to be returned
155-
* @return the value to which this cache maps the specified key,
156-
* contained within a {@link CompletableFuture}
159+
* @return the value to which this cache maps the specified key, contained
160+
* within a {@link CompletableFuture} which will never be {@code null}.
161+
* The provided future is expected to produce a value or raise an exception.
157162
* @since 6.1
158163
* @see #retrieve(Object)
159164
* @see #get(Object, Callable)

spring-context/src/main/java/org/springframework/cache/annotation/CacheConfig.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2015 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -32,6 +32,7 @@
3232
* @author Stephane Nicoll
3333
* @author Sam Brannen
3434
* @since 4.1
35+
* @see Cacheable
3536
*/
3637
@Target(ElementType.TYPE)
3738
@Retention(RetentionPolicy.RUNTIME)
@@ -42,8 +43,10 @@
4243
* Names of the default caches to consider for caching operations defined
4344
* in the annotated class.
4445
* <p>If none is set at the operation level, these are used instead of the default.
45-
* <p>May be used to determine the target cache (or caches), matching the
46-
* qualifier value or the bean names of a specific bean definition.
46+
* <p>Names may be used to determine the target cache(s), to be resolved via the
47+
* configured {@link #cacheResolver()} which typically delegates to
48+
* {@link org.springframework.cache.CacheManager#getCache}.
49+
* For further details see {@link Cacheable#cacheNames()}.
4750
*/
4851
String[] cacheNames() default {};
4952

spring-context/src/main/java/org/springframework/cache/annotation/Cacheable.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,17 @@
7070

7171
/**
7272
* Names of the caches in which method invocation results are stored.
73-
* <p>Names may be used to determine the target cache (or caches), matching
74-
* the qualifier value or bean name of a specific bean definition.
73+
* <p>Names may be used to determine the target cache(s), to be resolved via the
74+
* configured {@link #cacheResolver()} which typically delegates to
75+
* {@link org.springframework.cache.CacheManager#getCache}.
76+
* <p>This will usually be a single cache name. If multiple names are specified,
77+
* they will be consulted for a cache hit in the order of definition, and they
78+
* will all receive a put/evict request for the same newly cached value.
79+
* <p>Note that asynchronous/reactive cache access may not fully consult all
80+
* specified caches, depending on the target cache. In the case of late-determined
81+
* cache misses (e.g. with Redis), further caches will not get consulted anymore.
82+
* As a consequence, specifying multiple cache names in an async cache mode setup
83+
* only makes sense with early-determined cache misses (e.g. with Caffeine).
7584
* @since 4.2
7685
* @see #value
7786
* @see CacheConfig#cacheNames

spring-context/src/main/java/org/springframework/cache/concurrent/ConcurrentMapCache.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@ public <T> T get(Object key, Callable<T> valueLoader) {
160160
@Nullable
161161
public CompletableFuture<?> retrieve(Object key) {
162162
Object value = lookup(key);
163-
return (value != null ? CompletableFuture.completedFuture(fromStoreValue(value)) : null);
163+
return (value != null ? CompletableFuture.completedFuture(
164+
isAllowNullValues() ? toValueWrapper(value) : fromStoreValue(value)) : null);
164165
}
165166

166167
@SuppressWarnings("unchecked")

spring-context/src/main/java/org/springframework/cache/concurrent/ConcurrentMapCacheManager.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.util.Map;
2323
import java.util.concurrent.ConcurrentHashMap;
2424
import java.util.concurrent.ConcurrentMap;
25+
import java.util.function.Supplier;
2526

2627
import org.springframework.beans.factory.BeanClassLoaderAware;
2728
import org.springframework.cache.Cache;
@@ -35,11 +36,15 @@
3536
* the set of cache names is pre-defined through {@link #setCacheNames}, with no
3637
* dynamic creation of further cache regions at runtime.
3738
*
39+
* <p>Supports the asynchronous {@link Cache#retrieve(Object)} and
40+
* {@link Cache#retrieve(Object, Supplier)} operations through basic
41+
* {@code CompletableFuture} adaptation, with early-determined cache misses.
42+
*
3843
* <p>Note: This is by no means a sophisticated CacheManager; it comes with no
3944
* cache configuration options. However, it may be useful for testing or simple
4045
* caching scenarios. For advanced local caching needs, consider
41-
* {@link org.springframework.cache.jcache.JCacheCacheManager} or
42-
* {@link org.springframework.cache.caffeine.CaffeineCacheManager}.
46+
* {@link org.springframework.cache.caffeine.CaffeineCacheManager} or
47+
* {@link org.springframework.cache.jcache.JCacheCacheManager}.
4348
*
4449
* @author Juergen Hoeller
4550
* @since 3.1

spring-context/src/main/java/org/springframework/cache/interceptor/CacheAspectSupport.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,7 @@ private Object evaluate(@Nullable Object cacheHit, CacheOperationInvoker invoker
508508

509509
if (cacheHit != null && !hasCachePut(contexts)) {
510510
// If there are no put requests, just use the cache hit
511-
cacheValue = (cacheHit instanceof Cache.ValueWrapper wrapper ? wrapper.get() : cacheHit);
511+
cacheValue = unwrapCacheValue(cacheHit);
512512
returnValue = wrapCacheValue(method, cacheValue);
513513
}
514514
else {

spring-context/src/main/java/org/springframework/cache/support/SimpleValueWrapper.java

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2017 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,6 +16,8 @@
1616

1717
package org.springframework.cache.support;
1818

19+
import java.util.Objects;
20+
1921
import org.springframework.cache.Cache.ValueWrapper;
2022
import org.springframework.lang.Nullable;
2123

@@ -50,4 +52,19 @@ public Object get() {
5052
return this.value;
5153
}
5254

55+
@Override
56+
public boolean equals(@Nullable Object other) {
57+
return (this == other || (other instanceof ValueWrapper wrapper && Objects.equals(get(), wrapper.get())));
58+
}
59+
60+
@Override
61+
public int hashCode() {
62+
return Objects.hashCode(this.value);
63+
}
64+
65+
@Override
66+
public String toString() {
67+
return "ValueWrapper for [" + this.value + "]";
68+
}
69+
5370
}

spring-context/src/test/java/org/springframework/cache/annotation/ReactiveCachingTests.java

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
import org.springframework.cache.CacheManager;
3030
import org.springframework.cache.concurrent.ConcurrentMapCache;
3131
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
32-
import org.springframework.cache.support.SimpleValueWrapper;
3332
import org.springframework.context.ApplicationContext;
3433
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
3534
import org.springframework.context.annotation.Bean;
@@ -48,6 +47,7 @@ public class ReactiveCachingTests {
4847

4948
@ParameterizedTest
5049
@ValueSource(classes = {EarlyCacheHitDeterminationConfig.class,
50+
EarlyCacheHitDeterminationWithoutNullValuesConfig.class,
5151
LateCacheHitDeterminationConfig.class,
5252
LateCacheHitDeterminationWithValueWrapperConfig.class})
5353
void cacheHitDetermination(Class<?> configClass) {
@@ -143,6 +143,19 @@ CacheManager cacheManager() {
143143
}
144144

145145

146+
@Configuration(proxyBeanMethods = false)
147+
@EnableCaching
148+
static class EarlyCacheHitDeterminationWithoutNullValuesConfig {
149+
150+
@Bean
151+
CacheManager cacheManager() {
152+
ConcurrentMapCacheManager cm = new ConcurrentMapCacheManager("first");
153+
cm.setAllowNullValues(false);
154+
return cm;
155+
}
156+
}
157+
158+
146159
@Configuration(proxyBeanMethods = false)
147160
@EnableCaching
148161
static class LateCacheHitDeterminationConfig {
@@ -177,12 +190,7 @@ protected Cache createConcurrentMapCache(String name) {
177190
@Override
178191
public CompletableFuture<?> retrieve(Object key) {
179192
Object value = lookup(key);
180-
if (value != null) {
181-
return CompletableFuture.completedFuture(new SimpleValueWrapper(fromStoreValue(value)));
182-
}
183-
else {
184-
return CompletableFuture.completedFuture(null);
185-
}
193+
return CompletableFuture.completedFuture(value != null ? toValueWrapper(value) : null);
186194
}
187195
};
188196
}

0 commit comments

Comments
 (0)