Skip to content
About
Main content

The race between a reader and a writer: update versus delete

Break it on purposeNeeds Python

Problem

In , the application writes the database (the store) and then fixes the . There are two ways to fix it: update the cache with the new value, or delete the key so the next read reloads it. With two concurrent writers, "update" can leave the cache holding the older of the two values forever, because the cache writes can land in the opposite order to the store writes. "Delete" avoids that, but one race remains: a reader that missed, read the old value from the store, and then writes it into the cache just after a writer deleted the key. Only a (or a version in the key) bounds how long that stale value lives.

Use two threads and a dictionary as the "cache" plus a second dictionary as the "store". Add random sleeps of 0 to 5 ms between the steps of each operation. Run 10,000 trials of two writers (W1 writes v1, W2 writes v2) with the policy "write the store, then update the cache with the same value", and count the trials where cache and store end with different values. Repeat with the policy "write the store, then delete the cache key" and count again (expect zero disagreements that survive the next read). Then add a reader that misses, reads the store, sleeps, and populates the cache, to reproduce the . Measure how often a stale value is left in the cache, and show that a TTL of 1 second bounds how long it stays.

The question. How often does "update the cache" leave the cache and the store disagreeing, does "delete" fix it, and what bounds the race that remains?


Predict

With both writers updating the cache, in what share of trials do the cache and the store end up disagreeing?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    the-race-between-a-reader-and.py89 linesDownload
    View the code
    the-race-between-a-reader-and.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """The race between a reader and a writer: update versus delete.
    
    Two writers race on one key with "write the store, then update the cache"
    and with "write the store, then delete the cache key"; then a reader that
    misses and repopulates races a writer (the populate-after-invalidate race).
    """
    import argparse, json, random, threading, time
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--trials", type=int, default=10_000, help="Trials per experiment.")
    p.add_argument("--max-sleep-ms", type=float, default=5.0, help="Largest random sleep between the steps of an operation.")
    p.add_argument("--ttl", type=float, default=1.0, help="TTL bounding a stale entry, in seconds.")
    p.add_argument("--ttl-checks", type=int, default=20, help="Stale entries followed until their TTL expires.")
    p.add_argument("--seed", type=int, default=0, help="Random seed.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    random.seed(args.seed)
    pause = lambda: time.sleep(random.uniform(0, args.max_sleep_ms) / 1000)
    
    
    def two_writers(policy):
        """One trial: W1 writes v1 and W2 writes v2 at the same time. Returns True if cache and store disagree."""
        store, cache = {"k": "v0"}, {"k": "v0"}
        def writer(v):
            store["k"] = v; pause()
            if policy == "update": cache["k"] = v
            else: cache.pop("k", None)
        a, b = threading.Thread(target=writer, args=("v1",)), threading.Thread(target=writer, args=("v2",))
        a.start(); b.start(); a.join(); b.join()
        if policy == "delete" and "k" not in cache:          # the next read repopulates from the store
            cache["k"] = store["k"]
        return cache["k"] != store["k"]
    
    
    def reader_vs_writer():
        """A reader misses, reads the store, pauses, then populates; a writer writes and deletes meanwhile.
        Returns the cache dict (with its write time) if a stale value was left behind."""
        store, cache = {"k": "v0"}, {}
        def reader():
            v = store["k"]; pause()                         # miss -> read the store -> slow populate
            cache["k"] = (v, time.time())
        def writer():
            pause(); store["k"] = "v1"; cache.pop("k", None)
        a, b = threading.Thread(target=reader), threading.Thread(target=writer)
        a.start(); b.start(); a.join(); b.join()
        return (store, cache) if "k" in cache and cache["k"][0] != store["k"] else None
    
    
    update = sum(two_writers("update") for _ in range(args.trials))
    delete = sum(two_writers("delete") for _ in range(args.trials))
    print(f"two writers, update the cache: {update:,} of {args.trials:,} trials disagree ({update / args.trials:.2%})")
    print(f"two writers, delete the cache: {delete:,} of {args.trials:,} disagree after the next read")
    
    def follow(store, cache):
        """Right after the race: a TTL-honouring reader reads every 10 ms; how long is it served the stale value?"""
        v, written = cache["k"]
        while True:
            entry = cache.get("k")
            if entry is None or time.time() - entry[1] >= args.ttl:      # expired: the next read goes to the store
                cache["k"] = (store["k"], time.time())
            if cache["k"][0] == store["k"]:
                return time.time() - written
            time.sleep(0.01)
    
    stale, lifetimes = 0, []
    for _ in range(args.trials):
        r = reader_vs_writer()
        if r:
            stale += 1
            if len(lifetimes) < args.ttl_checks: lifetimes.append(follow(*r))
    print(f"reader against writer, delete: {stale:,} of {args.trials:,} trials leave a stale value "
          f"({stale / args.trials:.2%})")
    print(f"with a {args.ttl:g} s TTL, the {len(lifetimes)} stale entries followed were served for at most {max(lifetimes):.2f} s")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {
                "races": [{"experiment": "two writers, update", "bad": update, "pct": update / args.trials * 100},
                          {"experiment": "two writers, delete", "bad": delete, "pct": delete / args.trials * 100},
                          {"experiment": "reader against writer, delete", "bad": stale, "pct": stale / args.trials * 100}],
                "summary": [{"update_pct": update / args.trials * 100, "delete_bad": delete,
                             "stale_pct": stale / args.trials * 100,
                             "max_stale_s": max(lifetimes) if lifetimes else 0, "ttl": args.ttl}]}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run the-race-between-a-reader-and.py
    uv run the-race-between-a-reader-and.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--trials10,000 Trials per experiment.
--max-sleep-ms5 Largest random sleep between the steps of an operation.
--ttl1 TTL bounding a stale entry, in seconds.
--ttl-checks20 Stale entries followed until their TTL expires.
--seed0 Random seed.

Our run

Trials that end with the cache wrong

Trials that end with the cache wrong
ExperimentTrialsShare
two writers, update4,95049.5%
two writers, delete00%
reader against writer, delete4,91049.1%
Reference runApple M2 · 8 cores · 8 GB
Output · uv run the-race-between-a-reader-and.py
two writers, update the cache: 4,946 of 10,000 trials disagree (49.46%)
two writers, delete the cache: 0 of 10,000 disagree after the next read
reader against writer, delete: 4,913 of 10,000 trials leave a stale value (49.13%)
with a 1 s TTL, the 20 stale entries followed were served for at most 1.01 s
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

What we found

"Update the cache" left the cache and the store disagreeing in 49.5% of trials, far more than a few percent. In this lab both writers start at the same instant in every trial and pause a random 0 to 5 ms, so the two cache writes are about as likely to land in either order. In a real system two writes to the same key rarely overlap, so the share is lower, but every overlap that does happen leaves a wrong value that nothing will ever correct.

"Delete the cache key" had 0 disagreements after the next read, as expected. The reader race still happened under delete: 49.1% of trials left a stale value in the cache, again high because every trial races. With a 1 s TTL, the stale entries followed were served for at most 1.01 s.


Read it

Delete, do not update. A delete converges because the next read rebuilds the value from the store, the one place with the truth; an update can freeze the wrong answer into the cache indefinitely.

The populate-after-invalidate race is unavoidable in cache-aside, and its window is the duration of one read. Nothing in the write path can close it, which is why every cache entry should keep a TTL as a floor even when invalidation is in place: the TTL is the bound on how long a lost race can mislead readers. Versioned keys close it entirely, at the cost of carrying the version through the read path.