Skip to content

Caching Managers

Petrus Pradella edited this page Jun 23, 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.

Batch write-through & lazy seeding

public V seedIfAbsent(K key, V value);                                       // cache, no I/O
public CompletableFuture<BatchSaveReport<K>> saveAllAndCache(Collection<V> values);
  • seedIfAbsent caches a freshly created instance without writing it (no I/O) and returns the instance now resident for that key. If another thread already seeded (or loaded) the key, its value is kept (keep-first) and that one is returned. It becomes the canonical cached object, so repeated reads hand back the same instance and accumulate mutations - persisted only when you flush it. This is the write-back "lazy default".
  • saveAllAndCache is the batch sibling of saveAndCache: it calls repository().saveAll(...) once, then updates each key's cell in place with the saved instance. If the batch fails it retries entity-by-entity, so one bad record never loses the rest; an OptimisticLockException on a record evicts that (stale) cell, any other error leaves the cell intact. The future completes normally - the per-key outcomes are in the returned BatchSaveReport (empty when every record saved).

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.


Write-back

The default behaviour is write-through: every mutation goes to the backend via saveAndCache before it is visible. Write-back is the opt-in alternative - mutate the cached instance in memory, then flush the accumulated changes in one batch later. An entity opts in one of two ways (pick one, never both - the same alternative split as Versioned vs @OptimisticLock for locking); nothing else changes for entities that opt into neither.

1. Implement IDirtyable - the explicit three-method contract:

package br.com.finalcraft.everydatabase.manager.cache;

public interface IDirtyable {
    boolean isDirty();
    void markClean();
    void markDirty();
}

The dirty flag is in-memory bookkeeping, never persisted - mark it transient and @JsonIgnore:

public class Account implements IDirtyable {
    private UUID id;
    private long balance;
    @JsonIgnore private transient boolean dirty;   // never persisted

    public void deposit(long n) { balance += n; markDirty(); }

    public boolean isDirty() { return dirty; }
    public void markClean()  { dirty = false; }
    public void markDirty()  { dirty = true; }
}

2. Annotate a boolean field with @DirtyFlag - no interface; the manager reads, clears and re-sets the field by reflection. Your own mutations set it true:

public class Account {
    private UUID id;
    private long balance;
    @DirtyFlag @JsonIgnore private transient boolean dirty;   // never persisted

    public void deposit(long n) { balance += n; this.dirty = true; }
}

The field must be boolean/Boolean, non-static, non-final, and at most one per entity (a Boolean that is still null reads as not-dirty). Combining @DirtyFlag with implements IDirtyable throws.

An entity that opts into neither form keeps the existing read-through / write-through behaviour unchanged.

Dirty wins. While isDirty() is true, the cached cell is always served and never reloaded over - not by a TTL policy, not by invalidate(...). Unflushed local changes therefore cannot be lost to a backend reload, and a TTL policy is safe even on a mutable entity. Once the value is flushed and clean again, the policy applies normally.

seedIfAbsent - the lazy default

Write-back usually starts from seedIfAbsent: cache a freshly created instance with no I/O, make it the canonical cached object, and accumulate mutations on it until you flush:

Account acc = accounts.seedIfAbsent(id, new Account(id, 0));  // cached, no I/O (keep-first)
acc.deposit(100);                                             // dirty; never reloaded over

See seedIfAbsent above for the keep-first semantics.

flushDirty - persist every dirty cell

public CompletableFuture<BatchSaveReport<K>> flushDirty();

flushDirty collects every dirty cell, calls markClean() on each, and persists them in one batch via saveAllAndCache:

BatchSaveReport<UUID> report = accounts.flushDirty().join();  // one batch write, flags cleared

Failure handling is per entity, and the future still completes normally:

  • A transient (non-conflict) error re-markDirty()s the entity, so the next flushDirty() retries it.
  • An OptimisticLockException evicts that cell (the entity reloads on the next read).

At-least-once. A value re-dirtied by another thread during the flush re-sets its own flag (the flush cleared it first, before persisting), so it is picked up by the next flush and never silently dropped. Inspect the returned BatchSaveReport for what failed.

BatchSaveReport

saveAllAndCache and flushDirty both return a BatchSaveReport<K> (package br.com.finalcraft.everydatabase.manager). It lists failures only - a fully clean flush yields an empty report:

public interface BatchSaveReport<K> {
    boolean isEmpty();
    boolean hasFailures();
    List<KeyOutcome<K>> failures();
    List<K> conflictedKeys();   // CONFLICT outcomes - these cells were evicted
    List<K> erroredKeys();      // ERROR outcomes - these cells were kept
}

public interface KeyOutcome<K> {
    K key();
    Status status();            // SAVED / CONFLICT / ERROR
    Throwable error();
}

conflictedKeys() are the keys whose cells were evicted (reload on next read); erroredKeys() are the keys whose cells were kept and that the next flushDirty() will retry.


Parent registries

A RefRegistry can be chained to a parent, so a subsystem keeps a private registry that falls back to a shared one - "private-then-shared" resolution with no process-wide global map:

public RefRegistry(RefRegistry parent);   // chained to a parent
public RefRegistry parent();              // the parent, or null

resolver(type) checks the registry itself first, then walks up the parent chain. A Ref bound to the child (and the ref-aware codec/ref it vends) resolves through the chain too. register(...) and manager(...) are always local - they never touch the parent. Publish the entities you want others to reference in the shared parent; keep private ones local.

RefRegistry global = new RefRegistry();
RefRegistry plugin = new RefRegistry(global);

global.manager(PLAYERS, storage, CachePolicy.always());  // shared (published in the parent)
plugin.manager(JOBS,    storage, CachePolicy.always());  // private (local to 'plugin')

plugin.resolver(Player.class);   // not local -> resolved via the parent (fallback)

Two registration-check variants: isRegistered(type) is local only; isRegisteredInChain(type) walks the chain.

🧭 Decision — keep the chain shallow: one parent (yours -> shared). It is a fallback for resolving shared entities, not a place to register into - registration always lands on the registry you call.


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 on their own. The built-in answer is Cross-Process Cache Sync (CacheSync): it drives exactly these invalidate/evict knobs from a backend change feed (Mongo/Postgres) or version polling (MySQL/MariaDB), so you don't hand-wire Redis. Pair it with a TTL as the safety net. 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