-
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 world = new RefRegistry();
EntityDescriptor<UUID, Guild> GUILDS = EntityDescriptor.builder(UUID.class, Guild.class)
.collection("guilds")
.keyExtractor(Guild::getId)
.codec(world.codec(Guild.class)) // ref-aware codec bound to 'world'
.build();
// Create + register the manager in one call:
CachingManager<UUID, Guild> guilds = world.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 =
world.manager(GUILDS, storage, CachePolicy.always()); // unbounded
CachingManager<UUID, Guild> bounded =
world.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, world); // registered in 'world'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.
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 countedrepository() 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 detail lives in
specs/SPEC_refs_and_managers.md; 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 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) tomanager.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.
- Caching & References — the overview this page deepens; start there.
- 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. - Design rationale & the cell/stamp/tombstone model:
specs/SPEC_refs_and_managers.md.
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