Skip to content
About
Main content

Head-of-line blocking: log versus queue

Simulate a modelNeeds Python

Problem

A log keeps order within a , so a cannot move past a record it cannot process: everything behind that record waits. A shared 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 , 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

One consumer serves all 12 partitions and blocks on the poison record. How many partitions fall behind?

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.

    head-of-line-blocking-log-versus.py60 linesDownload
    View 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)
    
  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 head-of-line-blocking-log-versus.py
    uv run head-of-line-blocking-log-versus.py --help     # every knob
Knobs
Knob · defaultWhat 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

Lag after a poison record, by policyApple M2 · 8 cores · 8 GB
Partition with the poison recordWorst other partition
Policy
Data table
Lag after a poison record, by policy
MinutePartition with the poison recordWorst other partition
100
26K0
312K0
418K0
524K0
630K0
736K0
842K0
948K0
1054K0
1160K0
1266K0
1372K0
1478K0
1584K0
1690K0
1796K0
18102K0
19108K0
20114K0
21120K0
22126K0
23132K0
24138K0
25144K0
26150K0
27156K0
28162K0
29168K0
30174K0
31180K0
32186K0
33192K0
34198K0
35204K0
36210K0
37216K0
38222K0
39228K0
40234K0
41240K0
42246K0
43252K0
44258K0
45264K0
46270K0
47276K0
48282K0
49288K0
50294K0
51300K0
52306K0
53312K0
54318K0
55324K0
56330K0
57336K0
58342K0
59348K0
60354K0
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20
Output · uv run head-of-line-blocking-log-versus.py
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         0

What 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 's lag is rising on every partition at once after one bad deploy of a : 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.