-
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 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 RefRegistry owns your refs. It vends the ref-aware codec and the manager, so any
// Ref<?, Guild> read through this registry resolves through this registry's Guild manager.
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();
CachingManager<UUID, Guild> guilds = refRegistry.manager(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(); // playerRepo's codec = refRegistry.codec(Player.class)
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 — only that its reference was read through refRegistry.
See it end-to-end in the tests:
ManagerIntegrationTestand the entities undermanager/.../testdata.
// the manager add-on does NOT pull core in transitively — declare both:
implementation 'br.com.finalcraft.everydatabase:everydatabase-manager:1.0.4'
implementation 'br.com.finalcraft.everydatabase:everydatabase-core:1.0.4'Full 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 | RefRegistry |
entity type → its manager, per context — the registry a Ref is bound to |
A Ref holds no entity and no cache — only a key, the target type, and the RefRegistry it is bound
to. When you resolve it, it looks up the entity type's manager in that registry and asks that
manager (its cache, its backend, its policy). There is no global registry: each context owns its own
(see Multiple registries below). 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, bound to 'refRegistry'"| R(Ref)
R -->|"looks up type in"| Reg[refRegistry: RefRegistry]
Reg -->|"Guild → GuildManager"| M[CachingManager]
M -->|hit| C[(identity-map cache)]
M -->|miss → load| B[(Guild backend)]
Everything hangs off a RefRegistry. Make one per context (a plugin, a subsystem) at startup — it
vends the ref-aware codecs and the managers, so you can't half-wire it:
RefRegistry refRegistry = new RefRegistry();📌 Note — there is no global registry, on purpose. A shared static default would let two plugins register a manager for the same type and silently collide (last-writer-wins). Owning your registry makes that impossible. For a single-plugin app, one registry is all you need.
A Ref field needs Jackson to write it as its key and recover the target type on read. Use
refRegistry.codec(Type.class) (a bound, ref-aware codec) instead of a plain JacksonJsonCodec on any
entity that contains Ref fields:
.codec(refRegistry.codec(Guild.class)) // ref-aware codec bound to 'refRegistry'📌 Note — only entities that hold
Reffields strictly need the ref-aware codec; a leaf entity with no refs can use a plainJacksonJsonCodec(reading it throughrefRegistry.codec(...)is harmless too).: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, through the registry. refRegistry.manager(...)
creates it and registers it in refRegistry, so every Ref<?, ThatType> bound to refRegistry resolves
through it:
// Convenience: unbounded cache with a default freshness policy, registered in 'refRegistry'.
CachingManager<UUID, Guild> guilds = refRegistry.manager(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, RefRegistry registry) {
super(GUILDS, storage, CacheOptions.builder()
.policy(CachePolicy.always()) // small hot set: keep resident
.maxSize(1000) // ...but bounded by LRU
.build(), registry); // pass the registry to super(...)
}
}
⚠️ Gotcha — aRefcan only resolve after its manager exists and the ref is bound to a registry. Create your managers during startup, before any code resolves a ref.resolve()on an unbound ref or an unregistered type returns a failed future (never a sync throw);peek()in the same situation throwsIllegalStateException. The message tells you whether the ref is unbound or the 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. To resolve a hand-built ref, bind it to a registry
with refRegistry.ref(key, Guild.class) (or Ref.of(key, Guild.class, refRegistry)). A bare
Ref.of(key, Guild.class, null) is unbound — that explicit null registry is how you build an
unbound ref: fine to put on an entity and save (only the key is written), but resolving it fails
fast. Ref.empty(Guild.class) means "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.
📌 Note — that is write-through (persist before it's visible). For the opt-in write-back path - mutate the cached instance in memory and flush the dirty cells later in one batch (
IDirtyableor@DirtyFlag,seedIfAbsent,flushDirty) - see Caching Managers → Write-back.
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, refRegistry).preloadAll().join(); // guilds resident (always())
refRegistry.manager(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.
Because there is no global registry, two independent contexts (two plugins, two authors) can each
register a manager for the same entity type, backed by different storages, and never collide.
A Ref resolves only against the registry it was bound to (by the codec that read it), so the same
key resolves to different data in different registries:
// Two registries, each its own registry + its own stores. Both register a Hero manager — same type!
RefRegistry survival = new RefRegistry();
RefRegistry lobby = new RefRegistry();
survival.manager(heroDesc(survival), survivalStore, CachePolicy.always()); // Hero -> survivalStore
lobby.manager(heroDesc(lobby), lobbyStore, CachePolicy.always()); // Hero -> lobbyStore
// A SurvivalProfile read via survival.codec(...) binds its hero ref to 'survival';
// a LobbyProfile read via lobby.codec(...) binds its hero ref to 'lobby'.
survivalProfile.getHero().resolve(); // -> survivalStore's Hero
lobbyProfile.getHero().resolve(); // -> lobbyStore's Hero (same id, different entity)This is impossible with a single global Class → resolver map: the second Hero manager would
overwrite the first, and you'd only discover it at runtime in production. Dropping the global default
turns that silent collision into a thing that can't happen.
🧭 Decision — one registry for a single-plugin app (make it at startup, pass it to your codecs and managers). One registry per subsystem when independent modules each own their refs. Never share a registry across modules that don't trust each other's type usage.
A full two-subsystem example (SurvivalProfile/LobbyProfile sharing the Hero and Wallet types
across separate registries and backends) lives in MultiBackendRefExampleTest
(two_subsystems_resolve_the_same_types_through_their_own_registries).
Want the opposite of full isolation - share some types while keeping others private? Chain a
registry to a parent with new RefRegistry(parent): registration stays local, but resolution falls
back up to the shared parent ("private-then-shared", still no global map). See Caching Managers →
Parent registries.
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 on their own. Wire Cross-Process Cache Sync (CacheSync) to close the gap — it turns another instance's write into a localinvalidate/evictautomatically, via a backend change feed (Mongo/Postgres) or version polling (MySQL/MariaDB). Bound the residual staleness with a TTL too.
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
automatic dirty tracking (write-back is opt-in and manual - you set the flag yourself; see
Caching Managers). 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.
-
Cross-Process Cache Sync — keep caches fresh across instances (
CacheSync). - 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.
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