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:
co-authored by
Claude Opus 5
parent
58bae41f7a
commit
24bb10175d
@@ -38,7 +38,7 @@ Format: `<type>(<scope>): <short description>`
|
||||
|
||||
Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`,
|
||||
`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`,
|
||||
`ext-mcp`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`.
|
||||
`ext-mcp`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `ext-cache-core`, `ext-cache-caffeine`, `release`, `deps`, `ci`.
|
||||
|
||||
Examples:
|
||||
```
|
||||
|
||||
@@ -16,6 +16,10 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
|
||||
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
|
||||
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
|
||||
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
|
||||
| `flash-extensions/flash-ext-validation` | Request validation — jakarta constraints, compiled once per type |
|
||||
| `flash-extensions/flash-ext-scheduler` | Interval and cron background jobs on virtual threads |
|
||||
| `flash-extensions/flash-ext-cache-core` | Caching contract — `Cache`, `CacheManager`, `CacheSpec` |
|
||||
| `flash-extensions/flash-ext-cache-caffeine` | In-process cache backed by Caffeine |
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -146,6 +150,9 @@ See extension-specific READMEs for full details:
|
||||
- [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md)
|
||||
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md)
|
||||
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
|
||||
- [`flash-ext-validation`](flash-extensions/flash-ext-validation/docs/README.md)
|
||||
- [`flash-ext-scheduler`](flash-extensions/flash-ext-scheduler/docs/README.md)
|
||||
- [`flash-ext-cache-caffeine`](flash-extensions/flash-ext-cache-caffeine/docs/README.md)
|
||||
- [`flash-testing`](flash-testing/docs/README.md)
|
||||
|
||||
## Error handlers
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# flash-ext-cache-caffeine
|
||||
|
||||
In-process caching backed by [Caffeine](https://github.com/ben-manes/caffeine). Implements
|
||||
[`flash-ext-cache-core`](../../flash-ext-cache-core/docs/README.md).
|
||||
|
||||
## Dependency
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-cache-caffeine</artifactId>
|
||||
<version>${flash.version}</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```java
|
||||
FlashApp.create(8080)
|
||||
.install(new CaffeineCacheExtension())
|
||||
.scan("dev.example.api");
|
||||
```
|
||||
|
||||
```java
|
||||
@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
|
||||
|
||||
```java
|
||||
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.
|
||||
@@ -0,0 +1,39 @@
|
||||
<?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-caffeine</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-cache-core</artifactId>
|
||||
</dependency>
|
||||
<!--
|
||||
Caffeine rather than a hand-rolled LRU: W-TinyLFU admission, striped counters and
|
||||
amortised eviction are not a weekend's work to get right, and getting them wrong is a
|
||||
cache that is slower than no cache.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.relism.flash.ext.cache.caffeine;
|
||||
|
||||
import dev.relism.flash.ext.cache.Cache;
|
||||
import dev.relism.flash.ext.cache.CacheStats;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* {@link Cache} over a Caffeine cache. A thin adapter by design: every method delegates directly,
|
||||
* adding no wrapper object, no copy and no synchronisation of its own.
|
||||
*/
|
||||
final class CaffeineCache<K, V> implements Cache<K, V> {
|
||||
|
||||
private final com.github.benmanes.caffeine.cache.Cache<K, V> delegate;
|
||||
private final boolean statsRecorded;
|
||||
|
||||
CaffeineCache(com.github.benmanes.caffeine.cache.Cache<K, V> delegate, boolean statsRecorded) {
|
||||
this.delegate = delegate;
|
||||
this.statsRecorded = statsRecorded;
|
||||
}
|
||||
|
||||
@Override
|
||||
public V get(K key, Function<? super K, ? extends V> loader) {
|
||||
// Caffeine's own get(key, mappingFunction) already guarantees the loader runs once per key
|
||||
// across concurrent callers; wrapping it in anything of ours would only add a race.
|
||||
return delegate.get(key, loader);
|
||||
}
|
||||
|
||||
@Override public V getIfPresent(K key) { return delegate.getIfPresent(key); }
|
||||
@Override public void put(K key, V value) { delegate.put(key, value); }
|
||||
@Override public void invalidate(K key) { delegate.invalidate(key); }
|
||||
@Override public void invalidateAll() { delegate.invalidateAll(); }
|
||||
@Override public long estimatedSize() { return delegate.estimatedSize(); }
|
||||
|
||||
@Override
|
||||
public CacheStats stats() {
|
||||
if (!statsRecorded) return CacheStats.DISABLED;
|
||||
com.github.benmanes.caffeine.cache.stats.CacheStats snapshot = delegate.stats();
|
||||
return new CacheStats(snapshot.hitCount(), snapshot.missCount(), snapshot.evictionCount());
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.relism.flash.ext.cache.caffeine;
|
||||
|
||||
import dev.relism.flash.ext.cache.CacheManager;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
|
||||
/**
|
||||
* Installs an in-process {@link CacheManager} backed by Caffeine.
|
||||
*
|
||||
* <pre>{@code
|
||||
* FlashApp.create(8080)
|
||||
* .install(new CaffeineCacheExtension())
|
||||
* .scan("dev.example.api");
|
||||
* }</pre>
|
||||
*
|
||||
* <p>No configuration. Each cache declares its own size and TTL where it is built, because those
|
||||
* are properties of what is being cached, not of the process caching it.
|
||||
*
|
||||
* <p>Caches are dropped through {@link FlashContext#onClose}, so a stopped app does not keep its
|
||||
* values alive — which matters when many apps start and stop in one JVM, as they do under test.
|
||||
*/
|
||||
public final class CaffeineCacheExtension implements FlashExtension {
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.supply(CacheManager.class, services -> {
|
||||
CaffeineCacheManager manager = new CaffeineCacheManager();
|
||||
services.onClose(manager::clear);
|
||||
return manager;
|
||||
});
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package dev.relism.flash.ext.cache.caffeine;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import dev.relism.flash.ext.cache.Cache;
|
||||
import dev.relism.flash.ext.cache.CacheManager;
|
||||
import dev.relism.flash.ext.cache.CacheSpec;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/** In-process {@link CacheManager} backed by Caffeine. */
|
||||
final class CaffeineCacheManager implements CacheManager {
|
||||
|
||||
private final Map<String, Entry> caches = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public <K, V> Cache<K, V> build(String name, java.util.function.Consumer<CacheSpec> configure) {
|
||||
CacheSpec spec = CacheSpec.of();
|
||||
configure.accept(spec);
|
||||
|
||||
Entry entry = caches.computeIfAbsent(name, key -> new Entry(describe(spec), create(spec)));
|
||||
// Two handlers sharing a cache is the point; two handlers disagreeing about its size or
|
||||
// TTL is a bug that would otherwise resolve to whichever one ran first.
|
||||
String requested = describe(spec);
|
||||
if (!entry.signature.equals(requested))
|
||||
throw new IllegalStateException("Cache '" + name + "' already exists as " + entry.signature
|
||||
+ " but was requested as " + requested);
|
||||
return (Cache<K, V>) entry.cache;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public <K, V> Cache<K, V> cache(String name) {
|
||||
Entry entry = caches.get(name);
|
||||
return entry == null ? null : (Cache<K, V>) entry.cache;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> names() {
|
||||
return Set.copyOf(caches.keySet());
|
||||
}
|
||||
|
||||
/** Releases every entry so a stopped app does not keep its values alive. */
|
||||
void clear() {
|
||||
caches.values().forEach(entry -> entry.cache.invalidateAll());
|
||||
caches.clear();
|
||||
}
|
||||
|
||||
private static CaffeineCache<Object, Object> create(CacheSpec spec) {
|
||||
Caffeine<Object, Object> builder = Caffeine.newBuilder();
|
||||
if (spec.bounded()) builder.maximumSize(spec.maxSize());
|
||||
if (spec.ttl() != null) builder.expireAfterWrite(spec.ttl());
|
||||
if (spec.ttlAfterAccess() != null) builder.expireAfterAccess(spec.ttlAfterAccess());
|
||||
if (spec.statsRecorded()) builder.recordStats();
|
||||
return new CaffeineCache<>(builder.build(), spec.statsRecorded());
|
||||
}
|
||||
|
||||
private static String describe(CacheSpec spec) {
|
||||
return "maxSize=" + spec.maxSize() + " ttl=" + spec.ttl()
|
||||
+ " ttlAfterAccess=" + spec.ttlAfterAccess() + " stats=" + spec.statsRecorded();
|
||||
}
|
||||
|
||||
private record Entry(String signature, CaffeineCache<Object, Object> cache) {}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
package dev.relism.flash.ext.cache.caffeine;
|
||||
|
||||
import dev.relism.flash.ext.cache.Cache;
|
||||
import dev.relism.flash.ext.cache.CacheManager;
|
||||
import dev.relism.flash.ext.cache.CacheStats;
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class CaffeineCacheTest {
|
||||
|
||||
private static final AtomicInteger loads = new AtomicInteger();
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest app = FlashTest.of(configured -> {
|
||||
configured.install(new CaffeineCacheExtension());
|
||||
configured.ctx().onReady(() -> {
|
||||
Cache<String, String> users = configured.ctx().require(CacheManager.class)
|
||||
.build("users", spec -> spec.maxSize(100).ttl(Duration.ofMinutes(5)).recordStats());
|
||||
configured.get("/users/{id}", (req, res) ->
|
||||
users.get(req.param("id"), id -> "loaded:" + id + ":" + loads.incrementAndGet()));
|
||||
});
|
||||
});
|
||||
|
||||
private static CacheManager manager() {
|
||||
return app.app().ctx().require(CacheManager.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRepeatedRequestIsServedFromCache() {
|
||||
String first = app.get("/users/alice").expectStatus(200).body();
|
||||
String second = app.get("/users/alice").expectStatus(200).body();
|
||||
|
||||
assertEquals(first, second);
|
||||
assertTrue(first.startsWith("loaded:alice:"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void distinctKeysLoadSeparately() {
|
||||
assertNotEquals(app.get("/users/bob").body(), app.get("/users/carol").body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statsCountHitsAndMisses() {
|
||||
Cache<String, String> cache = manager().build("stats-probe", spec -> spec.maxSize(10).recordStats());
|
||||
cache.get("k", key -> "v");
|
||||
cache.get("k", key -> "v");
|
||||
|
||||
CacheStats stats = cache.stats();
|
||||
assertEquals(1, stats.misses());
|
||||
assertEquals(1, stats.hits());
|
||||
assertEquals(0.5, stats.hitRate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statsAreDisabledUnlessAskedFor() {
|
||||
Cache<String, String> cache = manager().build("no-stats", spec -> spec.maxSize(10));
|
||||
cache.get("k", key -> "v");
|
||||
|
||||
assertEquals(CacheStats.DISABLED, cache.stats());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theLoaderRunsOncePerKeyUnderConcurrency() throws Exception {
|
||||
Cache<String, String> cache = manager().build("single-flight", spec -> spec.maxSize(10));
|
||||
AtomicInteger invocations = new AtomicInteger();
|
||||
int threads = 16;
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
CountDownLatch done = new CountDownLatch(threads);
|
||||
|
||||
for (int i = 0; i < threads; i++) {
|
||||
Thread.ofVirtual().start(() -> {
|
||||
try {
|
||||
start.await();
|
||||
cache.get("hot", key -> {
|
||||
invocations.incrementAndGet();
|
||||
return "value";
|
||||
});
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
start.countDown();
|
||||
assertTrue(done.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
|
||||
assertEquals(1, invocations.get(), "concurrent callers must share one load, not race");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNullLoaderResultStoresNothing() {
|
||||
Cache<String, String> cache = manager().build("nulls", spec -> spec.maxSize(10));
|
||||
|
||||
assertNull(cache.get("missing", key -> null));
|
||||
assertNull(cache.getIfPresent("missing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidateDropsOneKeyAndInvalidateAllDropsEverything() {
|
||||
Cache<String, String> cache = manager().build("invalidation", spec -> spec.maxSize(10));
|
||||
cache.put("a", "1");
|
||||
cache.put("b", "2");
|
||||
|
||||
cache.invalidate("a");
|
||||
assertNull(cache.getIfPresent("a"));
|
||||
assertEquals("2", cache.getIfPresent("b"));
|
||||
|
||||
cache.invalidateAll();
|
||||
assertNull(cache.getIfPresent("b"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildIsIdempotentPerName() {
|
||||
Cache<String, String> first = manager().build("shared", spec -> spec.maxSize(10));
|
||||
Cache<String, String> second = manager().build("shared", spec -> spec.maxSize(10));
|
||||
|
||||
assertSame(first, second, "two handlers asking for one cache must get one cache");
|
||||
assertSame(first, manager().cache("shared"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void disagreeingOnASharedCacheIsARejectedMistakeNotASilentWinner() {
|
||||
manager().build("contested", spec -> spec.maxSize(10));
|
||||
|
||||
IllegalStateException conflict = assertThrows(IllegalStateException.class,
|
||||
() -> manager().build("contested", spec -> spec.maxSize(999)));
|
||||
assertTrue(conflict.getMessage().contains("contested"), conflict.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownNameReturnsNullRatherThanBuildingOne() {
|
||||
assertNull(manager().cache("never-built"));
|
||||
}
|
||||
|
||||
/** Why CaffeineCacheExtension registers onClose: values must not outlive the app holding them. */
|
||||
@Test
|
||||
void stoppingTheAppReleasesEveryCache() {
|
||||
FlashApp standalone = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build())
|
||||
.install(new CaffeineCacheExtension());
|
||||
standalone.start();
|
||||
CacheManager manager = standalone.ctx().require(CacheManager.class);
|
||||
manager.build("scoped", spec -> spec.maxSize(10)).put("k", "v");
|
||||
assertEquals(1, manager.names().size());
|
||||
|
||||
standalone.stop().join();
|
||||
|
||||
assertTrue(manager.names().isEmpty(), "caches must be released when the app stops");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
+50
@@ -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();
|
||||
}
|
||||
Vendored
+31
@@ -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();
|
||||
}
|
||||
Vendored
+64
@@ -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; }
|
||||
}
|
||||
Vendored
+20
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# flash-ext-cache-redis — planned
|
||||
|
||||
Not implemented. This directory holds the design so the decision is written down rather than
|
||||
rediscovered; there is deliberately **no module, no pom and no source**, because an empty module
|
||||
that builds an empty jar is dead weight in the reactor and in everyone's dependency tree.
|
||||
|
||||
Add it when there is a second replica that actually needs shared state.
|
||||
|
||||
## What it would implement
|
||||
|
||||
`CacheManager` and `Cache` from [`flash-ext-cache-core`](../../flash-ext-cache-core/docs/README.md),
|
||||
so switching backend is an install-line change:
|
||||
|
||||
```java
|
||||
.install(new RedisCacheExtension(RedisConfig.of("redis://localhost:6379")))
|
||||
```
|
||||
|
||||
## The part that is not a drop-in
|
||||
|
||||
`flash-ext-cache-caffeine` cannot fail. A networked cache can, and that changes the contract in
|
||||
ways an adapter cannot hide:
|
||||
|
||||
- **`get(key, loader)` can fail before reaching the loader.** The honest default is to fall
|
||||
through to the loader and serve the value uncached, so Redis being down degrades throughput
|
||||
rather than taking the application with it. That has to be a decision, not an accident.
|
||||
- **Values must be serialized.** Caffeine stores references. A `byte[]` codec belongs in the spec,
|
||||
and the natural default is whatever `flash-ext-jackson` is already configured with.
|
||||
- **`invalidateAll()` is not free.** Against a shared keyspace it is either a scan or a key
|
||||
prefix per cache name. The prefix is the right answer, and it means cache names become part of
|
||||
the wire contract.
|
||||
- **Stats are per-client, not per-cache.** Hit rate stays meaningful; eviction count does not,
|
||||
because Redis evicts on its own policy.
|
||||
|
||||
## Why it is not built yet
|
||||
|
||||
Nothing in the codebase has two replicas sharing cache state. Building it now would mean choosing
|
||||
a client library, a serialization format and a failure policy with no real usage to check them
|
||||
against — and the failure policy in particular is the kind of decision that is wrong until a
|
||||
production incident tells you otherwise.
|
||||
@@ -27,10 +27,27 @@
|
||||
<module>flash-ext-data-core</module>
|
||||
<module>flash-ext-data-jdbc</module>
|
||||
<module>flash-ext-data-hibernate</module>
|
||||
<module>flash-ext-cache-core</module>
|
||||
<module>flash-ext-cache-caffeine</module>
|
||||
</modules>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-cache-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-cache-caffeine</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
<version>${caffeine.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<maven.versions.plugin.version>2.18.0</maven.versions.plugin.version>
|
||||
<jmh.version>1.37</jmh.version>
|
||||
<junit.version>5.11.0</junit.version>
|
||||
<caffeine.version>3.1.8</caffeine.version>
|
||||
<build.helper.plugin.version>3.6.0</build.helper.plugin.version>
|
||||
</properties>
|
||||
|
||||
@@ -67,6 +68,16 @@
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-cache-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-cache-caffeine</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
|
||||
Reference in New Issue
Block a user