feat(ext-cache-core): add the caching contract and a Caffeine backend

Split the way flash-ext-data and flash-ext-view are: cache-core defines
Cache, CacheManager, CacheSpec and CacheStats and talks to nothing;
cache-caffeine implements them in process.

    users = require(CacheManager.class).build("users", spec -> spec
            .maxSize(10_000).ttl(Duration.ofMinutes(10)));

    return users.get(id, repo::findById);

get(key, loader) is the only shape most code needs and the only one that is
hard to get right: the loader runs once per key across concurrent callers
rather than each racing its own. A null result stores nothing, because caching
absence is a decision rather than a default.

build(name, spec) is idempotent per name, so two handlers wanting one cache get
one cache without coordinating who creates it. Disagreeing about the spec
throws rather than resolving to whichever handler initialised first, which is a
bug that only surfaces under load.

recordStats() is opt-in — counting is two atomic increments per lookup, and a
cache nobody measures should not pay for numbers nobody reads. Unmeasured
caches return CacheStats.DISABLED rather than zeroes that look like a cold
cache.

Caffeine rather than a hand-rolled LRU: for genuinely low traffic
ConcurrentHashMap::computeIfAbsent is one line and needs no module at all, and
this exists for when that stops being true. W-TinyLFU admission, striped
counters and amortised eviction are not a weekend's work, and getting them
wrong yields a cache slower than no cache. The adapter is deliberately thin —
every method delegates, adding no wrapper, copy or locking of its own.

Caches are dropped through FlashContext.onClose, so values do not outlive the
app holding them. Invisible with one app per process; immediate under test.

flash-ext-cache-redis is designed but not built, and has docs only — no module,
no pom, no source. An empty module that builds an empty jar is dead weight in
the reactor. The docs record what changes once the cache can fail: get() must
decide whether to fall through to the loader, values need a codec,
invalidateAll needs a key prefix that becomes wire contract, and eviction stats
stop meaning anything. Those are decisions that want a real second replica to
check them against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-09-09 12:30:03 +00:00
co-authored by Claude Opus 5
parent 58bae41f7a
commit 24bb10175d
17 changed files with 753 additions and 1 deletions
@@ -0,0 +1,64 @@
# flash-ext-cache-core
The caching contract, shared across backends. Like `flash-ext-data-core`, this module talks to
nothing: it defines the abstractions and a backend implements them.
## Components
- `Cache<K, V>` — a named cache. `get(key, loader)` is the method that matters.
- `CacheManager` — creates and hands back named caches.
- `CacheSpec` — size and expiry for one cache.
- `CacheStats` — hit/miss/eviction counters.
Install a backend, not this module: [`flash-ext-cache-caffeine`](../../flash-ext-cache-caffeine/docs/README.md)
for in-process caching.
## The one shape that matters
```java
User user = users.get(id, repo::findById);
```
Compute-if-absent is the only cache operation most code needs, and the only one that is hard to
get right — the loader runs **once per key** across concurrent callers, and the rest wait rather
than each computing their own. `getIfPresent`, `put`, `invalidate` and `invalidateAll` exist for
what it cannot express.
A loader returning `null` stores nothing and returns `null`. Caching absence is a decision, not a
default; wrap it in an `Optional` or a sentinel if you want it.
## Naming and sharing
`CacheManager.build(name, spec)` is idempotent per name: two handlers asking for `"users"` get one
cache, not two, so nobody has to coordinate who creates it first.
If they disagree about the spec, that throws. The alternative is a cache whose size depends on
which handler happened to initialise first, which is the kind of bug that only shows up under
load.
## Specs
```java
CacheSpec.of()
.maxSize(10_000)
.ttl(Duration.ofMinutes(10))
.recordStats();
```
Every field is optional, but a spec that sets neither `maxSize` nor `ttl` is an unbounded cache
that never expires — a memory leak wearing a hat. Set at least one.
`recordStats()` is off by default: counting costs a pair of atomic increments on every lookup, and
a cache nobody is measuring should not pay for numbers nobody reads. Without it, `stats()` returns
`CacheStats.DISABLED` rather than silently zero.
## Where the spec lives
On the cache, at the point it is built — not in application config. Size and TTL are properties of
*what is being cached*, not of the process doing the caching, and a TTL in a config file is a TTL
nobody can relate back to the data it governs.
## Writing a backend
Implement `CacheManager` and `Cache`, provide the manager from a `FlashExtension`, and register
cleanup with `FlashContext.onClose` so a stopped app does not keep its values alive.
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-cache-core</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,50 @@
package dev.relism.flash.ext.cache;
import java.util.function.Function;
/**
* A named cache. Obtained from a {@link CacheManager}, safe to hold in a handler field and share
* across threads.
*
* <pre>{@code
* User user = users.get(id, repo::findById);
* }</pre>
*
* <p>{@link #get} is the only method most code needs. The rest exist for the cases it cannot
* express: reading without populating, writing a value computed elsewhere, and invalidating.
*
* @param <K> key type — must have a stable {@code hashCode}/{@code equals}
* @param <V> value type
*/
public interface Cache<K, V> {
/**
* Returns the cached value, computing and storing it with {@code loader} if absent.
*
* <p>The loader runs at most once per key across concurrent callers; the others wait for it
* rather than each computing their own. A loader returning {@code null} stores nothing and
* {@code null} is returned.
*/
V get(K key, Function<? super K, ? extends V> loader);
/** The cached value, or {@code null} if absent. Never invokes a loader. */
V getIfPresent(K key);
/** Stores {@code value}, replacing any existing entry. */
void put(K key, V value);
/** Drops {@code key}. Does nothing if it was absent. */
void invalidate(K key);
/** Drops every entry. */
void invalidateAll();
/** Approximate entry count. Approximate because eviction is asynchronous in most backends. */
long estimatedSize();
/**
* Hit/miss counters since this cache was built, or {@link CacheStats#DISABLED} when the
* backend was not asked to record them.
*/
CacheStats stats();
}
@@ -0,0 +1,31 @@
package dev.relism.flash.ext.cache;
import java.util.function.Consumer;
/**
* Creates and hands back named caches. Resolve it with {@code require(CacheManager.class)}; a
* backend extension such as {@code flash-ext-cache-caffeine} provides the implementation.
*
* <pre>{@code
* @Override protected void onInit() {
* users = require(CacheManager.class).build("users", spec -> spec
* .maxSize(10_000)
* .ttl(Duration.ofMinutes(10)));
* }
* }</pre>
*
* <p>{@link #build} is idempotent per name: calling it twice returns the same cache rather than
* two, so several handlers can share one without coordinating who creates it. The spec of the
* first call wins; a later call with a different spec is a configuration mistake and throws.
*/
public interface CacheManager {
/** Creates the named cache, or returns the existing one. */
<K, V> Cache<K, V> build(String name, Consumer<CacheSpec> spec);
/** The named cache, or {@code null} if {@link #build} has not been called for it. */
<K, V> Cache<K, V> cache(String name);
/** Every cache name built so far, for an ops endpoint. */
java.util.Set<String> names();
}
@@ -0,0 +1,64 @@
package dev.relism.flash.ext.cache;
import java.time.Duration;
/**
* How one cache should behave. Every field is optional — a spec that sets nothing gives an
* unbounded cache that never expires, which is a memory leak wearing a hat, so set at least one
* of {@link #maxSize} or {@link #ttl}.
*
* <pre>{@code
* CacheSpec.of().maxSize(10_000).ttl(Duration.ofMinutes(10))
* }</pre>
*
* <p>Mutable builder rather than a record with {@code withX} copies: it is constructed once at
* boot inside a lambda and never shared.
*/
public final class CacheSpec {
private long maxSize = -1;
private Duration ttl;
private Duration ttlAfterAccess;
private boolean recordStats;
private CacheSpec() {}
public static CacheSpec of() {
return new CacheSpec();
}
/** Maximum entries before the backend starts evicting. Negative means unbounded. */
public CacheSpec maxSize(long maxSize) {
this.maxSize = maxSize;
return this;
}
/** Entries expire this long after they were written. */
public CacheSpec ttl(Duration ttl) {
this.ttl = ttl;
return this;
}
/** Entries expire this long after they were last read or written. */
public CacheSpec ttlAfterAccess(Duration ttlAfterAccess) {
this.ttlAfterAccess = ttlAfterAccess;
return this;
}
/**
* Records hit/miss counters for {@link Cache#stats()}.
*
* <p>Off by default: counting costs a pair of atomic increments on every lookup, and a cache
* nobody is measuring should not pay for numbers nobody reads.
*/
public CacheSpec recordStats() {
this.recordStats = true;
return this;
}
public long maxSize() { return maxSize; }
public Duration ttl() { return ttl; }
public Duration ttlAfterAccess() { return ttlAfterAccess; }
public boolean statsRecorded() { return recordStats; }
public boolean bounded() { return maxSize >= 0; }
}
@@ -0,0 +1,20 @@
package dev.relism.flash.ext.cache;
/**
* Hit/miss counters for one cache.
*
* @param hits lookups that found a value
* @param misses lookups that had to load
* @param evictions entries dropped to respect {@link CacheSpec#maxSize()}
*/
public record CacheStats(long hits, long misses, long evictions) {
/** Returned when {@link CacheSpec#recordStats()} was not set — all zero, and says so. */
public static final CacheStats DISABLED = new CacheStats(0, 0, 0);
/** Hits divided by lookups, or 0 when nothing has been looked up yet. */
public double hitRate() {
long total = hits + misses;
return total == 0 ? 0 : (double) hits / total;
}
}