Head-of-line blocking: log versus queue
Simulate a modelNeeds Python
Problem
A log keeps order within a partition, so a consumer cannot move past a record it cannot process: everything behind that record waits. A shared queue has no such order, so one bad message delays nothing, but once a message is consumed it is gone and the history cannot be read again.
Simulate 12 partitions with 100 records/s each and one poison record. Compare three consumer policies: block and retry, skip, and pause that partition only. Plot lag over time per partition. Then change the model so that one consumer serves all 12 partitions and blocks on the poison record. Then replace the log with a shared queue.
The question. How far does each partition fall behind under each policy, and what changes when one consumer serves all 12 partitions?
Predict
Expect lag on the blocked partition to rise by about 100 records every second (360,000 after one hour), while the other 11 stay near zero (each partition has its own consumer here). With one consumer serving all 12 partitions and blocking on the poison record, all 12 partitions grow, which is why "pause only that partition" is the better policy. With a shared queue the poison message delays nothing, but you can no longer read old data.
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
head-of-line-blocking-log-versus.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Head-of-line blocking: a log against a queue. 12 partitions of 100 records/s each and one poison record in partition 3. Consumer lag under three policies with one consumer per partition, then with one consumer serving all 12 that blocks, then with a shared queue. """ import argparse, json p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--partitions", type=int, default=12, help="Partitions.") p.add_argument("--rate", type=int, default=100, help="Records per second per partition.") p.add_argument("--seconds", type=int, default=3600, help="Simulated seconds.") p.add_argument("--poison-at", type=int, default=60, help="Second at which the poison record arrives.") p.add_argument("--blocked", type=int, default=3, help="Partition with the poison record.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() P, R, B = args.partitions, args.rate, args.blocked def run(policy): lag = [0] * P; series = []; skipped = 0 shared_capacity = R * P # one consumer for all partitions: same total capacity for sec in range(args.seconds): stuck = sec >= args.poison_at for q in range(P): lag[q] += R if policy == "block and retry (consumer per partition)": for q in range(P): if not (q == B and stuck): lag[q] = 0 elif policy == "skip the poison record": if stuck and sec == args.poison_at: skipped += 1 lag = [0] * P elif policy == "pause that partition only": for q in range(P): if not (q == B and stuck): lag[q] = 0 # the paused partition waits; the rest flow elif policy == "one consumer for all, blocks": if not stuck: lag = [0] * P # stuck on the poison record: nothing moves elif policy == "shared queue": lag = [0] * P # any worker takes the next message; the poison is set aside if sec % 60 == 59: series.append({"minute": (sec + 1) // 60, "policy": policy, "blocked_lag": lag[B], "max_other_lag": max(lag[q] for q in range(P) if q != B)}) return series rows = [] for pol in ("block and retry (consumer per partition)", "pause that partition only", "skip the poison record", "one consumer for all, blocks", "shared queue"): s = run(pol); rows += s print(f"{pol:<42} after {args.seconds // 60} min: blocked partition lag {s[-1]['blocked_lag']:>9,} " f"other partitions max {s[-1]['max_other_lag']:>9,}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"lag": 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 head-of-line-blocking-log-versus.py uv run head-of-line-blocking-log-versus.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --partitions12 | Partitions. |
| --rate100 | Records per second per partition. |
| --seconds3600 | Simulated seconds. |
| --poison-at60 | Second at which the poison record arrives. |
| --blocked3 | Partition with the poison record. |
Our run
Data table
| Minute | Partition with the poison record | Worst other partition |
|---|---|---|
| 1 | 0 | 0 |
| 2 | 6K | 0 |
| 3 | 12K | 0 |
| 4 | 18K | 0 |
| 5 | 24K | 0 |
| 6 | 30K | 0 |
| 7 | 36K | 0 |
| 8 | 42K | 0 |
| 9 | 48K | 0 |
| 10 | 54K | 0 |
| 11 | 60K | 0 |
| 12 | 66K | 0 |
| 13 | 72K | 0 |
| 14 | 78K | 0 |
| 15 | 84K | 0 |
| 16 | 90K | 0 |
| 17 | 96K | 0 |
| 18 | 102K | 0 |
| 19 | 108K | 0 |
| 20 | 114K | 0 |
| 21 | 120K | 0 |
| 22 | 126K | 0 |
| 23 | 132K | 0 |
| 24 | 138K | 0 |
| 25 | 144K | 0 |
| 26 | 150K | 0 |
| 27 | 156K | 0 |
| 28 | 162K | 0 |
| 29 | 168K | 0 |
| 30 | 174K | 0 |
| 31 | 180K | 0 |
| 32 | 186K | 0 |
| 33 | 192K | 0 |
| 34 | 198K | 0 |
| 35 | 204K | 0 |
| 36 | 210K | 0 |
| 37 | 216K | 0 |
| 38 | 222K | 0 |
| 39 | 228K | 0 |
| 40 | 234K | 0 |
| 41 | 240K | 0 |
| 42 | 246K | 0 |
| 43 | 252K | 0 |
| 44 | 258K | 0 |
| 45 | 264K | 0 |
| 46 | 270K | 0 |
| 47 | 276K | 0 |
| 48 | 282K | 0 |
| 49 | 288K | 0 |
| 50 | 294K | 0 |
| 51 | 300K | 0 |
| 52 | 306K | 0 |
| 53 | 312K | 0 |
| 54 | 318K | 0 |
| 55 | 324K | 0 |
| 56 | 330K | 0 |
| 57 | 336K | 0 |
| 58 | 342K | 0 |
| 59 | 348K | 0 |
| 60 | 354K | 0 |
block and retry (consumer per partition) after 60 min: blocked partition lag 354,000 other partitions max 0
pause that partition only after 60 min: blocked partition lag 354,000 other partitions max 0
skip the poison record after 60 min: blocked partition lag 0 other partitions max 0
one consumer for all, blocks after 60 min: blocked partition lag 354,000 other partitions max 354,000
shared queue after 60 min: blocked partition lag 0 other partitions max 0What we found
With a consumer per partition, blocking and retrying left the poisoned partition 354K records behind after an hour, while the worst of the other eleven stayed at 0. That is slightly under the expected 360,000 because the poison record arrives one minute in. Pausing only that partition gave the same picture, and skipping the record left no lag at all (0), at the price of the skipped record. With one consumer for all 12 partitions, every partition fell behind together: 354K records on the healthy ones too. The shared queue had no lag on any partition (0), as expected.
Read it
In a log, a poison record blocks whatever reads past it, and how much it blocks depends on how consumers are assigned to partitions. One consumer per partition confines the damage to one partition; one consumer for many spreads it to all of them.
Your consumer group's lag is rising on every partition at once after one bad deploy of a producer: look for one record the consumer cannot get past, then pause that partition or route the record aside. A queue would not have stalled, but it would also not let you reread the hour you just lost.