read-through

Read-through caching: load a missing key from a source and keep a present answer

ReadThroughCacheFactory supplies caches that ask a source for a missing key once, however many callers want it, and keep each present answer under the capacity and eviction a CachePolicy names.

Rationale

Why. A slow source should be asked once for a key, then not again until the kept value is gone.

Key Concepts

Lookup

Role. apply(key) answers a kept value without asking the source. Otherwise it asks the source and returns the answer. A present answer is kept. An empty answer or a failure is not, so the next lookup asks again.

Pattern. DECORATOR

Policy

Role. CachePolicy names a supplier of capacities, a builder of eviction strategies and the clock that stamps events. The supplier must return a new capacity each time, because each cache keeps its own. The builder receives forget, which removes a key, and the cache's capacity.

Pattern. STRATEGY

How It Works

Guarantees. No public parameter takes null and no public method returns it, declared with contrax's @PreCondition(IsNotNull.class) and @PostCondition(IsNotNull.class). Concurrent lookups of a key share one answer, and an exception from the source reaches each of them.

Thread safety. A cache may be called from any thread. The source and client OBSERVERs must support concurrent calls.

Lane

Purpose. Keeping what a slow source answers.

Alternative. write-through when writes go through the cache as well; ConcurrentHashMap.computeIfAbsent when nothing is ever forgotten.

When to avoid. A source whose latest answer always matters: a kept value is returned until it is evicted.

Usage

Eight concurrent lookups, then a ninth, ask the source once

import java.time.Instant;
import java.util.Collections;
import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import software.visionary.cache.Cache;
import software.visionary.cache.capacity.fixed.FixedSize;
import software.visionary.cache.eviction.recency.LeastRecentlyUsed;
import software.visionary.cache.policy.CachePolicy;
import software.visionary.cache.topology.read.through.ReadThroughCacheFactory;

AtomicInteger asked = new AtomicInteger();
Cache<String, Integer> cache = new ReadThroughCacheFactory<String, Integer>(
        key -> {
            asked.incrementAndGet();
            return Optional.of(key.length());
        },
        new CachePolicy<>(() -> new FixedSize<>(100), LeastRecentlyUsed::new, Instant::now)).get();

Callable<Optional<Integer>> lookup = () -> cache.apply("answer");
try (var callers = Executors.newVirtualThreadPerTaskExecutor()) {
    callers.invokeAll(Collections.nCopies(8, lookup));
}

System.out.println(cache.apply("answer"));
System.out.println(asked);

Output

Optional[6]
1

An empty answer is not kept, and a present one stays until evicted

Map<String, String> source = new ConcurrentHashMap<>();
Cache<String, String> cache = new ReadThroughCacheFactory<String, String>(
        key -> Optional.ofNullable(source.get(key)),
        new CachePolicy<>(() -> new FixedSize<>(100), LeastRecentlyUsed::new, Instant::now)).get();

System.out.println(cache.apply("answer"));
source.put("answer", "42");
System.out.println(cache.apply("answer"));
source.put("answer", "43");
System.out.println(cache.apply("answer"));
cache.strategy().evict("answer");
System.out.println(cache.apply("answer"));

Output

Optional.empty
Optional[42]
Optional[42]
Optional[43]

An eviction strategy of your own

final class OldestFirst<KEY> implements EvictionStrategy<KEY> {
    private final Consumer<KEY> forget;
    private final Capacity<KEY> capacity;
    private final Queue<KEY> kept = new ArrayDeque<>();

    OldestFirst(Consumer<KEY> forget, Capacity<KEY> capacity) {
        this.forget = forget;
        this.capacity = capacity;
    }

    @Override public void evict(KEY key) {
        forget.accept(key);
    }

    @Override public void update(CacheEvent<KEY, ?> event) {
        if (event instanceof Insert<KEY, ?>) {
            kept.add(event.key());
            while (capacity.exceeded()) {
                evict(kept.element());
            }
        } else if (event instanceof Evict<KEY, ?>) {
            kept.remove(event.key());
        }
    }
}

Cache<String, Integer> cache = new ReadThroughCacheFactory<String, Integer>(
        key -> Optional.of(key.length()),
        new CachePolicy<>(() -> new FixedSize<>(2), OldestFirst::new, Instant::now)).get();
cache.add(event -> {
    if (event instanceof Evict<String, ?> evicted) { System.out.println("evicted " + evicted.key()); }
});

Stream.of("a", "b", "c").forEach(cache::apply);

Output

evicted a

Dependencies

  • software.visionary:cache:2026.10.13 at Compile
  • software.visionary.contrax:processor:2026.10.13 at Processor
  • software.visionary:junit-closure:2026.09.17 at Testing

Views

Drawn from the release. A link in a view opens its source at the release tag.

architecture.svg
architecture.svg
classes.software.visionary.cache.policy.svg
classes.software.visionary.cache.policy.svg
classes.software.visionary.cache.topology.coordination.svg
classes.software.visionary.cache.topology.coordination.svg
classes.software.visionary.cache.topology.observation.svg
classes.software.visionary.cache.topology.observation.svg
classes.software.visionary.cache.topology.read.through.internal.svg
classes.software.visionary.cache.topology.read.through.internal.svg
classes.software.visionary.cache.topology.read.through.svg
classes.software.visionary.cache.topology.read.through.svg
classes.software.visionary.cache.topology.svg
classes.software.visionary.cache.topology.svg
packages.svg
packages.svg