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 LRU cache: 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 hot keys must be fetched again. Under LRU the damage is min(scan length, C). An admission filter asks a different question: not "what should I evict?" but "should this key come in at all?". A small count-min sketch 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 (alpha = 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 hit rate. 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
LRU's hit rate drops to near the pre-scan floor for as long as about C requests after the scan (damage = min(scan length, C)); the filtered cache loses only a small amount and recovers quickly, and the loss does not grow when you make the scan longer.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide 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)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
| Knob · default | What 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
Data table
| Request | series | Hit rate |
|---|---|---|
| 700K | LRU | 66.6% |
| 710K | LRU | 66.4% |
| 720K | LRU | 66.4% |
| 730K | LRU | 66.2% |
| 740K | LRU | 65.6% |
| 750K | LRU | 66.2% |
| 760K | LRU | 65.6% |
| 770K | LRU | 66.3% |
| 780K | LRU | 66.9% |
| 790K | LRU | 66.8% |
| 800K | LRU | 66.3% |
| 810K | LRU | 67% |
| 820K | LRU | 67% |
| 830K | LRU | 67.2% |
| 840K | LRU | 66.8% |
| 850K | LRU | 66.6% |
| 860K | LRU | 66.4% |
| 870K | LRU | 66.4% |
| 880K | LRU | 66.6% |
| 890K | LRU | 66.1% |
| 900K | LRU | 66% |
| 910K | LRU | 65.9% |
| 920K | LRU | 66.3% |
| 930K | LRU | 66.2% |
| 940K | LRU | 67% |
| 950K | LRU | 67% |
| 960K | LRU | 66.5% |
| 970K | LRU | 65.6% |
| 980K | LRU | 66.5% |
| 990K | LRU | 66.5% |
| 1M | LRU | 66.3% |
| 1.01M | LRU | 0% |
| 1.02M | LRU | 0% |
| 1.03M | LRU | 0% |
| 1.04M | LRU | 0% |
| 1.05M | LRU | 0% |
| 1.06M | LRU | 0% |
| 1.07M | LRU | 0% |
| 1.08M | LRU | 0% |
| 1.09M | LRU | 0% |
| 1.1M | LRU | 0% |
| 1.11M | LRU | 0% |
| 1.12M | LRU | 0% |
| 1.13M | LRU | 0% |
| 1.14M | LRU | 0% |
| 1.15M | LRU | 0% |
| 1.16M | LRU | 0% |
| 1.17M | LRU | 0% |
| 1.18M | LRU | 0% |
| 1.19M | LRU | 0% |
| 1.2M | LRU | 0% |
| 1.21M | LRU | 56.4% |
| 1.22M | LRU | 65.7% |
| 1.23M | LRU | 66.2% |
| 1.24M | LRU | 66.8% |
| 1.25M | LRU | 66.3% |
| 1.26M | LRU | 67.2% |
| 1.27M | LRU | 66.9% |
| 1.28M | LRU | 65.9% |
| 1.29M | LRU | 67.4% |
| 1.3M | LRU | 65.8% |
| 1.31M | LRU | 66.9% |
| 1.32M | LRU | 67.4% |
| 1.33M | LRU | 66.1% |
| 1.34M | LRU | 65.7% |
| 1.35M | LRU | 66.1% |
| 1.36M | LRU | 66.8% |
| 1.37M | LRU | 67.8% |
| 1.38M | LRU | 66.4% |
| 1.39M | LRU | 66.1% |
| 1.4M | LRU | 66.7% |
| 1.41M | LRU | 65.9% |
| 1.42M | LRU | 66.5% |
| 1.43M | LRU | 66.9% |
| 1.44M | LRU | 65.9% |
| 1.45M | LRU | 66.1% |
| 1.46M | LRU | 66.3% |
| 1.47M | LRU | 66.3% |
| 1.48M | LRU | 66.8% |
| 1.49M | LRU | 66.4% |
| 1.5M | LRU | 66.7% |
| 700K | LRU + admission filter | 72.5% |
| 710K | LRU + admission filter | 72.4% |
| 720K | LRU + admission filter | 71.4% |
| 730K | LRU + admission filter | 72.3% |
| 740K | LRU + admission filter | 71.4% |
| 750K | LRU + admission filter | 72.7% |
| 760K | LRU + admission filter | 72.2% |
| 770K | LRU + admission filter | 72.7% |
| 780K | LRU + admission filter | 72.8% |
| 790K | LRU + admission filter | 72.8% |
| 800K | LRU + admission filter | 72.1% |
| 810K | LRU + admission filter | 72.8% |
| 820K | LRU + admission filter | 72.3% |
| 830K | LRU + admission filter | 73% |
| 840K | LRU + admission filter | 72.9% |
| 850K | LRU + admission filter | 72.8% |
| 860K | LRU + admission filter | 71.9% |
| 870K | LRU + admission filter | 72.6% |
| 880K | LRU + admission filter | 72.5% |
| 890K | LRU + admission filter | 72.2% |
| 900K | LRU + admission filter | 71.7% |
| 910K | LRU + admission filter | 72.3% |
| 920K | LRU + admission filter | 71.8% |
| 930K | LRU + admission filter | 72% |
| 940K | LRU + admission filter | 72.7% |
| 950K | LRU + admission filter | 73.1% |
| 960K | LRU + admission filter | 72.1% |
| 970K | LRU + admission filter | 71.9% |
| 980K | LRU + admission filter | 72.2% |
| 990K | LRU + admission filter | 72.6% |
| 1M | LRU + admission filter | 71.6% |
| 1.01M | LRU + admission filter | 0% |
| 1.02M | LRU + admission filter | 0% |
| 1.03M | LRU + admission filter | 0% |
| 1.04M | LRU + admission filter | 0% |
| 1.05M | LRU + admission filter | 0% |
| 1.06M | LRU + admission filter | 0% |
| 1.07M | LRU + admission filter | 0% |
| 1.08M | LRU + admission filter | 0% |
| 1.09M | LRU + admission filter | 0% |
| 1.1M | LRU + admission filter | 0% |
| 1.11M | LRU + admission filter | 0% |
| 1.12M | LRU + admission filter | 0% |
| 1.13M | LRU + admission filter | 0% |
| 1.14M | LRU + admission filter | 0% |
| 1.15M | LRU + admission filter | 0% |
| 1.16M | LRU + admission filter | 0% |
| 1.17M | LRU + admission filter | 0% |
| 1.18M | LRU + admission filter | 0% |
| 1.19M | LRU + admission filter | 0% |
| 1.2M | LRU + admission filter | 0% |
| 1.21M | LRU + admission filter | 63.8% |
| 1.22M | LRU + admission filter | 65.3% |
| 1.23M | LRU + admission filter | 67.8% |
| 1.24M | LRU + admission filter | 68.3% |
| 1.25M | LRU + admission filter | 68.9% |
| 1.26M | LRU + admission filter | 70.7% |
| 1.27M | LRU + admission filter | 70.7% |
| 1.28M | LRU + admission filter | 70.4% |
| 1.29M | LRU + admission filter | 72% |
| 1.3M | LRU + admission filter | 71.1% |
| 1.31M | LRU + admission filter | 71.5% |
| 1.32M | LRU + admission filter | 72.2% |
| 1.33M | LRU + admission filter | 71.5% |
| 1.34M | LRU + admission filter | 71.1% |
| 1.35M | LRU + admission filter | 72.1% |
| 1.36M | LRU + admission filter | 72.9% |
| 1.37M | LRU + admission filter | 73.6% |
| 1.38M | LRU + admission filter | 72% |
| 1.39M | LRU + admission filter | 72.1% |
| 1.4M | LRU + admission filter | 72.5% |
| 1.41M | LRU + admission filter | 72.3% |
| 1.42M | LRU + admission filter | 72.8% |
| 1.43M | LRU + admission filter | 72.4% |
| 1.44M | LRU + admission filter | 72.1% |
| 1.45M | LRU + admission filter | 72.4% |
| 1.46M | LRU + admission filter | 72.6% |
| 1.47M | LRU + admission filter | 72.4% |
| 1.48M | LRU + admission filter | 72.4% |
| 1.49M | LRU + admission filter | 72.3% |
| 1.5M | LRU + admission filter | 72.7% |
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 W-TinyLFU: a small LRU window that absorbs new keys, a frequency-gated main region, and a doorkeeper 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.