Skip to content

Caching and References

Petrus Pradella edited this page Jun 26, 2026 · 9 revisions

Caching & References (everydatabase-manager)

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.


The 30-second version

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-cache

peek() 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: ManagerIntegrationTest and the entities under manager/.../testdata.


Install

// 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.


The one idea: a reference is not a cache

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&lt;UUID,Guild&gt;<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)]
Loading

Step 0 — a registry

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.

Step 1 — a ref-aware codec

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 Ref fields strictly need the ref-aware codec; a leaf entity with no refs can use a plain JacksonJsonCodec (reading it through refRegistry.codec(...) is harmless too). :core can'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…" }

Step 2 — one manager per entity type

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 — a Ref can 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 throws IllegalStateException. The message tells you whether the ref is unbound or the type is missing its manager.


Step 3 — reading a reference

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 miss

After 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 miss

Build 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).


Step 4 — writing & deleting through the cache

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 (IDirtyable or @DirtyFlag, seedIfAbsent, flushDirty) - see Caching Managers → Write-back.


Batching: the N+1 antidote

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-trip

And 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 or maxSize covers the collection.


Freshness vs capacity (two different knobs)

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 with maxSize and/or call purgeExpired() periodically.

Per-reference policy: @RefPolicy

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 · 0always() · < 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()).


Nested references

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'd

preloadAll() 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.


⭐ One entity, many databases, many key types

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.


Multiple registries — one type, many registries

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.

🧭 Decisionone 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.


What it guarantees (and what it doesn't)

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 local invalidate/evict automatically, 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.

Non-goals

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.


See also

Clone this wiki locally