Skip to content

Caching Managers

Petrus Pradella edited this page Jun 17, 2026 · 6 revisions

Caching Managers

What this page covers: the CachingManager<K, V> — the cache-backed façade that sits in front of one core Repository, owns an in-memory identity map (one live instance per key), and is the RefResolver a Ref resolves through. We cover the full method surface, the cell / stamp / tombstone model that makes concurrent reads and writes safe, write-through, the N+1 antidote (getAll), and how to create one (RefRegistry.manager vs subclassing). This deepens the Caching & References overview — read that first.

📌 NoteCachingManager lives in the optional everydatabase-manager add-on. A manager is the cache; a Ref is just the pointer (see Typed References (Ref)). One manager per entity type.


The smallest example

import br.com.finalcraft.everydatabase.manager.CachingManager;
import br.com.finalcraft.everydatabase.manager.RefRegistry;
import br.com.finalcraft.everydatabase.manager.cache.CachePolicy;

RefRegistry refRegistry = new RefRegistry();

EntityDescriptor<UUID, Guild> GUILDS = EntityDescriptor.builder(UUID.class, Guild.class)
        .collection("guilds")
        .keyExtractor(Guild::getId)
        .codec(refRegistry.codec(Guild.class))     // ref-aware codec bound to 'refRegistry'
        .build();

// Create + register the manager in one call:
CachingManager<UUID, Guild> guilds = refRegistry.manager(GUILDS, storage, CachePolicy.always());

Optional<Guild> hot = guilds.peek(guildId);                       // sync, cache-only
guilds.resolve(guildId).thenAccept(opt -> render(opt));           // async, load-on-miss + cache
guilds.saveAndCache(updatedGuild).join();                         // write-through

Everything async returns a CompletableFuture; .join() is shown for brevity. Compose with thenApply/thenCompose — see The Async API. There are no blocking variants.


What a manager is

A CachingManager<K, V> wraps exactly one Repository<K, V> (obtained from the Storage for its descriptor) and adds an in-memory cache with identity-map semantics: for as long as a key stays cached, the manager hands out the same instance. It implements RefResolver<K, V>, which is the contract a Ref looks up in its registry. Construction self-registers it (keyed by the entity type) in the RefRegistry you pass.

public class CachingManager<K, V> implements RefResolver<K, V> {
    public CachingManager(EntityDescriptor<K, V> descriptor, Storage storage,
                          CacheOptions options, RefRegistry registry) { … }
    public CachingManager(EntityDescriptor<K, V> descriptor, Storage storage,
                          CachePolicy policy, RefRegistry registry) { … }   // unbounded convenience
}

⚠️ Gotcha — the constructor registers itself in the registry. Create your managers at startup, before any code resolves a ref of that type; an unregistered type makes resolve() return a failed future and peek() throw. See Typed References (Ref) → Binding.


Creating one: RefRegistry.manager vs subclassing

The common case — RefRegistry.manager(...) creates and registers in one call:

CachingManager<UUID, Guild> guilds =
        refRegistry.manager(GUILDS, storage, CachePolicy.always());                    // unbounded
CachingManager<UUID, Guild> bounded =
        refRegistry.manager(GUILDS, storage, CacheOptions.builder()
                .policy(CachePolicy.always())
                .maxSize(1000)
                .build());                                                       // bounded LRU

A domain-named subclass reads better at call sites and centralises the cache policy — pass the registry through to super(...):

public final class GuildManager extends CachingManager<UUID, Guild> {
    public GuildManager(Storage storage, RefRegistry registry) {
        super(GUILDS, storage, CacheOptions.builder()
                .policy(CachePolicy.always())   // small hot set: keep resident
                .maxSize(1000)                  // ...but bounded by LRU
                .build(), registry);            // self-registers in 'registry'
    }
}

GuildManager guilds = new GuildManager(storage, refRegistry);   // registered in 'refRegistry'

Both paths are equivalent — pick whichever reads better. Freshness (CachePolicy) vs capacity (CacheOptions.maxSize) is its own topic: see Cache Policies & Freshness.


The method surface

Reads (value-level)

Optional<Guild>                     one = guilds.peek(guildId);              // cache-only
Optional<Guild>                  oneTtl = guilds.peek(guildId, policy);      // cache-only, given policy
CompletableFuture<Optional<Guild>>  fut = guilds.resolve(guildId);           // load on miss + cache
CompletableFuture<Optional<Guild>> futP = guilds.resolve(guildId, policy);   // ...with a policy override

These are the same peek/resolve a Ref calls — a Ref just supplies the key it holds. peek never does I/O and returns empty on a miss/stale; resolve serves a fresh hit or loads-and-caches.

getAll — the N+1 antidote

Resolving refs one-by-one in a loop is N+1. When you hold the keys up front, getAll serves cache hits from memory and fetches only the misses in a single findMany:

List<UUID> guildIds = onlinePlayers.stream().map(p -> p.getGuild().key()).collect(toList());
List<Guild> loaded  = guilds.getAll(guildIds).join();   // partial cache hits + one backend round-trip
public CompletableFuture<List<V>> getAll(Collection<K> keys);

Misses are loaded and cached (their cells updated in place); the result order is not significant. A noCache default policy turns getAll into a plain batched fetch that doesn't populate the cache.

preloadAll — mirror a hot set

guilds.preloadAll().join();   // installs every guild from the backend (where absent)
public CompletableFuture<Void> preloadAll();

preloadAll installs entries only where absent, so a concurrently-saved value is never clobbered and held instances stay stable (call clearCache() first for a hard refresh).

📌 Note — with a bounded maxSize, preloadAll() of a collection larger than the bound is truncated by LRU; it's a full mirror only when the cache is unbounded or maxSize covers the collection. preloadAll does not cascade into nested refs — each type has its own manager and is loaded independently.

Writes & deletes (write-through)

Prefer the cache-aware pair over repository().save/delete, which touch the backend but leave a stale cache entry behind:

public CompletableFuture<Void>    saveAndCache(V value);    // persist + update the cell in place
public CompletableFuture<Boolean> deleteAndEvict(K key);    // delete + tombstone (returns: existed?)
guilds.saveAndCache(guild).join();   // memoized Ref handles immediately see the new value
guilds.deleteAndEvict(id).join();    // backend delete + a tombstone that blocks racy reloads
  • saveAndCache persists, then updates the key's cell in place with the saved instance — so every memoized Ref observes it on the next read. On an OptimisticLockException it auto-evicts the stale entry (so the next read reloads the current backend state) and still propagates the exception. See Optimistic Locking.
  • deleteAndEvict deletes from the backend and turns the slot into a stamp-ordered tombstone (not a bare removal), so a concurrent in-flight reload that read the entity before the delete cannot re-install it. The eviction always runs, whether or not the key was cached.

Invalidation knobs

guilds.invalidate(key);     // mark stale → next read reloads (lazy)
guilds.evict(key);          // remove the entry outright
guilds.invalidateAll();     // mark every entry stale
guilds.clearCache();        // empty the cache
int n = guilds.purgeExpired();  // drop entries the default policy no longer deems fresh; returns count

purgeExpired() matters for memory: cached entries are held by strong references, so TTL governs freshness, not memory — an expired-but-untouched entry isn't GC-eligible until overwritten or evicted. Call purgeExpired() periodically and/or bound the store with maxSize. See Cache Policies & Freshness.

Accessors

Repository<UUID, Guild> repo = guilds.repository();   // the UNCACHED backend (queries, bypassing reads)
Class<Guild>            type = guilds.type();
int                     size = guilds.cachedSize();   // live cells only — tombstones are NOT counted

repository() is your escape hatch for query(...), findBy(...), count(), and any cache-bypassing read; see Indexing & Queries and CRUD Operations.


The identity map: cells, stamps, and tombstones

This is the model that makes concurrent reads and writes safe. The working mental model:

  • Cell. Each key maps to a stable CacheEntry cell for its cache lifetime. A Ref memoizes this cell (see Typed References (Ref)). What is stable is the cell, not necessarily the value instance.
  • In-place updates. A write or reload swaps the value inside the same cell rather than replacing the cell — which is exactly why a memoized Ref sees new values without re-resolving.
  • Stamps. Every publish (write or reload) carries a monotonic stamp. A slower in-flight reload can never regress a newer write: the stale value loses the stamp comparison and is discarded.
  • Cold-miss convergence. Concurrent cold misses for the same key converge on one instance (the first installed cell wins, keep-first) — they never each install their own copy.
  • Tombstones. A deleteAndEvict leaves a stamp-ordered tombstone in the slot. A slower reload that read the entity before the delete can't resurrect it (same stamp guard). The tombstone is cleared by a later re-save (which resurrects the entity), by purgeExpired(), or by LRU eviction.
flowchart LR
    K[key] --> Cell[CacheEntry cell]
    Cell -->|"value swapped in place,<br/>guarded by a monotonic stamp"| V[live value]
    Cell -->|memoized by| R[Ref handle]
    Cell -. deleteAndEvict .-> T[(stamp-ordered tombstone)]
Loading

📌 NotecachedSize() counts live cells only; a tombstone is a present-but-not-live slot and is not counted.


What it guarantees (and what it doesn't)

The cache hands out the same instance per key for its lifetime, publication is atomic and stamp-ordered, but the value is shared mutable state and freshness is bounded:

Hazard Solved by optimistic lock? What solves it
Read staleness (you miss another process's update) TTL / invalidate(key)
Write staleness (you overwrite a newer copy) ✅ (versioned backends) OptimisticLockException → auto-evict + reload
Mutating a swapped-out value mutate through saveAndCache, or single-writer discipline

⚠️ Gotcha — cross-process writes are invisible to a local cache. Bound them with a TTL and/or wire an external invalidation signal (e.g. Redis pub/sub) to manager.evict(key) / manager.invalidate(key). Optimistic locking only protects the write side, and only on versioned backends that enforce it (MySQL/MariaDB, PostgreSQL, Mongo) — see Optimistic Locking.


See also

Clone this wiki locally