diff --git a/AGENTS.md b/AGENTS.md index 6ee7f45..3a6fa6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ Format: `(): ` 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: ``` diff --git a/README.md b/README.md index 84cf184..10ac1e4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/flash-extensions/flash-ext-cache-caffeine/docs/README.md b/flash-extensions/flash-ext-cache-caffeine/docs/README.md new file mode 100644 index 0000000..757ed16 --- /dev/null +++ b/flash-extensions/flash-ext-cache-caffeine/docs/README.md @@ -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 + + dev.relism + flash-ext-cache-caffeine + ${flash.version} + +``` + +## 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 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. diff --git a/flash-extensions/flash-ext-cache-caffeine/pom.xml b/flash-extensions/flash-ext-cache-caffeine/pom.xml new file mode 100644 index 0000000..0141f3d --- /dev/null +++ b/flash-extensions/flash-ext-cache-caffeine/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-cache-caffeine + + + + dev.relism + flash-ext-cache-core + + + + com.github.ben-manes.caffeine + caffeine + + + org.junit.jupiter + junit-jupiter + + + dev.relism + flash-testing + test + + + diff --git a/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCache.java b/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCache.java new file mode 100644 index 0000000..b884194 --- /dev/null +++ b/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCache.java @@ -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 implements Cache { + + private final com.github.benmanes.caffeine.cache.Cache delegate; + private final boolean statsRecorded; + + CaffeineCache(com.github.benmanes.caffeine.cache.Cache delegate, boolean statsRecorded) { + this.delegate = delegate; + this.statsRecorded = statsRecorded; + } + + @Override + public V get(K key, Function 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()); + } +} diff --git a/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheExtension.java b/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheExtension.java new file mode 100644 index 0000000..f7b39f3 --- /dev/null +++ b/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheExtension.java @@ -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. + * + *
{@code
+ * FlashApp.create(8080)
+ *     .install(new CaffeineCacheExtension())
+ *     .scan("dev.example.api");
+ * }
+ * + *

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. + * + *

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; + }); + } +} diff --git a/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheManager.java b/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheManager.java new file mode 100644 index 0000000..7f6332c --- /dev/null +++ b/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheManager.java @@ -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 caches = new ConcurrentHashMap<>(); + + @Override + @SuppressWarnings("unchecked") + public Cache build(String name, java.util.function.Consumer 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) entry.cache; + } + + @Override + @SuppressWarnings("unchecked") + public Cache cache(String name) { + Entry entry = caches.get(name); + return entry == null ? null : (Cache) entry.cache; + } + + @Override + public Set 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 create(CacheSpec spec) { + Caffeine 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 cache) {} +} diff --git a/flash-extensions/flash-ext-cache-caffeine/src/test/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheTest.java b/flash-extensions/flash-ext-cache-caffeine/src/test/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheTest.java new file mode 100644 index 0000000..8c85a42 --- /dev/null +++ b/flash-extensions/flash-ext-cache-caffeine/src/test/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheTest.java @@ -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 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 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 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 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 cache = manager().build("nulls", spec -> spec.maxSize(10)); + + assertNull(cache.get("missing", key -> null)); + assertNull(cache.getIfPresent("missing")); + } + + @Test + void invalidateDropsOneKeyAndInvalidateAllDropsEverything() { + Cache 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 first = manager().build("shared", spec -> spec.maxSize(10)); + Cache 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"); + } +} diff --git a/flash-extensions/flash-ext-cache-core/docs/README.md b/flash-extensions/flash-ext-cache-core/docs/README.md new file mode 100644 index 0000000..ffdc3d2 --- /dev/null +++ b/flash-extensions/flash-ext-cache-core/docs/README.md @@ -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` — 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. diff --git a/flash-extensions/flash-ext-cache-core/pom.xml b/flash-extensions/flash-ext-cache-core/pom.xml new file mode 100644 index 0000000..0a825be --- /dev/null +++ b/flash-extensions/flash-ext-cache-core/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-cache-core + + + + dev.relism + flash + + + org.junit.jupiter + junit-jupiter + + + diff --git a/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/Cache.java b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/Cache.java new file mode 100644 index 0000000..eb27faf --- /dev/null +++ b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/Cache.java @@ -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. + * + *

{@code
+ * User user = users.get(id, repo::findById);
+ * }
+ * + *

{@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 key type — must have a stable {@code hashCode}/{@code equals} + * @param value type + */ +public interface Cache { + + /** + * Returns the cached value, computing and storing it with {@code loader} if absent. + * + *

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 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(); +} diff --git a/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheManager.java b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheManager.java new file mode 100644 index 0000000..26adb07 --- /dev/null +++ b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheManager.java @@ -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. + * + *

{@code
+ * @Override protected void onInit() {
+ *     users = require(CacheManager.class).build("users", spec -> spec
+ *             .maxSize(10_000)
+ *             .ttl(Duration.ofMinutes(10)));
+ * }
+ * }
+ * + *

{@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. */ + Cache build(String name, Consumer spec); + + /** The named cache, or {@code null} if {@link #build} has not been called for it. */ + Cache cache(String name); + + /** Every cache name built so far, for an ops endpoint. */ + java.util.Set names(); +} diff --git a/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheSpec.java b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheSpec.java new file mode 100644 index 0000000..3897ce6 --- /dev/null +++ b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheSpec.java @@ -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}. + * + *

{@code
+ * CacheSpec.of().maxSize(10_000).ttl(Duration.ofMinutes(10))
+ * }
+ * + *

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()}. + * + *

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; } +} diff --git a/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheStats.java b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheStats.java new file mode 100644 index 0000000..a48276c --- /dev/null +++ b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheStats.java @@ -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; + } +} diff --git a/flash-extensions/flash-ext-cache-redis/docs/README.md b/flash-extensions/flash-ext-cache-redis/docs/README.md new file mode 100644 index 0000000..3723782 --- /dev/null +++ b/flash-extensions/flash-ext-cache-redis/docs/README.md @@ -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. diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml index 70452eb..17a9e53 100644 --- a/flash-extensions/pom.xml +++ b/flash-extensions/pom.xml @@ -27,10 +27,27 @@ flash-ext-data-core flash-ext-data-jdbc flash-ext-data-hibernate + flash-ext-cache-core + flash-ext-cache-caffeine + + dev.relism + flash-ext-cache-core + ${project.version} + + + dev.relism + flash-ext-cache-caffeine + ${project.version} + + + com.github.ben-manes.caffeine + caffeine + ${caffeine.version} + dev.relism flash-testing diff --git a/pom.xml b/pom.xml index aaa7c7e..a57d9f4 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,7 @@ 2.18.0 1.37 5.11.0 + 3.1.8 3.6.0 @@ -67,6 +68,16 @@ flash-testing ${project.version} + + dev.relism + flash-ext-cache-core + ${project.version} + + + dev.relism + flash-ext-cache-caffeine + ${project.version} + dev.relism flash-ext-jackson