write-behind
Write-behind caching: run a cache's writes on an injected Executor
WriteBehindCacheFactory decorates each supplied MutableCache so accept submits the write to an injected Executor instead of performing it.
Rationale
Why. A slow write should not hold up the caller.
Key Concepts
Delegated writes
Role. accept submits one task, which writes to the supplied cache. Everything else goes straight to the supplied cache, so a read returns the previous value until the task has run. The caller owns the Executor, which decides when tasks run and in what order.
Pattern. DECORATOR
Failed writes
Role. When a task's write throws, its key is evicted and the failure is rethrown from the task, so the supplied cache no longer answers with a value it could not write.
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).
Thread safety. Safe to share between threads when the supplied cache and the Executor are.
Lane
Purpose. Taking a write off the caller's thread.
Alternative. write-through alone when the caller must know the write persisted before going on.
When to avoid. A write the caller must see succeed: a failure surfaces in the task, never to the caller of accept.
Usage
A write runs when the Executor runs its task; here the Executor is a queue
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.Map;
import java.util.Optional;
import java.util.Queue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
import software.visionary.cache.MutableCache;
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.write.behind.WriteBehindCacheFactory;
import software.visionary.cache.topology.write.through.WriteThroughCacheFactory;
Map<String, String> storage = new ConcurrentHashMap<>(Map.of("answer", "old"));
Supplier<MutableCache<String, String>> writeThrough = new WriteThroughCacheFactory<String, String>(
key -> Optional.ofNullable(storage.get(key)),
storage::put,
new CachePolicy<>(() -> new FixedSize<>(100), LeastRecentlyUsed::new, Instant::now));
Queue<Runnable> tasks = new ArrayDeque<>();
MutableCache<String, String> cache = new WriteBehindCacheFactory<>(writeThrough, tasks::add).get();
cache.accept("answer", "new");
System.out.println(cache.apply("answer").orElseThrow() + ", " + storage.get("answer"));
tasks.remove().run();
System.out.println(cache.apply("answer").orElseThrow() + ", " + storage.get("answer"));
Output
old, old
new, newA failed write reaches the task, not the caller, and the next read loads again
AtomicInteger loads = new AtomicInteger();
Supplier<MutableCache<String, String>> writeThrough = new WriteThroughCacheFactory<String, String>(
key -> Optional.of("load " + loads.incrementAndGet()),
(key, value) -> { throw new IllegalStateException("disk full"); },
new CachePolicy<>(() -> new FixedSize<>(100), LeastRecentlyUsed::new, Instant::now));
Queue<Runnable> tasks = new ArrayDeque<>();
MutableCache<String, String> cache = new WriteBehindCacheFactory<>(writeThrough, tasks::add).get();
System.out.println(cache.apply("answer").orElseThrow());
cache.accept("answer", "42");
System.out.println("accept returned");
try {
tasks.remove().run();
} catch (IllegalStateException failed) {
System.out.println("task threw: " + failed.getMessage());
}
System.out.println(cache.apply("answer").orElseThrow());
Output
load 1
accept returned
task threw: disk full
load 2Dependencies
software.visionary:cache:2026.10.13at Compilesoftware.visionary.contrax:processor:2026.10.13at Processorsoftware.visionary:junit-closure:2026.09.17at Testing
Views
Drawn from the release. A link in a view opens its source at the release tag.