Skip to content
About
Main content

Stampede with and without protection

Simulate a modelNeeds Python

Problem

When a popular entry expires, every request that arrives before it is refilled misses and recomputes it. By Little's Law, the number of duplicate recomputes in progress is L = λ_k · W: the key's request rate times the time one recompute takes. At 1,000 requests per second and a 2-second query, that is 2,000 identical queries at once, and they slow each other down, which makes W longer and the worse. Two protections exist. lets one request recompute while the others wait for its result. lets a random request refresh the entry shortly before it expires, while everyone else is still served the old value.

Write a small Python program with a fake "database" function that sleeps 2 seconds and counts how many times it is called. Put a dictionary cache with a in front of it. Start 200 threads that all ask for the same key at the moment it expires. First run it with no protection and record the number of database calls (expect about 200, the value of L = λ_k · W). Then add per-key coalescing (one thread computes, the others wait on an event) and record the calls again (expect 1). Finally replace coalescing with early probabilistic expiry: refresh when now - delta * beta * math.log(random.random()) >= expiry, with delta equal to the measured recompute time and beta = 1. Send steady traffic for many TTL periods and count recomputes per period (expect slightly more than 1) and the longest time any request waited (expect close to 0).

The script uses a 120-second TTL for the steady-traffic part, refreshes in the background, and runs for three periods.

The question. How many database calls does one expiry cause with no protection and with coalescing, and how many recomputes per TTL period does early expiry need?


Predict

With no protection, how many of the 200 threads call the database when the entry expires?

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.

    stampede-with-and-without-protection.py125 linesDownload
    View the code
    stampede-with-and-without-protection.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Stampede with and without protection.
    
    A fake database call that sleeps and counts its calls, behind a TTL cache.
    200 threads ask for the same key the moment it expires: first unprotected,
    then with per-key coalescing; then steady traffic with early probabilistic expiry.
    """
    import argparse, json, math, random, threading, time
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--threads", type=int, default=200, help="Threads that ask for the key at the moment it expires.")
    p.add_argument("--recompute", type=float, default=2.0, help="Seconds one database call takes (W).")
    p.add_argument("--ttl", type=float, default=120.0, help="TTL of the entry, in seconds, for the steady-traffic part.")
    p.add_argument("--periods", type=int, default=3, help="TTL periods of steady traffic.")
    p.add_argument("--clients", type=int, default=50, help="Steady-traffic clients, each asking every 10 ms.")
    p.add_argument("--beta", type=float, default=1.0, help="beta of early probabilistic expiry.")
    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)
    
    calls = 0
    call_lock = threading.Lock()
    def db():
        global calls
        with call_lock: calls += 1
        time.sleep(args.recompute); return "page"
    
    
    def burst(get):
        """All threads ask at once for a key that has just expired; returns database calls."""
        global calls
        calls, cache = 0, {}
        start = threading.Barrier(args.threads)
        def worker():
            start.wait(); get("home", cache)
        ts = [threading.Thread(target=worker) for _ in range(args.threads)]
        for t in ts: t.start()
        for t in ts: t.join()
        return calls
    
    
    # 1. No protection: every thread that misses calls the database
    def get_plain(key, cache):
        v = cache.get(key)
        if v: return v
        cache[key] = db(); return cache[key]
    
    # 2. Per-key coalescing: one thread computes, the others wait on an event
    lock, events = threading.Lock(), {}
    def get_coalesced(key, cache):
        v = cache.get(key)
        if v: return v
        with lock:
            ev = events.get(key)
            leader = ev is None
            if leader: events[key] = ev = threading.Event()
        if leader:
            cache[key] = db(); ev.set()
            with lock: events.pop(key)
        else:
            ev.wait()
        return cache[key]
    
    plain = burst(get_plain)
    coalesced = burst(get_coalesced)
    print(f"{args.threads} threads at expiry, no protection: {plain} database calls "
          f"(L = lambda_k * W: every request in the window recomputes)")
    print(f"{args.threads} threads at expiry, coalesced:     {coalesced} database call(s)")
    
    # 3. Early probabilistic expiry under steady traffic: a request that draws
    #    now - delta*beta*ln(rand) >= expiry refreshes in the background; everyone is served the current value
    calls = 0
    entry = {"value": db(), "expiry": time.time() + args.ttl, "refreshing": False}
    delta = args.recompute                                    # the measured recompute time
    refresh_log, waits, stop = [], [], threading.Event()
    entry_lock = threading.Lock()
    t0 = time.time()
    
    def refresh():
        v = db()
        with entry_lock:
            entry.update(value=v, expiry=time.time() + args.ttl, refreshing=False)
    
    def client():
        while not stop.is_set():
            t = time.time()
            with entry_lock:
                early = t - delta * args.beta * math.log(random.random()) >= entry["expiry"]
                if early and not entry["refreshing"]:
                    entry["refreshing"] = True
                    refresh_log.append(t - t0)
                    threading.Thread(target=refresh).start()
                _ = entry["value"]
            waits.append(time.time() - t)
            time.sleep(0.01)
    
    calls = 0
    cs = [threading.Thread(target=client) for _ in range(args.clients)]
    for c in cs: c.start()
    time.sleep(args.ttl * args.periods)
    stop.set()
    for c in cs: c.join()
    
    per_period = [sum(1 for r in refresh_log if i * args.ttl <= r < (i + 1) * args.ttl) for i in range(args.periods)]
    rate = len(waits) / (args.ttl * args.periods)
    lead = delta * args.beta * math.log(rate * delta * args.beta)     # where lambda * delta * e^(-x/delta) = 1
    print(f"steady traffic {rate:,.0f} requests/s: early expiry should fire about delta*beta*ln(lambda*delta) = {lead:.1f} s "
          f"before expiry, so about {args.ttl / (args.ttl - lead + delta):.2f} recomputes per {args.ttl:g} s period")
    print(f"early expiry, {args.periods} TTL periods of {args.ttl:g} s, {args.clients} clients: recomputes per period {per_period} "
          f"(mean {sum(per_period) / len(per_period):.2f}); longest wait {max(waits) * 1000:.1f} ms")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {
                "periods": [{"period": i + 1, "recomputes": n} for i, n in enumerate(per_period)],
                "summary": [{"plain_calls": plain, "coalesced_calls": coalesced,
                             "mean_recomputes": sum(per_period) / len(per_period), "max_wait_ms": max(waits) * 1000,
                             "rate": rate, "lead_s": lead, "predicted": args.ttl / (args.ttl - lead + delta),
                             "threads": args.threads}]}}, 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 stampede-with-and-without-protection.py
    uv run stampede-with-and-without-protection.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--threads200 Threads that ask for the key at the moment it expires.
--recompute2 Seconds one database call takes (W).
--ttl120 TTL of the entry, in seconds, for the steady-traffic part.
--periods3 TTL periods of steady traffic.
--clients50 Steady-traffic clients, each asking every 10 ms.
--beta1 beta of early probabilistic expiry.
--seed0 Random seed.

Our run

Early probabilistic expiry: recomputes per TTL periodApple M2 · 8 cores · 8 GB
Data table
Early probabilistic expiry: recomputes per TTL period
TTL periodRecomputes
11
21
31
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20
Output · uv run stampede-with-and-without-protection.py
200 threads at expiry, no protection: 200 database calls (L = lambda_k * W: every request in the window recomputes)
200 threads at expiry, coalesced:     1 database call(s)
steady traffic 4,211 requests/s: early expiry should fire about delta*beta*ln(lambda*delta) = 18.1 s before expiry, so about 1.15 recomputes per 120 s period
early expiry, 3 TTL periods of 120 s, 50 clients: recomputes per period [1, 1, 1] (mean 1.00); longest wait 13.5 ms

What we found

With no protection, the 200 threads made 200 database calls: every one of them missed and recomputed. With coalescing they made 1.

Early expiry recomputed 1 times per period on average and no request waited longer than 13.5 ms, as expected. How early it fires depends on the traffic: at 4,210 requests per second and a 2-second recompute, the refresh is expected about 18.1 s before expiry, so a period sees about 1.15 recomputes. With a TTL much shorter than that lead (try --ttl 10), the entry is refreshed again almost as soon as the last refresh lands, and recomputes per period climb well above 1.


Read it

What makes a stampede a disaster and not just waste is feedback: the duplicate queries slow the database, which lengthens W, which raises L. If λ_k · W exceeds what the database can serve, the key never fills again, even after the trigger is gone.

Coalescing removes the stampede inside one process, but across a fleet of 50 servers it still leaves 50 recomputes unless the lock is distributed. Early probabilistic expiry needs no lock at all and works across the fleet: the probability of refreshing rises as expiry nears, scaled by the measured recompute time, so expensive entries start trying earlier. Its price is slightly more than one recompute per period, and its condition is a TTL comfortably longer than the refresh lead δ·β·ln(λ·δ).