-
Notifications
You must be signed in to change notification settings - Fork 1
Cache Policies and 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 capacity — CachePolicy (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-manageradd-on, under…manager.cache. They configure a Caching Managers.
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.)
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.
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 itWhat each does on a read:
-
always()—isFreshis true unless the entry was explicitly marked stale (invalidate(key)). The default; pairs withmaxSizeto bound memory of a hot set. -
ttl(duration)—isFreshis true whilenowis beforeloadedAt + duration(and not marked stale). Once past the window, the next read reloads. -
noCache()—isFreshis always false andcacheable()is false. This is a true bypass: aresolveundernoCacheloads 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-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)noCacheoverride 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 orevictit first — that is, discard the local change on purpose. See Caching Managers forflushDirty/evict.
⚠️ Gotcha —noCache()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. UsenoCache()only for genuinely always-fresh data (a live scoreboard), not as a way to "disable" caching you'll want back later.
A policy can come from three places, in increasing specificity:
-
The manager default —
CacheOptions.policy(), used when a reference declares no override. -
A per-reference override —
@RefPolicyon the field, orref.withPolicy(...)/ref.withTtl(...)at runtime (see below). -
An explicit per-read policy —
manager.peek(key, policy)/manager.resolve(key, 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.
📌 Note —
maxSizeinteracts withpreloadAll(): mirroring a collection larger than the bound truncates to themaxSizehottest entries. A full in-memory mirror needs an unbounded cache or amaxSizethat covers the collection. See Caching Managers.
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 —
@RefPolicyis 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 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.
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
⚠️ 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 unboundedmaxSizedoes 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 manyA 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.
🧭 Decision —
always()+ amaxSizebound 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 withpurgeExpired()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.
- Caching & References — the overview this page deepens; start there.
-
Caching Managers — where the policy is evaluated;
purgeExpired,maxSize, write-through. - Cross-Process Cache Sync — invalidate across instances instead of waiting out a TTL.
-
Typed References (Ref) —
withPolicy/withTtland how@RefPolicyis read from the field. - Optimistic Locking — the write-side guard that complements read-side freshness.
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