Skip to content

Caching reads

Attaching a CacheSpec to a document gives you read-through caching: reads serve from the cache, writes invalidate, and a cached read is never stale within the contract you opted into. The Cache reads with Redis recipe is the wiring. This page explains the behaviors the contract layers on top — protections you get for free, and knobs you opt into when a read pattern needs them. They are backend-agnostic (the mock cache honors them too); where a feature needs Redis specifics, that's called out.

Stampede protection

Two protections come with read-through caching; you wire neither.

Concurrent misses collapse. When many requests miss the same key at once (a cold start, an invalidation on a hot key), one of them fetches from the database and the rest wait for that result — per process, one gateway fetch instead of a thundering herd. Followers share the leader's failure too, so a broken fetch fails fast everywhere instead of retrying in a pile.

Hot keys can refresh early. Even with collapsed misses, a popular entry expiring means every replica misses at the same moment. Opt in with CacheSpec(name="products", early_refresh_beta=1.0): a cache hit close to expiry may volunteer to recompute before the entry dies, with probability scaled by how expensive the recompute was observed to be — so refreshes desynchronize across replicas and a hot key never expires for everyone at once. Enabled entries carry a small metadata envelope in the cached value; the default (None) keeps the payload format unchanged. By default the elected reader waits for the recompute; add early_refresh_background=True and it serves the still-valid cached entry immediately while the refresh runs detached — the reader never pays the recompute latency, and a failed refresh is logged rather than surfaced (the entry is still valid; a later election retries).

An in-process L1 for hot documents

When one document is read constantly, even a cache hit costs a network round-trip plus a JSON decode — per request, per replica. An opt-in L1 serves those reads from process memory instead, already decoded:

from forze.application.contracts.cache import CacheSpec, L1Spec

cache = CacheSpec(
    name="products",
    l1=L1Spec(ttl=timedelta(seconds=2), capacity=1024),
)

The L1 TTL is a staleness budget

This changes the consistency contract. A write invalidates the L1 only on the replica that performed it — other replicas may serve their L1 entry for up to ttl after a write. Keep the TTL small (it must be below the cache TTL), and enable L1 only on read models that tolerate reads that stale. On the writing replica, read-your-writes still holds: local writes refresh the local L1.

Within the budget, the behavior is what you'd hope for: repeat reads of a hot product never leave the process, the entry pool is LRU-bounded at capacity, and expired entries fall back to the distributed cache (which keeps the early-refresh machinery above fully functional — set the L1 TTL well below the cache TTL).

The eviction policy is pluggable. If scans (batch jobs, listings) sweep one-off documents through the L1 and evict your hot set, switch to the in-box W-TinyLFU store — frequency-based admission rejects one-pass traffic, so scans can't displace hot documents:

from forze.application.integrations.document import tiny_lfu_l1_store

L1Spec(ttl=timedelta(seconds=2), store_factory=tiny_lfu_l1_store)

Push invalidation: shrink the staleness window to ~zero

On Redis 6+, the L1 staleness budget can become a backstop instead of the contract. Opt in on the Redis side:

RedisCacheConfig(namespace="app:products", invalidation_push=True)

This turns on Redis client-side caching (CLIENT TRACKING): the server pushes an invalidation to every replica the moment any replica writes a cached product — the L1 entry drops within a network round-trip instead of waiting out its TTL. With push on, you can comfortably raise the L1 TTL (e.g. to 30–60 s) for a better hit rate.

The failure posture is fail-open: if the push stream drops, every L1 flushes (events may have been missed), reconnects with backoff, and in the meantime the TTL bounds staleness exactly as before. Two setups stay TTL-only by design: tenant-routed clients (a tracking stream bound to one tenant's Redis would miss every other tenant's writes) and dynamic per-tenant namespaces (no stable broadcast prefix) — both log and degrade gracefully.

Adaptive lifetimes

A fixed TTL is a compromise: tight enough for the documents that churn, wasteful for the ones that don't. Two opt-ins adapt it per entry — both freshness-safe, because in-band writes invalidate regardless of TTL; these only govern the revalidation cadence and the out-of-band safety net.

Stable documents earn longer lifetimes (the HTTP heuristic-freshness rule — RFC 7234's "10% of age"):

CacheSpec(
    name="products",
    age_ttl=AgeBasedTtl(alpha=0.1, min_ttl=timedelta(seconds=30), max_ttl=timedelta(hours=1)),
)

At warm time the entry's lifetime becomes alpha × the document's age since last_update_at, clamped: a product untouched for a day caches for an hour; one edited a minute ago revalidates within seconds — and resets to cautious the moment it's written again. No state, no tuning per document.

Hot documents stay cached while they're hot (sliding expiration):

CacheSpec(name="products", ttl=timedelta(hours=1), sliding_ttl=timedelta(seconds=60))

Each cache hit extends the entry's life to the sliding window, so a frequently-read document never expires mid-heat — and one quiet window after its last read, it's gone. Seasonality and time-of-day patterns need no prediction: the entry simply lives while in season. ttl remains the absolute cap — even a perpetually-hot entry revalidates against the source within that bound. Sliding applies to document read-through (versioned) entries; plain set/get key-value usage keeps fixed lifetimes.

The two compose: the age heuristic sets each entry's initial lifetime and cap, sliding keeps it alive under access within that cap.

Each of these is a one-line addition to the CacheSpec you wire in the Cache reads with Redis recipe.