Skip to content

Commit 6d9a2eb

Browse files
committed
Improve fix of duplicate upstream subscription during reactive cache put
This commit fixes an issue where a Cacheable method which returns a Flux (or multi-value publisher) will be invoked once, but the returned publisher is actually subscribed twice. The previous fix 988f363 would cause the cached elements to depend on the first usage pattern / request pattern, which is likely to be too confusing to users. This fix reintroduces the notion of exhausting the original Flux by having a second subscriber dedicated to that, but uses `refCount(2)` to ensure that the original `Flux` returned by the cached method is still only subscribed once. Closes gh-32370
1 parent c1d4b61 commit 6d9a2eb

File tree

3 files changed

+73
-33
lines changed

3 files changed

+73
-33
lines changed

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

+28-2
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,28 @@ void cacheHitDetermination(Class<?> configClass) {
107107
}
108108

109109

110+
@ParameterizedTest
111+
@ValueSource(classes = {AsyncCacheModeConfig.class, AsyncCacheModeConfig.class})
112+
void fluxCacheDoesntDependOnFirstRequest(Class<?> configClass) {
113+
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(configClass, ReactiveCacheableService.class);
114+
ReactiveCacheableService service = ctx.getBean(ReactiveCacheableService.class);
115+
116+
Object key = new Object();
117+
118+
List<Long> l1 = service.cacheFlux(key).take(1L, true).collectList().block();
119+
List<Long> l2 = service.cacheFlux(key).take(3L, true).collectList().block();
120+
List<Long> l3 = service.cacheFlux(key).collectList().block();
121+
122+
Long first = l1.get(0);
123+
124+
assertThat(l1).as("l1").containsExactly(first);
125+
assertThat(l2).as("l2").containsExactly(first, 0L, -1L);
126+
assertThat(l3).as("l3").containsExactly(first, 0L, -1L, -2L, -3L);
127+
128+
ctx.close();
129+
}
130+
131+
110132
@CacheConfig(cacheNames = "first")
111133
static class ReactiveCacheableService {
112134

@@ -119,12 +141,16 @@ CompletableFuture<Long> cacheFuture(Object arg) {
119141

120142
@Cacheable
121143
Mono<Long> cacheMono(Object arg) {
122-
return Mono.just(this.counter.getAndIncrement());
144+
// here counter not only reflects invocations of cacheMono but subscriptions to
145+
// the returned Mono as well. See https://github.com/spring-projects/spring-framework/issues/32370
146+
return Mono.defer(() -> Mono.just(this.counter.getAndIncrement()));
123147
}
124148

125149
@Cacheable
126150
Flux<Long> cacheFlux(Object arg) {
127-
return Flux.just(this.counter.getAndIncrement(), 0L);
151+
// here counter not only reflects invocations of cacheFlux but subscriptions to
152+
// the returned Flux as well. See https://github.com/spring-projects/spring-framework/issues/32370
153+
return Flux.defer(() -> Flux.just(this.counter.getAndIncrement(), 0L, -1L, -2L, -3L));
128154
}
129155
}
130156

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

+20-30
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,12 @@
2626
import java.util.Optional;
2727
import java.util.concurrent.CompletableFuture;
2828
import java.util.concurrent.ConcurrentHashMap;
29-
import java.util.concurrent.atomic.AtomicReference;
3029
import java.util.function.Supplier;
3130

3231
import org.apache.commons.logging.Log;
3332
import org.apache.commons.logging.LogFactory;
34-
import reactor.core.observability.DefaultSignalListener;
33+
import org.reactivestreams.Subscriber;
34+
import org.reactivestreams.Subscription;
3535
import reactor.core.publisher.Flux;
3636
import reactor.core.publisher.Mono;
3737

@@ -90,7 +90,6 @@
9090
* @author Sam Brannen
9191
* @author Stephane Nicoll
9292
* @author Sebastien Deleuze
93-
* @author Simon Baslé
9493
* @since 3.1
9594
*/
9695
public abstract class CacheAspectSupport extends AbstractCacheInvoker
@@ -1037,45 +1036,34 @@ public void performCachePut(@Nullable Object value) {
10371036

10381037

10391038
/**
1040-
* Reactor stateful SignalListener for collecting a List to cache.
1039+
* Reactive Streams Subscriber for exhausting the Flux and collecting a List
1040+
* to cache.
10411041
*/
1042-
private class CachePutSignalListener extends DefaultSignalListener<Object> {
1042+
private final class CachePutListSubscriber implements Subscriber<Object> {
10431043

1044-
private final AtomicReference<CachePutRequest> request;
1044+
private final CachePutRequest request;
10451045

10461046
private final List<Object> cacheValue = new ArrayList<>();
10471047

1048-
public CachePutSignalListener(CachePutRequest request) {
1049-
this.request = new AtomicReference<>(request);
1048+
public CachePutListSubscriber(CachePutRequest request) {
1049+
this.request = request;
10501050
}
10511051

10521052
@Override
1053-
public void doOnNext(Object o) {
1054-
this.cacheValue.add(o);
1053+
public void onSubscribe(Subscription s) {
1054+
s.request(Integer.MAX_VALUE);
10551055
}
1056-
10571056
@Override
1058-
public void doOnComplete() {
1059-
CachePutRequest r = this.request.get();
1060-
if (this.request.compareAndSet(r, null)) {
1061-
r.performCachePut(this.cacheValue);
1062-
}
1057+
public void onNext(Object o) {
1058+
this.cacheValue.add(o);
10631059
}
1064-
10651060
@Override
1066-
public void doOnCancel() {
1067-
// Note: we don't use doFinally as we want to propagate the signal after cache put, not before
1068-
CachePutRequest r = this.request.get();
1069-
if (this.request.compareAndSet(r, null)) {
1070-
r.performCachePut(this.cacheValue);
1071-
}
1061+
public void onError(Throwable t) {
1062+
this.cacheValue.clear();
10721063
}
1073-
10741064
@Override
1075-
public void doOnError(Throwable error) {
1076-
if (this.request.getAndSet(null) != null) {
1077-
this.cacheValue.clear();
1078-
}
1065+
public void onComplete() {
1066+
this.request.performCachePut(this.cacheValue);
10791067
}
10801068
}
10811069

@@ -1159,8 +1147,10 @@ public Object processPutRequest(CachePutRequest request, @Nullable Object result
11591147
ReactiveAdapter adapter = (result != null ? this.registry.getAdapter(result.getClass()) : null);
11601148
if (adapter != null) {
11611149
if (adapter.isMultiValue()) {
1162-
return adapter.fromPublisher(Flux.from(adapter.toPublisher(result))
1163-
.tap(() -> new CachePutSignalListener(request)));
1150+
Flux<?> source = Flux.from(adapter.toPublisher(result))
1151+
.publish().refCount(2);
1152+
source.subscribe(new CachePutListSubscriber(request));
1153+
return adapter.fromPublisher(source);
11641154
}
11651155
else {
11661156
return adapter.fromPublisher(Mono.from(adapter.toPublisher(result))

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

+25-1
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,30 @@ void cacheHitDetermination(Class<?> configClass) {
111111
ctx.close();
112112
}
113113

114+
@ParameterizedTest
115+
@ValueSource(classes = {EarlyCacheHitDeterminationConfig.class,
116+
EarlyCacheHitDeterminationWithoutNullValuesConfig.class,
117+
LateCacheHitDeterminationConfig.class,
118+
LateCacheHitDeterminationWithValueWrapperConfig.class})
119+
void fluxCacheDoesntDependOnFirstRequest(Class<?> configClass) {
120+
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(configClass, ReactiveCacheableService.class);
121+
ReactiveCacheableService service = ctx.getBean(ReactiveCacheableService.class);
122+
123+
Object key = new Object();
124+
125+
List<Long> l1 = service.cacheFlux(key).take(1L, true).collectList().block();
126+
List<Long> l2 = service.cacheFlux(key).take(3L, true).collectList().block();
127+
List<Long> l3 = service.cacheFlux(key).collectList().block();
128+
129+
Long first = l1.get(0);
130+
131+
assertThat(l1).as("l1").containsExactly(first);
132+
assertThat(l2).as("l2").containsExactly(first, 0L, -1L);
133+
assertThat(l3).as("l3").containsExactly(first, 0L, -1L, -2L, -3L);
134+
135+
ctx.close();
136+
}
137+
114138
@CacheConfig(cacheNames = "first")
115139
static class ReactiveCacheableService {
116140

@@ -132,7 +156,7 @@ Mono<Long> cacheMono(Object arg) {
132156
Flux<Long> cacheFlux(Object arg) {
133157
// here counter not only reflects invocations of cacheFlux but subscriptions to
134158
// the returned Flux as well. See https://github.com/spring-projects/spring-framework/issues/32370
135-
return Flux.defer(() -> Flux.just(this.counter.getAndIncrement(), 0L));
159+
return Flux.defer(() -> Flux.just(this.counter.getAndIncrement(), 0L, -1L, -2L, -3L));
136160
}
137161
}
138162

0 commit comments

Comments
 (0)