Skip to content

Caching and References

Petrus Pradella edited this page Jun 16, 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 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-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.

See it end-to-end in the tests: ManagerIntegrationTest and the entities under manager/.../testdata.


Install

implementation 'br.com.finalcraft.everydatabase:everydatabase-manager:1.0.1'  // pulls core transitively

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

Step 1 — a ref-aware codec

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 Ref fields need RefCodecs. A leaf entity with no refs can use a plain JacksonJsonCodec. :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. 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 — a Ref can 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 throws IllegalStateException. Both messages tell you which 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: 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).


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.


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


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. Bound them with a TTL and/or wire an external invalidation signal to manager.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.

Non-goals

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.


See also

Clone this wiki locally