Data engineering

Cache-aside: reproduce a stale-write race

Deleting a cache entry does not cancel a database read already in progress. Six deterministic schedules show when an old reply can refill the cache and how a generation check changes the next read.

Artifact
6 event schedules with explicit load, write, invalidation and fill traces
Observed race
Version 1 can refill the cache after version 2 commits
Boundary
A local generation fence protects the cache; it does not retroactively refresh an in-flight response
Conceptual blue database and transparent cache model with crossing delivery paths and an amber outdated tile.
Conceptual illustration of an old value arriving through a delayed cache-fill path.

An invalidation can arrive before an obsolete fill

A cache miss begins a database read. Before that read finishes, another request updates the database and invalidates the cache. The original read then returns its old value and puts it into the empty cache. The invalidation succeeded, but a later request can still receive stale data.

We reproduced that schedule with a small Python model. Its database has a version number, its cache holds a version and expiry time, and every event has an assigned timestamp. Six cases compare ordinary fills, an obsolete fill, a generation check and expiration. There are no threads, network calls or measured latency values.

Final read outcomes from six cache-aside schedules
ScheduleFinal read at msDatabase versionReturned versionStale final read
Ordinary miss322No
Fill then invalidate322No
Obsolete fill421Yes
Generation fence422No
Before TTL expiry10221Yes
At TTL expiry10322No
Database version is 2 in every case. The six final reads return versions 2, 2, 1, 2, 1 and 2.
Figure 1. Results from explicit event schedules. Version numbers identify values; they are not latency or throughput measurements. View full-size figure.

The third and fifth cases return version 1 after version 2 has committed. The generation-fenced case refuses the obsolete fill and fetches version 2 on the next read. Expiration also makes the next read fetch version 2, but only when the assigned expiry boundary is reached.

Version numbers in this example describe one record's successive values. They are not response times, cache hit rates or evidence that one cache product outperforms another.

Separate the write order from the in-flight read

The usual cache-aside read path checks the cache, loads a missing value from the data store and populates the cache. A write updates the data store and invalidates the affected cache entry. Microsoft's pattern guidance recommends that write order, while explicitly warning that cache-aside does not guarantee consistency. Azure cache-aside pattern.

Updating the database before invalidating avoids one common window: a miss should not refill from a database that the writer has not yet updated. However, this ordering cannot cancel a database read that already captured the previous value. That read may finish after the invalidation.

Our ordinary-miss case writes version 2, invalidates and then loads. It returns version 2. The fill-then-invalidate case caches version 1 before the write. Invalidation removes it, so the final read also returns version 2. Both schedules work under the model's rules.

The obsolete-fill case changes only the placement of the load completion. That small change is enough to expose the race. Testing only a complete read followed by a complete write would never exercise it.

Read the five events in order

At time 0, a loader captures database version 1 and generation 0. At time 1, the writer commits database version 2. At time 2, invalidation empties the cache and advances its generation to 1. At time 3, the old loader attempts to fill the cache. The final request reads at time 4.

Capture version 1, commit version 2, invalidate and advance the generation, attempt the old fill, then read the resulting cache state.
Figure 2. Assigned times expose the stale-fill window. A generation mismatch rejects the obsolete insertion in the fenced variant. View full-size figure.

A naive fill stores version 1 with an expiry of 103. The final read finds that entry and returns it. Nothing about the cache being empty at time 2 prevents the later insertion.

Redis documents a related client-side caching race in which an invalidation arrives on one connection before an older read reply arrives on another. Its example uses a placeholder to recognize whether the pending read was invalidated before caching the reply. That is a specific client-side protocol issue, with the same useful question: can an old reply repopulate state after invalidation? Redis client-side caching races.

Do not treat the local model as a Redis implementation. It has no connection ordering, server-assisted tracking or reconnection behavior. The documentation supplies a real protocol example. Our explicit schedule isolates the stale-fill mechanism.

Reject a fill whose generation has changed

The model's optional fence captures the cache generation when a load starts. Before storing the returned value, it compares the captured generation with the current one. A mismatch means an invalidation happened during the load, so the cache rejects that fill.

In the fourth schedule, the old loader holds generation 0 while the cache is at generation 1. Its fill is rejected. The next read misses, loads version 2 and stores it under generation 1. The final read therefore returns version 2.

The check and insertion must form one indivisible operation within the authority that owns this cache state. A separate check followed by an unguarded write creates another opportunity for invalidation to slip between them. Our single Python object supplies that atomic step by construction. It does not demonstrate how multiple processes should coordinate it.

Generation lifetime matters too. If eviction removes the generation and recreation resets it to an earlier value, a delayed loader might mistake a new cache lifetime for its old one. A production design needs a token or version scheme that cannot accidentally reuse a still-live loader's identity.

This fence protects the shared cache from the obsolete reply. It does not retroactively change the value already read by the request that started at time 0. A requirement that every response reflect a later completed write needs a stronger, explicitly defined consistency contract.

Expiration limits a cache residence window

Time to live, or TTL, is measured from the fill in this model. The obsolete value arrives at time 3 and receives a 100 ms TTL. It remains eligible at time 102 and expires at time 103. The stale window is therefore related to the late fill, not simply to the database commit at time 1.

At expiry, the model removes the entry before checking for a hit. The resulting miss immediately reads the current database version and refills the cache. That is why the sixth case returns version 2.

These rules are deliberately small. A stale replica, another delayed loader or a failed invalidation can extend or recreate staleness in a real system. Repeatedly refreshing an entry's TTL on access would also change the result. State which event starts and renews the expiry clock before describing a maximum age.

Shortening TTL can increase database reads. The right value depends on the cost of misses and the product's tolerance for old values. The 100 ms fixture makes the boundary arithmetic easy to inspect. It supplies no workload evidence for choosing a production TTL.

Define the cache key and the failure policy

A perfectly ordered invalidation is still wrong if it targets the wrong key. Include every input that changes the cached representation: the record identity, relevant tenant scope and any authorization-dependent variation. Decide whether several representations of one record require several invalidations.

Permission decisions deserve particular care. An old cached decision can remain useful to an attacker after access has been revoked. Identify which operations may tolerate stale display data and which must consult authoritative policy state. The model tests record versions, not authorization safety.

Local caches add another boundary because each application instance can hold a different value. Microsoft's guidance calls out this divergence. A writer invalidating its own process does not establish that every other process received or applied the same event.

When invalidation depends on a message, define what happens after missed delivery or reconnect. A durable handoff can preserve the obligation to notify another component. Our transactional outbox crash cases explain why a queued obligation and a completed consumer effect are different facts. That handoff alone does not provide a cache consistency guarantee.

Reproduce the schedules and extend one boundary

Download the Python model, six-row CSV and event traces. Save the source as experiment.py and execute it with Python 3. It uses only the standard library.

Illustrative procedure
python3 experiment.py

The script runs all schedules twice, checks identical results and asserts the returned-version sequence: 2, 2, 1, 2, 1, 2. It also verifies that only the generation-fenced case rejects a fill. Assigned timestamps make expiry behavior reproducible without sleeping.

For an implementation test, first preserve the event order with explicit barriers around the database load and cache fill. Then add your actual cache adapter. A test based only on repeated requests and random delays can pass without ever reaching the relevant window.

Additional boundaries to exercise with the real cache adapter
ExtensionQuestion to answerEvidence to retain
Multiple loadersCan an older reply overwrite a newer fill?Load tokens and resulting entry version
Multiple instancesWhich caches observe the invalidation?Per-instance receipt and cache state
ReconnectWhat state survives a tracking interruption?Connection lifecycle and invalidation policy
EvictionCan a generation identity be reused?Token lifetime across removal and recreation
Read replicasCan a fresh miss load an old value?Database source and returned version

Use these cases to define what a successful invalidation means for your application. Record versions and event order in the assertions. A successful delete operation proves that an entry was removed at one point. The later fill is a separate event that the test must account for.

Sources

Documentation checked .

  1. Microsoft: cache-aside pattern
  2. Redis: client-side caching and invalidation races