Files
Flash5/flash-extensions/flash-ext-cache-core/docs
Zakaria El OrcheandClaude Opus 5 24bb10175d 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>
2026-09-09 12:30:03 +00:00
..

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 for in-process caching.

The one shape that matters

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

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.