Skip to content
About
Main content

Scan resistance: LRU against an admission filter

Simulate a modelNeeds Python with NumPy

Problem

A one-time scan (a crawler, a backfill, a report reading every row once) is poison for an : every scanned key is newer than everything in the cache, so a scan longer than the cache size C replaces all of it, and the must be fetched again. Under LRU the damage is min(scan length, C). An asks a different question: not "what should I evict?" but "should this key come in at all?". A small count-min estimates how often each key has been seen, and a new key is admitted only if it has been seen more often than the entry it would evict. Scanned keys are seen once, so they should never get in.

Generate a Zipf-distributed request stream ( = 1) over 100,000 keys with numpy.random.zipf or a precomputed cumulative table, 2 million requests, cache size C = 5,000. Simulate an LRU cache and compute the . Insert a scan of 200,000 distinct keys in the middle of the stream and plot the hit rate in windows of 10,000 requests. Then implement a simple admission filter: a count-min sketch with 4 rows, a counter increment on every request, halving of all counters every 10 x C requests, and the rule "admit a new key only if its estimated count is higher than that of the eviction victim". Run the same stream.

The chart also holds runs with scans of 100,000 and 400,000 keys.

The question. How much hit rate does LRU lose after the scan, how much does the admission filter lose, and does the filter's loss grow with the length of the scan?


Predict

After a 400,000-key scan, how does the admission filter's hit rate compare with LRU's?

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.

    scan-resistance-lru-against-an-admission.py89 linesDownload
    View the code
    scan-resistance-lru-against-an-admission.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["numpy"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Scan resistance: LRU against an admission filter.
    
    A Zipf request stream with a one-time scan in the middle, through plain LRU
    and through LRU behind a count-min-sketch admission filter (admit a new key
    only if it has been seen more often than the entry it would evict).
    """
    import argparse, json
    from collections import OrderedDict
    import numpy as np
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--requests", type=int, default=2_000_000, help="Zipf requests in the stream.")
    p.add_argument("--keys", type=int, default=100_000, help="Distinct keys in the Zipf stream.")
    p.add_argument("--alpha", type=float, default=1.0, help="Zipf exponent.")
    p.add_argument("--capacity", type=int, default=5_000, help="Cache size C, in entries.")
    p.add_argument("--scan", type=int, default=200_000, help="Distinct one-time keys scanned in the middle of the stream.")
    p.add_argument("--window", type=int, default=10_000, help="Requests per hit-rate window.")
    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()
    rng = np.random.default_rng(args.seed)
    
    w = 1.0 / np.arange(1, args.keys + 1) ** args.alpha
    zipf = rng.choice(args.keys, size=args.requests, p=w / w.sum()).tolist()
    mid = args.requests // 2
    trace = zipf[:mid] + list(range(args.keys, args.keys + args.scan)) + zipf[mid:]   # scan keys never repeat
    scan_start, scan_end = mid, mid + args.scan
    
    
    class CountMin:
        """4 rows of counters; every counter halves every `reset` increments (aging)."""
        def __init__(self, width, reset, rows=4):
            self.t = [[0] * width for _ in range(rows)]
            self.seeds = [0x9E3779B1, 0x85EBCA77, 0xC2B2AE3D, 0x27D4EB2F][:rows]
            self.w, self.reset, self.n = width, reset, 0
        def _cols(self, k):
            return [((k + 1) * s >> 7) % self.w for s in self.seeds]
        def add(self, k):
            for r, c in enumerate(self._cols(k)): self.t[r][c] += 1
            self.n += 1
            if self.n % self.reset == 0: self.t = [[x >> 1 for x in row] for row in self.t]
        def est(self, k):
            return min(self.t[r][c] for r, c in enumerate(self._cols(k)))
    
    
    def run(admission):
        cache, hits, windows, sketch = OrderedDict(), [], [], CountMin(4 * args.capacity, 10 * args.capacity)
        for i, k in enumerate(trace):
            if admission: sketch.add(k)
            if k in cache:
                cache.move_to_end(k); hits.append(1)
            else:
                hits.append(0)
                if len(cache) < args.capacity:
                    cache[k] = True
                else:
                    victim = next(iter(cache))
                    if not admission or sketch.est(k) > sketch.est(victim):
                        cache.popitem(last=False); cache[k] = True
            if (i + 1) % args.window == 0:
                windows.append(sum(hits[-args.window:]) / args.window)
        return windows
    
    
    lru, filtered = run(False), run(True)
    def zipf_hit(ws, a, b):                                  # mean hit rate over windows [a, b) of the Zipf part
        seg = ws[a // args.window: b // args.window]
        return sum(seg) / max(1, len(seg))
    before = (zipf_hit(lru, mid - 200_000, mid), zipf_hit(filtered, mid - 200_000, mid))
    after = (zipf_hit(lru, scan_end, scan_end + args.capacity * 4), zipf_hit(filtered, scan_end, scan_end + args.capacity * 4))
    print(f"Zipf alpha {args.alpha}, {args.keys:,} keys, C = {args.capacity:,}, scan of {args.scan:,} keys at request {mid:,}")
    print(f"hit rate before the scan:   LRU {before[0]:.1%}   with admission filter {before[1]:.1%}")
    print(f"hit rate in the {args.capacity * 4:,} requests after the scan: LRU {after[0]:.1%}   with admission filter {after[1]:.1%}")
    
    rows = [{"request": (i + 1) * args.window, "policy": name, "hit_pct": h * 100}
            for name, ws in (("LRU", lru), ("LRU + admission filter", filtered)) for i, h in enumerate(ws)]
    rows = [r for r in rows if mid - 300_000 <= r["request"] <= scan_end + 300_000]          # around the scan
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"windows": rows, "summary": [{
                "lru_before": before[0] * 100, "lru_after": after[0] * 100,
                "filter_before": before[1] * 100, "filter_after": after[1] * 100}]}}, 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 scan-resistance-lru-against-an-admission.py
    uv run scan-resistance-lru-against-an-admission.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--requests2,000,000 Zipf requests in the stream.
--keys100,000 Distinct keys in the Zipf stream.
--alpha1 Zipf exponent.
--capacity5000 Cache size C, in entries.
--scan200,000 Distinct one-time keys scanned in the middle of the stream.
--window10,000 Requests per hit-rate window.
--seed0 Random seed.

Our run

Hit rate per 10,000 requests around the scanApple M2 · 8 cores · 8 GB
LRULRU + admission filter
Scan length
Data table
Hit rate per 10,000 requests around the scan
RequestseriesHit rate
700KLRU66.6%
710KLRU66.4%
720KLRU66.4%
730KLRU66.2%
740KLRU65.6%
750KLRU66.2%
760KLRU65.6%
770KLRU66.3%
780KLRU66.9%
790KLRU66.8%
800KLRU66.3%
810KLRU67%
820KLRU67%
830KLRU67.2%
840KLRU66.8%
850KLRU66.6%
860KLRU66.4%
870KLRU66.4%
880KLRU66.6%
890KLRU66.1%
900KLRU66%
910KLRU65.9%
920KLRU66.3%
930KLRU66.2%
940KLRU67%
950KLRU67%
960KLRU66.5%
970KLRU65.6%
980KLRU66.5%
990KLRU66.5%
1MLRU66.3%
1.01MLRU0%
1.02MLRU0%
1.03MLRU0%
1.04MLRU0%
1.05MLRU0%
1.06MLRU0%
1.07MLRU0%
1.08MLRU0%
1.09MLRU0%
1.1MLRU0%
1.11MLRU0%
1.12MLRU0%
1.13MLRU0%
1.14MLRU0%
1.15MLRU0%
1.16MLRU0%
1.17MLRU0%
1.18MLRU0%
1.19MLRU0%
1.2MLRU0%
1.21MLRU56.4%
1.22MLRU65.7%
1.23MLRU66.2%
1.24MLRU66.8%
1.25MLRU66.3%
1.26MLRU67.2%
1.27MLRU66.9%
1.28MLRU65.9%
1.29MLRU67.4%
1.3MLRU65.8%
1.31MLRU66.9%
1.32MLRU67.4%
1.33MLRU66.1%
1.34MLRU65.7%
1.35MLRU66.1%
1.36MLRU66.8%
1.37MLRU67.8%
1.38MLRU66.4%
1.39MLRU66.1%
1.4MLRU66.7%
1.41MLRU65.9%
1.42MLRU66.5%
1.43MLRU66.9%
1.44MLRU65.9%
1.45MLRU66.1%
1.46MLRU66.3%
1.47MLRU66.3%
1.48MLRU66.8%
1.49MLRU66.4%
1.5MLRU66.7%
700KLRU + admission filter72.5%
710KLRU + admission filter72.4%
720KLRU + admission filter71.4%
730KLRU + admission filter72.3%
740KLRU + admission filter71.4%
750KLRU + admission filter72.7%
760KLRU + admission filter72.2%
770KLRU + admission filter72.7%
780KLRU + admission filter72.8%
790KLRU + admission filter72.8%
800KLRU + admission filter72.1%
810KLRU + admission filter72.8%
820KLRU + admission filter72.3%
830KLRU + admission filter73%
840KLRU + admission filter72.9%
850KLRU + admission filter72.8%
860KLRU + admission filter71.9%
870KLRU + admission filter72.6%
880KLRU + admission filter72.5%
890KLRU + admission filter72.2%
900KLRU + admission filter71.7%
910KLRU + admission filter72.3%
920KLRU + admission filter71.8%
930KLRU + admission filter72%
940KLRU + admission filter72.7%
950KLRU + admission filter73.1%
960KLRU + admission filter72.1%
970KLRU + admission filter71.9%
980KLRU + admission filter72.2%
990KLRU + admission filter72.6%
1MLRU + admission filter71.6%
1.01MLRU + admission filter0%
1.02MLRU + admission filter0%
1.03MLRU + admission filter0%
1.04MLRU + admission filter0%
1.05MLRU + admission filter0%
1.06MLRU + admission filter0%
1.07MLRU + admission filter0%
1.08MLRU + admission filter0%
1.09MLRU + admission filter0%
1.1MLRU + admission filter0%
1.11MLRU + admission filter0%
1.12MLRU + admission filter0%
1.13MLRU + admission filter0%
1.14MLRU + admission filter0%
1.15MLRU + admission filter0%
1.16MLRU + admission filter0%
1.17MLRU + admission filter0%
1.18MLRU + admission filter0%
1.19MLRU + admission filter0%
1.2MLRU + admission filter0%
1.21MLRU + admission filter63.8%
1.22MLRU + admission filter65.3%
1.23MLRU + admission filter67.8%
1.24MLRU + admission filter68.3%
1.25MLRU + admission filter68.9%
1.26MLRU + admission filter70.7%
1.27MLRU + admission filter70.7%
1.28MLRU + admission filter70.4%
1.29MLRU + admission filter72%
1.3MLRU + admission filter71.1%
1.31MLRU + admission filter71.5%
1.32MLRU + admission filter72.2%
1.33MLRU + admission filter71.5%
1.34MLRU + admission filter71.1%
1.35MLRU + admission filter72.1%
1.36MLRU + admission filter72.9%
1.37MLRU + admission filter73.6%
1.38MLRU + admission filter72%
1.39MLRU + admission filter72.1%
1.4MLRU + admission filter72.5%
1.41MLRU + admission filter72.3%
1.42MLRU + admission filter72.8%
1.43MLRU + admission filter72.4%
1.44MLRU + admission filter72.1%
1.45MLRU + admission filter72.4%
1.46MLRU + admission filter72.6%
1.47MLRU + admission filter72.4%
1.48MLRU + admission filter72.4%
1.49MLRU + admission filter72.3%
1.5MLRU + admission filter72.7%
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · run 2026-10-20 · 3 runs
Output · uv run scan-resistance-lru-against-an-admission.py --scan 200000
Zipf alpha 1.0, 100,000 keys, C = 5,000, scan of 200,000 keys at request 1,000,000
hit rate before the scan:   LRU 66.5%   with admission filter 72.3%
hit rate in the 20,000 requests after the scan: LRU 61.0%   with admission filter 64.6%

What we found

Before the scan the filter already did better than LRU (72.3% against 66.5%), because it keeps rarely-seen keys out all the time. In the 20,000 requests after a 200,000-key scan, LRU was at 61% while it refilled, and the filter at 64.6%.

The filter's loss did grow with the scan, against the expectation. After a 100,000-key scan it held 70.4%; after 400,000 keys, 58.7%, below LRU. The cause is the aging rule: the counters halve every 10 × C requests, and the scanned keys count as requests. A long scan halves the hot keys' counts many times over while they are not being requested, and the scan keys, colliding in a sketch of fixed width, build up small counts of their own. Once both are near zero, scan keys start to win admission. The protection holds while the scan is short relative to the aging period.


Read it

Admission is the better lever than eviction because it is cheap and off the read path: a few bits per key over the whole key space, against exact recency bookkeeping for every resident entry. And it changes the request stream the cache sees, which no eviction policy can do.

The chapter's bound, damage limited to one window regardless of scan length, belongs to : a small LRU window that absorbs new keys, a frequency-gated main region, and a that catches keys seen once before they reach the sketch. The simple filter in this lab has no window and no doorkeeper, and the run shows why both are there. The other defence comes from data systems themselves: a scan that declares its reads will not repeat can bypass the cache entirely.