# read-through

`software.visionary.cache:read-through:2026.10.13` · https://codeberg.org/caching/read-through

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

<code>ReadThroughCacheFactory</code> 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 <code>CachePolicy</code> 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.** <code>apply(key)</code> 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.** <code>CachePolicy</code> 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 <code>forget</code>, 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 <code>@PreCondition(IsNotNull.class)</code> and <code>@PostCondition(IsNotNull.class)</code>. 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.** <code>write-through</code> when writes go through the cache as well; <code>ConcurrentHashMap.computeIfAbsent</code> 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

```java
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

```text
Optional[6]
1
```

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

```java
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

```text
Optional.empty
Optional[42]
Optional[42]
Optional[43]
```

An eviction strategy of your own

```java
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

```text
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

