To aggregate only background refreshes while keeping explicit loads immediate, implement CacheLoader. In this pattern, load and loadAll invoke the mapping function directly (synchronously), while asyncReload submits the request to a Reactor Sink for batching.
Note: The asyncReload method must be synchronized because the Reactor Sink does not support concurrent submissions.
public final class CoalescingBulkLoader<K, V> implements CacheLoader<K, V> {
private final Function<Set<K>, Map<K, V>> mappingFunction;
private final Sinks.Many<Request<K, V>> sink;
/**
* @param maxSize the maximum entries to collect before performing a bulk request
* @param maxTime the maximum duration to wait before performing a bulk request
* @param parallelism the number of parallel bulk loads that can be performed
* @param mappingFunction the function to compute the values
*/
public CoalescingBulkLoader(int maxSize, Duration maxTime, int parallelism,
Function<Set<K>, Map<K, V>> mappingFunction) {
this.sink = Sinks.many().unicast().onBackpressureBuffer();
this.mappingFunction = requireNonNull(mappingFunction);
sink.asFlux()
.bufferTimeout(maxSize, maxTime)
.map(requests -> requests.stream().collect(
toMap(Entry::getKey, Entry::getValue)))
.parallel(parallelism)
.runOn(Schedulers.boundedElastic())
.subscribe(this::handle);
}
@Override public V load(K key) {
return loadAll(Set.of(key)).get(key);
}
@Override public Map<K, V> loadAll(Set<? extends K> keys) {
return mappingFunction.apply(keys);
}
@Override public synchronized CompletableFuture<V> asyncReload(K key, V oldValue, Executor e) {
var entry = Map.entry(key, new CompletableFuture<V>());
sink.tryEmitNext(entry).orThrow();
return entry.getValue();
}
private void handle(Map<K, CompletableFuture<V>> requests) {
try {
var results = mappingFunction.apply(requests.keySet());
requests.forEach((key, result) -> result.complete(results.get(key)));
} catch (Throwable t) {
requests.forEach((key, result) -> result.completeExceptionally(t));
}
}
}