What an overflowing buffer loses
Simulate a modelNeeds Python
Problem
A bounded buffer that fills must decide what to do with the next record: block the producer, drop the newest (reject the arrival), drop the oldest (prefer fresh data over complete data), or spill. Which records are lost is decided here. When a newer record makes an older one worthless (a price, a sensor reading, a status), dropping the head of the queue is strictly better than dropping the tail, and for a keyed buffer where only the latest value per key matters, dropping the oldest per key is better still: you reduce volume without losing any entity.
Simulate a stream of updates for 1,000 entities (each record is the new value of one entity, chosen at random). The producer sends 1,500 updates per second for 60 seconds into a buffer of 2,000 records; the consumer applies 1,000 per second, then drains what is left. Run three full-policies: drop-newest, drop-oldest, and drop-oldest per key (a newer update for a key replaces the queued one; only a new key evicts the head). At the end, compare the consumer's value for every entity with the true latest value, and record how old each value was when it was applied.
The script is an exact discrete-event simulation with a fixed seed.
The question. Each policy throws away about the same number of records. How many entities does each leave with a wrong final value, and how stale is what the consumer applies?
Predict
Dropping the head of the queue (the oldest) is strictly better than dropping the tail (the newest) when a newer record makes an older one worthless. For a keyed buffer where only the latest value per key matters, dropping the oldest per key is better still: you reduce volume without losing any entity.
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
what-an-overflowing-buffer-loses.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """What an overflowing buffer loses: drop-newest, drop-oldest, drop-oldest per key. A keyed stream (one update per record for one of K entities, such as a product's price) overloads a bounded buffer: the producer is faster than the consumer for a while, then stops, and the consumer drains what is left. Three full-policies: drop-newest (reject the arrival), drop-oldest (evict the head), and drop-oldest per key (a newer update for a key replaces the queued one; only a new key evicts the head). An exact discrete-event simulation. At the end the consumer's view of every entity is compared with the truth. """ import argparse, json, random from collections import OrderedDict, deque p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--produce", type=float, default=1500, help="Updates produced per second during the overload.") p.add_argument("--consume", type=float, default=1000, help="Updates the consumer applies per second.") p.add_argument("--keys", type=int, default=1000, help="Number of entities (keys).") p.add_argument("--buffer", type=int, default=2000, help="Buffer capacity (records).") p.add_argument("--seconds", type=float, default=60, help="Seconds of overload; then the producer stops and the consumer drains.") p.add_argument("--seed", type=int, default=1, help="Random seed for the key sequence.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() def run(policy): rnd = random.Random(args.seed) truth, seen = {}, {} # key -> latest value produced / applied fifo, keyed = deque(), OrderedDict() # (key, value, t) for the FIFO policies; key -> (value, t) for per key produced = dropped = 0 ages = [] t_arr, t_srv, value = 0.0, 0.0, 0 while True: size = len(keyed) if policy == "drop-oldest per key" else len(fifo) arriving = t_arr < args.seconds if arriving and t_arr <= t_srv: t, key = t_arr, rnd.randrange(args.keys) value += 1; produced += 1; truth[key] = value if policy == "drop-oldest per key": if key in keyed: keyed[key] = (value, t); dropped += 1 # the queued, older update is superseded else: if len(keyed) >= args.buffer: keyed.popitem(last=False); dropped += 1 keyed[key] = (value, t) elif size < args.buffer: fifo.append((key, value, t)) elif policy == "drop-newest": dropped += 1 else: # drop-oldest fifo.popleft(); fifo.append((key, value, t)); dropped += 1 t_arr += 1 / args.produce elif size: t = max(t_srv, 0.0) if policy == "drop-oldest per key": key, (v, made) = keyed.popitem(last=False) else: key, v, made = fifo.popleft() seen[key] = v; ages.append(t - made) t_srv = t + 1 / args.consume elif arriving: t_srv = t_arr else: break ages.sort() wrong = sum(1 for k, v in truth.items() if seen.get(k) != v) return {"policy": policy, "produced": produced, "dropped": dropped, "dropped_pct": 100 * dropped / produced, "wrong_final": wrong, "wrong_final_pct": 100 * wrong / len(truth), "p50_age_s": ages[len(ages) // 2], "p99_age_s": ages[int(len(ages) * 0.99)]} rows = [run(pol) for pol in ("drop-newest", "drop-oldest", "drop-oldest per key")] for r in rows: print(f"{r['policy']:<20} dropped {r['dropped']:>7,} ({r['dropped_pct']:4.1f}%) entities with a wrong final value " f"{r['wrong_final']:>4} ({r['wrong_final_pct']:4.1f}%) age at delivery p50 {r['p50_age_s']:.3f} s p99 {r['p99_age_s']:.3f} s") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"policies": rows}}, 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 what-an-overflowing-buffer-loses.py uv run what-an-overflowing-buffer-loses.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --produce1500 | Updates produced per second during the overload. |
| --consume1000 | Updates the consumer applies per second. |
| --keys1000 | Number of entities (keys). |
| --buffer2000 | Buffer capacity (records). |
| --seconds60 | Seconds of overload; then the producer stops and the consumer drains. |
| --seed1 | Random seed for the key sequence. |
Our run
One overload, three full-policies
| Full-policy | Records dropped (%) | Entities with a wrong final value | Age when applied, p50 | Age when applied, p99 |
|---|---|---|---|---|
| drop-newest | 31.1 | 321 | 2 s | 2 s |
| drop-oldest | 31.1 | 44 | 1.33 s | 1.79 s |
| drop-oldest per key | 32.9 | 0 | 324 ms | 355 ms |
drop-newest dropped 28,000 (31.1%) entities with a wrong final value 321 (32.1%) age at delivery p50 2.000 s p99 2.000 s
drop-oldest dropped 28,000 (31.1%) entities with a wrong final value 44 ( 4.4%) age at delivery p50 1.333 s p99 1.793 s
drop-oldest per key dropped 29,650 (32.9%) entities with a wrong final value 0 ( 0.0%) age at delivery p50 0.324 s p99 0.355 sWhat we found
All three policies threw away roughly a third of the records: 31.1% for drop-newest, 31.1% for drop-oldest and 32.9% superseded for drop-oldest per key. What they lost was very different. Drop-newest left 321 of 1,000 entities with a wrong final value, because the updates it rejected while full included the last one for those entities; drop-oldest left 44; drop-oldest per key left 0. The applied values were also fresher: a median age of 324 ms per key, against 2 s for drop-newest, whose full queue is a fixed delay in front of every record.
Read it
The number of records dropped says almost nothing about the damage. What matters is which records go, and that is the full-policy's decision, made once when the buffer is configured. For state where the latest value wins, coalescing by key keeps the buffer smaller than the number of entities, so it never has to drop an entity at all. For data where every record counts (payments, orders), none of the three dropping policies is acceptable: block the producer or spill instead.