-
Notifications
You must be signed in to change notification settings - Fork 1
Caching and References
What this page covers: the optional everydatabase-manager add-on — typed references between
entities (Ref<K, V>) and per-type caching (CachingManager) that sit in front of the core. By
the end you can hold a Guild instead of a raw UUID, cache hot entities with a policy you
control, and even reference entities that live in a different database — without turning the
library into an ORM.
📌 Note — this is a separate artifact you opt into. The core stays untouched; everything here is a thin façade over a
Repository. If you only need plain CRUD, you don't need this module.
import br.com.finalcraft.everydatabase.manager.*;
import br.com.finalcraft.everydatabase.manager.cache.CachePolicy;
import br.com.finalcraft.everydatabase.manager.jackson.RefCodecs;
import lombok.*;
// 1. An entity that references another by a typed Ref — stored as just the key on disk.
@Data @NoArgsConstructor @AllArgsConstructor
public class Player {
private UUID uuid;
private Ref<UUID, Guild> guild; // -> "guild":"<uuid>" in the stored JSON
}
// 2. A manager per entity type. It self-registers, so any Ref<?, Guild> resolves through it.
EntityDescriptor<UUID, Guild> GUILDS = EntityDescriptor.builder(UUID.class, Guild.class)
.collection("guilds")
.keyExtractor(Guild::getId)
.codec(RefCodecs.json(Guild.class)) // ref-aware Jackson codec
.build();
CachingManager<UUID, Guild> guilds =
new CachingManager<>(GUILDS, storage, CachePolicy.always());
// 3. Resolve a reference — it goes through the Guild manager (its cache, its backend).
Player p = playerRepo.find(playerId).join().orElseThrow();
Optional<Guild> hot = p.getGuild().peek(); // synchronous, cache-only (hot path)
p.getGuild().resolve().thenAccept(opt -> render(opt)); // async: cache hit, or load-and-cachepeek() never does I/O; resolve() loads on a miss. The Player neither knows nor cares where
its Guild lives or how it's cached.
See it end-to-end in the tests:
ManagerIntegrationTestand the entities undermanager/.../testdata.
implementation 'br.com.finalcraft.everydatabase:everydatabase-manager:1.0.1' // pulls core transitivelyFull coordinates and the Maven snippet are on Installation.
This is the whole design, and keeping the two apart is what makes it composable:
| Concern | Who owns it | What it is |
|---|---|---|
| The pointer | Ref<K, V> |
a typed handle that serializes as just its key |
| The cache | CachingManager<K, V> |
an in-memory identity map (one live instance per key) |
| The wiring |
Refs registry |
entity type → its manager, so a Ref finds its cache |
A Ref holds no entity and no cache — only a key and the target type. When you resolve it, it looks
up the entity type's manager in the process-wide Refs registry and asks that manager (its cache,
its backend, its policy). The Ref* and Cache* naming families stay separate on purpose.
flowchart LR
P["Player<br/>(in MariaDB)"] -->|"holds Ref<UUID,Guild><br/>= just a key"| R(Ref)
R -->|"looks up type in"| Reg[Refs registry]
Reg -->|"Guild → GuildManager"| M[CachingManager]
M -->|hit| C[(identity-map cache)]
M -->|miss → load| B[(Guild backend)]
A Ref field needs Jackson to write it as its key and recover the target type on read. Wrap any
entity that contains Ref fields with RefCodecs.json(Type.class) instead of a plain
JacksonJsonCodec:
.codec(RefCodecs.json(Guild.class)) // registers the RefModule on the mapper📌 Note — only entities that hold
Reffields needRefCodecs. A leaf entity with no refs can use a plainJacksonJsonCodec.:corecan't depend on:manager, which is exactly why this ref-aware codec lives in the manager module rather than being baked into core's codecs. See Codecs.
On disk the result is byte-for-byte what storing the raw key would produce — no embedded object, no
@JsonIgnore tricks:
{ "uuid": "5f1e…", "guild": "9a2c…" }Create a CachingManager per entity type at startup. Construction self-registers it in Refs
(keyed by the entity type), so every Ref<?, ThatType> resolves through it. Two constructors:
// Convenience: unbounded cache with a default freshness policy.
CachingManager<UUID, Guild> guilds = new CachingManager<>(GUILDS, storage, CachePolicy.always());
// Full control: a domain-named subclass with bounded LRU + default policy.
public final class GuildManager extends CachingManager<UUID, Guild> {
public GuildManager(Storage storage) {
super(GUILDS, storage, CacheOptions.builder()
.policy(CachePolicy.always()) // small hot set: keep resident
.maxSize(1000) // ...but bounded by LRU
.build());
}
}
⚠️ Gotcha — aRefcan only resolve after its manager exists. Create your managers during startup, before any code resolves a ref.resolve()on an unregistered type returns a failed future (never a sync throw);peek()on an unregistered, un-memoized type throwsIllegalStateException. Both messages tell you which type is missing its manager.
Three read styles, from a held Ref (e.g. a field on a loaded entity):
| Call | Blocks? | I/O? | Returns |
|---|---|---|---|
ref.peek() |
no | never |
Optional<V> — cache-only; empty if absent/stale |
ref.resolve() |
no | on miss |
CompletableFuture<Optional<V>> — hit, or load-and-cache |
ref.join() |
yes | on cold miss |
V or null (blocking convenience) |
Optional<Guild> cached = player.getGuild().peek(); // hot loop, lock-free
player.getGuild().resolve().thenAccept(opt -> ...); // load on miss, then cache
Guild g = player.getGuild().join(); // blocks only on a cold missAfter the first resolution a Ref memoizes the live cache cell, so later reads are lock-free
(no registry lookup, no map lookup) and — because the cell updates in place — always observe the
latest value. A long-held Ref (say, on an online player) behaves like a self-refreshing handle.
You can also read straight off the manager when you have keys rather than refs:
Optional<Guild> one = guilds.peek(guildId); // cache-only
CompletableFuture<Optional<Guild>> fut = guilds.resolve(guildId); // load on missBuild refs programmatically when you need to: Ref.of(key, Guild.class), or
Ref.empty(Guild.class) for "points at nothing" (a JSON null round-trips to an empty Ref,
never a bare null).
Prefer the cache-aware write/delete pair over repository().save/delete, which touch the backend
but leave a stale cache entry behind:
guilds.saveAndCache(guild).join(); // persist + update the cell in place (memoized handles see it)
guilds.deleteAndEvict(id).join(); // delete + evict (a tombstone blocks racy in-flight reloads)Both are stamp-ordered: a slower in-flight reload can neither clobber a newer saveAndCache nor
resurrect a newer deleteAndEvict. On an OptimisticLockException, saveAndCache auto-evicts the
stale entry so the next read reloads the current backend state (the exception still propagates) —
see Optimistic Locking.
Resolving refs one-by-one in a loop is N+1. When you have 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-tripAnd to mirror a small, hot collection into memory at startup:
new GuildManager(storage).preloadAll().join(); // installs every guild (where absent)📌 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.
Two concerns, two types, on purpose:
-
Freshness is a
CachePolicy, evaluated per read, and per-reference overridable:-
CachePolicy.always()— keep until explicitly invalidated/evicted (the default for hot sets). -
CachePolicy.ttl(Duration)— refetch once the entry is older than the duration. -
CachePolicy.noCache()— never serve from cache; a true bypass that also never populates it.
-
-
Capacity is
CacheOptions.maxSize(LRU eviction) — a property of the store, shared by every entry, so it is not per-reference overridable.
CacheOptions opts = CacheOptions.builder()
.policy(CachePolicy.ttl(Duration.ofMinutes(5)))
.maxSize(10_000) // 0 = unbounded (CacheOptions.UNBOUNDED)
.build();
⚠️ Gotcha — 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. Bound memory withmaxSizeand/or callpurgeExpired()periodically.
The manager owns a default policy per type, but a single reference can override freshness right
where it's declared — read from the field even with Lombok @Data private fields:
@Data @NoArgsConstructor @AllArgsConstructor
public class Guild {
private UUID id;
private String name;
@RefPolicy(ttlSeconds = 180) // this ref: 3-minute TTL
private Ref<UUID, GuildBattleData> battleData;
@RefPolicy(noCache = true) // this ref: always hit the backend
private Ref<UUID, LiveScoreboard> scoreboard;
}ttlSeconds > 0 → TTL · 0 → always() · < 0 → inherit the manager default · noCache = true
→ bypass (overrides ttlSeconds). The override changes only this reference's freshness verdict;
the cached value stays shared (one instance for everyone — a stricter ref may trigger a reload that
refreshes the entry for all consumers, never staler). This is the real Guild test entity.
Need it at runtime instead of on a field? Derive a ref: ref.withTtl(Duration.ofMinutes(3)) or
ref.withPolicy(CachePolicy.noCache()).
Nesting falls out for free: each entity type has its own manager, with its own policy/TTL. A
Guild held in memory does not drag its GuildBattleData in — it only holds the key.
new GuildManager(storage).preloadAll().join(); // guilds resident (always())
new CachingManager<>(BATTLE, storage, CachePolicy.ttl(ofMinutes(3))); // battle data lazy, 3-min TTL
Guild g = player.getGuild().peek().orElseThrow(); // memory
g.getBattleData().resolve().thenAccept(opt -> render(opt)); // backend on cold miss, then TTL'dpreloadAll() of guilds does not cascade into battle data; cycles are safe (resolution is lazy);
N+1 is opt-in per level (batch with getAll(...)). See NestedRefTest.
The payoff. Because a Ref resolves through its type's manager — and a manager can be backed by
any Storage — a single root entity can fan out across heterogeneous databases, each
reference under its own key type, and the root neither knows nor cares:
@Data @NoArgsConstructor @AllArgsConstructor
public class PlayerProfile {
private UUID uuid;
private Ref<String, Clan> clan; // String key -> PostgreSQL
private Ref<Long, Wallet> wallet; // Long key -> MongoDB
private Ref<Integer, Stats> stats; // Integer key -> H2
private Ref<Settings.Key, Settings> settings; // record key -> LocalFile
private Ref<Session.Id, Session> session; // record key -> InMemory
}Each type gets one manager on its own backend; resolving each reference hits a different database.
This has its own page — One Entity, Many Databases — and a runnable end-to-end test against
all six real backends in MultiBackendRefExampleTest.
The cache hands out the same instance per key for its lifetime (an identity map). 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 save a stale copy over a newer one) | ✅ (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 tomanager.evict(key)/manager.invalidate(key).
Invalidation knobs: invalidate(key) (mark stale, reload next read), evict(key) (remove
outright), invalidateAll(), clearCache(), purgeExpired() (drop no-longer-fresh entries; returns
the count). repository() gives you the uncached backend for queries or cache-bypassing reads;
cachedSize() counts live cells.
No eager fetch, no lazy proxies, no hidden I/O, no implicit joins, no entity-graph mapping, no dirty
tracking. Resolution is always explicit (peek/resolve) and batchable (getAll). The cache is
opt-in; freshness is a knob you own.
- One Entity, Many Databases — the heterogeneous-backend fan-out, in full.
- Typed References (Ref) · Caching Managers · Cache Policies & Freshness — deep dives.
-
Codecs — why
RefCodecslives in this module and how it relates to core codecs. - Optimistic Locking — the conflict→auto-evict interaction.
- Choosing a Backend — pick a backend per reference.
- Design rationale & the cell/stamp 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