-
Notifications
You must be signed in to change notification settings - Fork 1
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.
📌 Note —
CachingManagerlives in the optionaleverydatabase-manageradd-on. A manager is the cache; aRefis just the pointer (see Typed References (Ref)). One manager per entity type.
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-throughEverything async returns a CompletableFuture; .join() is shown for brevity. Compose with
thenApply/thenCompose — see The Async API. There are no blocking variants.
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 makesresolve()return a failed future andpeek()throw. See Typed References (Ref) → Binding.
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 LRUA 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.
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 overrideThese 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.
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-trippublic 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.
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 ormaxSizecovers the collection.preloadAlldoes not cascade into nested refs — each type has its own manager and is loaded independently.
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-
saveAndCachepersists, then updates the key's cell in place with the saved instance — so every memoizedRefobserves it on the next read. On anOptimisticLockExceptionit auto-evicts the stale entry (so the next read reloads the current backend state) and still propagates the exception. See Optimistic Locking. -
deleteAndEvictdeletes 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.
public V seedIfAbsent(K key, V value); // cache, no I/O
public CompletableFuture<BatchSaveReport<K>> saveAllAndCache(Collection<V> values);-
seedIfAbsentcaches 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". -
saveAllAndCacheis the batch sibling ofsaveAndCache: it callsrepository().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; anOptimisticLockExceptionon a record evicts that (stale) cell, any other error leaves the cell intact. The future completes normally - the per-key outcomes are in the returnedBatchSaveReport(empty when every record saved).
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 countpurgeExpired() 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.
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
Collection<Guild> live = guilds.cachedValues(); // snapshot of the live cached values (bulk iteration)cachedValues() returns a point-in-time snapshot of the values currently held in the identity map
(live cells only, no tombstones) — handy for iterating or flushing the hot set without touching the
backend. It's a copy: mutating the returned collection doesn't affect the cache.
repository() is your escape hatch for query(...), findBy(...), count(), and any
cache-bypassing read; see Indexing & Queries and CRUD Operations.
This is the model that makes concurrent reads and writes safe. The working mental model:
-
Cell. Each key maps to a stable
CacheEntrycell for its cache lifetime. ARefmemoizes 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
Refsees 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
deleteAndEvictleaves 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), bypurgeExpired(), 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)]
📌 Note —
cachedSize()counts live cells only; a tombstone is a present-but-not-live slot and is not counted.
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.
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 overSee seedIfAbsent above for the keep-first semantics.
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 clearedFailure handling is per entity, and the future still completes normally:
- A transient (non-conflict) error re-
markDirty()s the entity, so the nextflushDirty()retries it. - An
OptimisticLockExceptionevicts 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.
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.
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 nullresolver(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.
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 theseinvalidate/evictknobs 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.
- Caching & References — the overview this page deepens; start there.
-
Cross-Process Cache Sync — drive
invalidate/evictfrom other instances' writes. - Typed References (Ref) — what resolves through a manager, and cell memoization.
-
Cache Policies & Freshness —
CachePolicy,CacheOptions.maxSize,@RefPolicy,purgeExpired. - One Entity, Many Databases — one manager per type, each on its own backend.
-
Optimistic Locking — the conflict → auto-evict interaction on
saveAndCache. -
CRUD Operations · Indexing & Queries — the uncached
repository()surface. -
The Async API — the
CompletableFuturemodel behind every method here.
EveryDatabase · Home · made by Petrus Pradella
Getting Started
Core Concepts
Working with Data
Backends
Manager Module
- Caching & References
- Typed References (Ref)
- Caching Managers
- Cache Policies & Freshness
- Cross-Process Cache Sync
- One Entity, Many Databases
Operations
Advanced
Reference
Contributing