DISTRIBUTED SYSTEMSENGINEERING ESSAY · 7 MIN READ

Cache invalidation is a versioning problem

Notebook dates are an editorial chronology, separate from publication dates.

Deleting a cache entry is an event, not a proof that every later reader sees current data. A slow fill can put an older value back after the deletion. I would model cached values as claims about a particular source version and make the allowed relationship between versions part of the read contract.

A cache key identifies a computation

A cached result depends on more than the most obvious record identifier. Tenant, locale, permissions, query parameters, feature configuration and representation version can all change the answer. Leaving one relevant input out of the key merges distinct computations and can produce incorrect or unauthorised results even when invalidation is perfectly timely. I would start by writing the function being cached and listing its inputs. Some inputs belong directly in the key; others can be represented by a stable version that changes whenever their effect on the result changes.

HTTP caching offers a useful vocabulary for separating freshness from validation. RFC 9111 defines when stored responses may be reused and how validation interacts with cached state. An application-level cache does not automatically inherit those semantics, but it should be equally explicit. A value can be old yet still acceptable under a bounded-staleness contract, or recently fetched yet invalid because the caller's permissions changed. Age alone is therefore an incomplete correctness test. The cache must know which statement it is making: current for version V, fresh for a duration, or safe to revalidate before reuse.

References: [1] RFC 9111: HTTP Caching

The stale-fill race survives a successful delete

Consider this illustrative schedule. Reader A misses the cache and reads source version 7. Writer B commits version 8 and deletes the cache key. Reader A then finishes its slow work and stores version 7 under the ordinary key. Every invalidation call succeeded, but subsequent readers receive the old value. Adding a short time-to-live limits how long that particular entry remains, assuming the timer is not reset by another stale fill. It does not make the fill consistent with the writer's commit.

One way to reason about the race is to retain a per-key minimum acceptable generation at the cache boundary. A fill carrying an older generation is rejected atomically. The example below models that decision in serial code. It is not a complete invalidation protocol: the system must still deliver generation changes reliably or define what happens while delivery is delayed. The model clarifies the missing piece in delete-only designs. Once the cache has learned that version 8 is required, an operation based on version 7 must not be allowed to recreate authoritative-looking current state.

Serial model of a guarded cache fill. Production generation comparison and publication must be atomic. python
class VersionedCache:
    def __init__(self):
        self.floor = 0
        self.entry = None

    def invalidate(self, generation):
        self.floor = max(self.floor, generation)
        if self.entry and self.entry[0] < self.floor:
            self.entry = None

    def fill(self, generation, value):
        if generation < self.floor:
            return False
        if self.entry and generation < self.entry[0]:
            return False
        self.entry = (generation, value)
        return True

cache = VersionedCache()
cache.invalidate(8)
assert not cache.fill(7, 'old result')
assert cache.fill(8, 'new result')
assert cache.entry == (8, 'new result')
print(cache.entry)

References: [2] Redis client-side caching

Immutable version keys move the coordination point

Another design stores results under immutable keys such as object identity plus source generation. A slow computation for version 7 cannot overwrite the representation for version 8 because their keys differ. This makes publication easier to reason about and can support efficient sharing across readers. It does not remove the need to discover the correct current generation. A mutable pointer, database read or invalidation stream still tells the application which immutable object to request. The coordination problem moves to that smaller piece of state rather than disappearing.

This pattern is especially attractive for expensive derived objects whose inputs can be named precisely. The key might include a content digest, schema version and renderer version. Old objects can remain valid historical results while a retention policy eventually removes them. I would avoid using a timestamp as a substitute for a defined source version unless its ordering and uniqueness properties are sufficient. Two changes with the same timestamp or clocks moving backwards can merge distinct generations. A monotonically assigned database version or a digest of all relevant immutable inputs gives a clearer identity.

References: [1] RFC 9111: HTTP Caching

Invalidation delivery has a recovery contract

Redis's client-side caching documentation explains server-assisted invalidation tracking and the need to handle connection loss correctly. A client that misses invalidations cannot simply reconnect and assume its local entries remain trustworthy. My general rule is that a gap in an invalidation stream creates uncertainty about every entry covered by that stream unless a recovery protocol can reconstruct the missing changes. Flushing the affected cache may be the simplest safe response. A resumable versioned log can preserve more state, but only if its cursor and retention rules are actually enforced.

Ordering matters too. If updates carry source generations, an old invalidation arriving late should not lower the cache's accepted generation. If messages contain only a delete instruction, duplicates are usually harmless but late stale fills remain possible. I would test delayed, duplicated, reordered and missing notifications separately. The desired outcome can be a cache miss, a documented stale response or a blocking source read. A silent return to unbounded staleness after reconnection is the failure. Recovery behaviour belongs in the cache API because every caller otherwise invents a different assumption.

References: [2] Redis client-side caching

Time-to-live is a bound only under stated assumptions

A five-minute TTL sounds like a five-minute staleness guarantee, but that conclusion needs conditions. If a loader reads a ten-minute-old replica and then starts a fresh five-minute timer, the result can be fifteen minutes behind the primary. If stale-while-revalidate repeatedly serves an old value while refreshes fail, the maximum age depends on an additional stale-serving limit. If a loader works from a long-lived snapshot, a new cache insertion can contain old source state. I would separate insertion age, source age and permitted serving age in the design.

A useful policy can explicitly trade freshness for availability. For a public catalogue, serving a known old value during a source outage may be better than returning an error. For revoked access, the same choice may be unacceptable. The contract should classify data rather than applying one global TTL to every key. Source version, fetched-at time and maximum stale deadline can coexist in the entry metadata. That makes the fallback decision inspectable and allows an operator to tell whether the system is serving an intentionally degraded result or violating its freshness promise.

References: [1] RFC 9111: HTTP Caching

Prevent a correct invalidation from causing overload

Invalidating a popular key can synchronise thousands of readers into the same expensive miss. Request coalescing lets one loader perform the work while others wait, and jittered expiry can spread unrelated refreshes over time. Those mechanisms need their own bounds: a hung loader should not hold every waiter indefinitely, and a per-key coordination map should not grow without limit under arbitrary keys. I would preserve the caller's deadline and cap concurrent fills at the source boundary, because a cache failure should not automatically become a database outage.

The counterargument is that generation tracking and coordinated fills are excessive for ordinary caches. Often they are. A small cache of low-consequence data with an explicitly loose freshness target may need only a TTL and a safe fallback. The reason to introduce stronger machinery is a demonstrated contract, such as preventing stale publication after a write or isolating tenant-specific results. I would choose the simplest design that satisfies that contract and write down its limits. Complexity is not evidence of correctness, and an elaborate invalidation stream without gap recovery can be less trustworthy than a short, honest TTL.

References: [1] RFC 9111: HTTP Caching[2] Redis client-side caching

Test the version transitions users can observe

Tests should pause a loader after its source read, commit a newer source version, deliver invalidation and then release the loader. They should also disconnect the invalidation channel while writes continue and verify the reconnect policy. For permission-sensitive data, change the caller's authority without changing the underlying object and confirm that keying or validation prevents reuse. These tests target the relation between source and cache versions; a test that merely calls delete and then checks for a miss covers only the simplest local operation.

I would monitor source fallback load, rejected stale fills, invalidation lag and the age of values actually served, using bounded dimensions. Hit rate alone can reward a cache that efficiently returns the wrong generation. The useful success criterion is that the cache avoids enough work while preserving the chosen freshness and isolation contract through concurrency and recovery. Framing invalidation as version management makes that criterion concrete. It asks which generation a reader may see and what evidence makes that choice valid, rather than treating deletion as the end of the story.

References: [2] Redis client-side caching[1] RFC 9111: HTTP Caching

Sources and further reading

  1. RFC 9111: HTTP Caching

    The primary specification defines HTTP freshness and validation semantics. Application-level generation models and hypothetical races are this essay's analysis, not claims that HTTP implements them automatically.

  2. Redis client-side caching

    Primary documentation describes invalidation tracking and connection-loss handling. The guarded-fill code is an illustrative independent model rather than Redis implementation code.

FROM THE NOTEBOOK.

Back to all notes