Skip to content

Cache Policies and Freshness

Petrus Pradella edited this page Jun 23, 2026 · 5 revisions

Cache Policies & Freshness

What this page covers: how the manager decides whether a cached entry is still good enough to serve. The core split is freshness vs capacityCachePolicy (always / TTL / noCache, per-read and per-reference overridable) governs freshness; CacheOptions.maxSize (LRU) governs capacity, and the two live in different types on purpose. We also cover @RefPolicy (per-field freshness override) and the memory note that strong references make purgeExpired() matter. This deepens the Caching & References overview.

📌 Note — these types live in the optional everydatabase-manager add-on, under …manager.cache. They configure a Caching Managers.


The smallest example

import br.com.finalcraft.everydatabase.manager.cache.CachePolicy;
import br.com.finalcraft.everydatabase.manager.cache.CacheOptions;

// Freshness: a 5-minute TTL.  Capacity: keep at most 10k hottest entries (LRU).
CacheOptions opts = CacheOptions.builder()
        .policy(CachePolicy.ttl(Duration.ofMinutes(5)))
        .maxSize(10_000)                 // 0 = unbounded (CacheOptions.UNBOUNDED)
        .build();

CachingManager<UUID, Guild> guilds = refRegistry.manager(GUILDS, storage, opts);

The manager evaluates the policy on every peek/resolve; the LRU bound is enforced by the store as entries are installed. (refRegistry.manager(GUILDS, storage, CachePolicy.always()) is the unbounded shorthand when you don't need a maxSize.)


Two different knobs, two different types

This is the single most important design decision in the cache layer:

Concern Type Scope Per-reference overridable?
Freshness — may I serve this entry, or must I reload? CachePolicy per read ✅ yes
Capacity — how many entries may live in the store? CacheOptions.maxSize per store ❌ no

They're kept apart because they have different scopes: a policy is evaluated per read and one reference can be stricter than another, but maxSize is a property of the shared store — you can't give one reference its own size bound. So TTL is a per-read knob; maxSize is a manager knob. Mixing them onto one type would be incoherent.


CachePolicy — freshness

Three strategies, built from static factories:

CachePolicy.always()              // serve until explicitly invalidated/evicted (default for hot sets)
CachePolicy.ttl(Duration ttl)     // refetch once the entry is older than ttl
CachePolicy.noCache()             // never serve from cache — a true bypass that also never populates it

What each does on a read:

  • always()isFresh is true unless the entry was explicitly marked stale (invalidate(key)). The default; pairs with maxSize to bound memory of a hot set.
  • ttl(duration)isFresh is true while now is before loadedAt + duration (and not marked stale). Once past the window, the next read reloads.
  • noCache()isFresh is always false and cacheable() is false. This is a true bypass: a resolve under noCache loads from the backend and returns the value without populating or touching the shared cache entry. It neither serves from nor writes to the cache.

There's also CachePolicy.fromAdminConfig(name, ttlSeconds) for building a policy from raw config strings ("ALWAYS" / "TTL" / "NOCACHE"; unknown names throw IllegalArgumentException) — handy when the policy comes from an admin-editable file.

A dirty write-back cell overrides the policy ("dirty wins")

A dirty-trackable value (it implements IDirtyable or carries a @DirtyFlag-annotated transient/@JsonIgnore dirty flag, so plain entities are unaffected) changes how freshness is evaluated for its cell: while isDirty() is true, the cached cell is always served and never reloaded — the freshness policy is bypassed. Concretely, for a dirty cell:

  • a ttl(...) window that has elapsed does not reload it;
  • invalidate(key) does not drop it;
  • even a per-read or @RefPolicy(noCache = true) noCache override does not reload over it.

The point is that local, unflushed changes win over the freshness policy — a reload can never throw away an edit you haven't persisted yet. Once the cell is flushed and markClean()'d, the policy applies normally again.

📌 Note — this is what makes TTL safe on a mutable entity. Before write-back, a ttl(...) on a set you also mutate locally could reload a cell out from under an unsaved edit; with a write-back entity, a dirty cell is pinned until it flushes, so TTL only ever reloads clean cells. If you deliberately need to force-refresh a dirty entity from the backend, flush or evict it first — that is, discard the local change on purpose. See Caching Managers for flushDirty/evict.

⚠️ GotchanoCache() is not "TTL of zero". A zero/short TTL still caches the value and serves concurrent readers from one shared instance until it expires; noCache() never caches at all, so every read is an independent backend round-trip and you lose the identity-map guarantee for that reference. Use noCache() only for genuinely always-fresh data (a live scoreboard), not as a way to "disable" caching you'll want back later.

Where a policy applies

A policy can come from three places, in increasing specificity:

  1. The manager defaultCacheOptions.policy(), used when a reference declares no override.
  2. A per-reference override@RefPolicy on the field, or ref.withPolicy(...) / ref.withTtl(...) at runtime (see below).
  3. An explicit per-read policymanager.peek(key, policy) / manager.resolve(key, policy).

CacheOptions — capacity (and the default policy)

public final class CacheOptions {
    public static final int UNBOUNDED = 0;          // sentinel for maxSize()

    public static CacheOptions of(CachePolicy policy);     // policy + no size bound
    public static Builder builder();

    public CachePolicy policy();    // the default freshness policy
    public int         maxSize();   // 0 = unbounded
}
CacheOptions opts = CacheOptions.builder()
        .policy(CachePolicy.ttl(Duration.ofMinutes(5)))   // default freshness for this manager
        .maxSize(10_000)                                  // LRU bound; 0 / UNBOUNDED = no bound
        .build();

maxSize drives a thread-safe access-order LRU: once the store exceeds the bound, the least-recently-used entry is evicted. maxSize(0) (or UNBOUNDED) means no bound — entries leave the cache only by invalidation/eviction/purgeExpired.

📌 NotemaxSize interacts with preloadAll(): mirroring a collection larger than the bound truncates to the maxSize hottest entries. A full in-memory mirror needs an unbounded cache or a maxSize that covers the collection. See Caching Managers.


@RefPolicy — per-field freshness override

The manager owns a default policy per type, but a single reference can override freshness right where the relationship is declared — read from the field, so it works with Lombok @Data private fields too:

import br.com.finalcraft.everydatabase.manager.RefPolicy;

@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;
}

The annotation maps to a CachePolicy, baked into the Ref by the deserializer:

@RefPolicy value Effective policy
ttlSeconds > 0 CachePolicy.ttl(Duration.ofSeconds(ttlSeconds))
ttlSeconds == 0 CachePolicy.always()
ttlSeconds < 0 (the default) inherit the manager default
noCache = true CachePolicy.noCache() — overrides ttlSeconds

📌 Note@RefPolicy is read from the declared field by reflection (with a fallback past any Lombok-generated setter that would otherwise hide it), exactly like core's @Indexed. So private fields + @Data + generated accessors are fully supported. See Typed References (Ref).

The override changes the verdict, not the value

The crucial property: a per-reference override only changes whether this reference is willing to reuse what's cached — it never forks the identity map. The CacheEntry (value + loadedAt) stays shared, one instance for everyone. A stricter reference may consider the shared entry stale and trigger a reload, which refreshes the entry for all consumers — never staler, possibly fresher.

The one exception is noCache: because its cacheable() is false, that reference loads and returns the value without populating or touching the shared entry — a true bypass.

Runtime equivalent

When the override isn't known at compile time, derive a ref instead of annotating a field:

ref.withTtl(Duration.ofMinutes(3));         // == withPolicy(CachePolicy.ttl(...))
ref.withPolicy(CachePolicy.noCache());      // bypass at runtime
refRegistry.ref(key, Type.class, CachePolicy.ttl(Duration.ofMinutes(3)));   // a bound ref + override

Freshness ≠ memory: the strong-reference note

⚠️ Gotcha — cached entries are held by strong references, so TTL governs freshness, not memory. An expired-but-untouched entry is not GC-eligible until it is overwritten or evicted — a pure TTL with an unbounded maxSize does not bound memory. Bound it one of two ways (or both):

  • maxSize — evicted entries become collectable; the obvious memory bound.
  • purgeExpired() — proactively drops entries the default policy no longer considers fresh and returns the count. Call it periodically (e.g. a scheduled task) for a TTL'd, unbounded cache.
int dropped = guilds.purgeExpired();   // release no-longer-fresh entries; returns how many

A SoftReference-backed value mode (let the GC reclaim under pressure) is intentionally not provided — maxSize + purgeExpired() cover memory bounding, and real caches (Guava/Caffeine) default to size+time eviction over soft refs for the same reasons.


Choosing a policy

🧭 Decision

  • always() + a maxSize bound for a small, hot, mostly-read-locally set (guilds, definitions): keep them resident, bound memory with LRU, rely on write-through to stay current.
  • ttl(Duration) when another process may write the same data and you can tolerate bounded staleness: short TTL for fast-moving data, longer for slow. Pair with purgeExpired() if unbounded. For near-instant cross-instance freshness instead of waiting out the TTL, add Cross-Process Cache Sync — the TTL then becomes a safety net for a dropped/missed event rather than your primary freshness bound.
  • noCache() only for genuinely always-fresh reads (live scoreboards). It's a per-reference @RefPolicy(noCache = true) more often than a whole-manager default.

See also

Clone this wiki locally