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>
flash-ext-cache-caffeine
In-process caching backed by Caffeine. Implements
flash-ext-cache-core.
Dependency
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-cache-caffeine</artifactId>
<version>${flash.version}</version>
</dependency>
Quick start
FlashApp.create(8080)
.install(new CaffeineCacheExtension())
.scan("dev.example.api");
@GET("/api/users/{id}")
public final class GetUser extends RequestHandler {
private Cache<String, User> users;
private UserRepository repo;
@Override protected void onInit() {
repo = require(UserRepository.class);
users = require(CacheManager.class).build("users", spec -> spec
.maxSize(10_000)
.ttl(Duration.ofMinutes(10)));
}
@Override public Object handle(Request req, Response res) {
return users.get(req.param("id"), repo::findById);
}
}
The extension takes no configuration. Each cache declares its own size and TTL where it is built.
Why Caffeine and not a LinkedHashMap
An LRU on top of LinkedHashMap is about sixty lines, and for a cache that is genuinely
low-traffic it is the right answer — ConcurrentHashMap::computeIfAbsent is one line and has no
hit rate to get wrong.
This module exists for the case where that stops being true. Caffeine's W-TinyLFU admission, striped frequency counters and amortised eviction are not a weekend's work to reproduce, and the failure mode of getting them wrong is a cache that is slower than no cache — lock contention on every lookup, or an eviction policy that throws away exactly the entries you were about to want.
Lifecycle
Caches are released through FlashContext.onClose, so app.stop() drops every entry. That is
invisible in production with one app per process and matters immediately under test, where many
apps start and stop in one JVM.
Statistics
CacheStats stats = users.stats();
stats.hitRate(); // 0.0 until something is looked up
Requires recordStats() on the spec. Without it you get CacheStats.DISABLED, which is honest
about being unmeasured rather than reporting zeroes that look like a cold cache.
manager.names() lists every cache built so far, for an ops endpoint.
What this is not
HTTP caching. If what you want is for the client to stop asking — Cache-Control, ETag,
304 Not Modified — that is a middleware, not an object cache, and it saves the whole request
rather than the lookup inside it. Reach for that first: it is cheaper, and the two solve different
problems.
A shared cache. Every replica has its own. Two instances will hold different values for the
same key, and an invalidation on one does not reach the other. When that becomes a problem the
answer is a networked backend — see the note on flash-ext-cache-redis — and the semantics change
with it: a cache that can fail is no longer transparent.