Skip to content
About
Main content

Break per-key order in a real Kafka topic, then repair it

Run a real systemBreak it on purposeNeeds Docker, Python

Problem

A log such as Kafka keeps records in order within a , and a that keys records sends every record of a key to the same partition. Together that gives per-key order, and it holds only while every clause holds: one thread per partition processes in order; the producer does not reorder or duplicate when it retries; the key maps to the same partition for the whole run. Break any clause and records of one key are processed out of order, with no error anywhere.

Run a single local Kafka (for example with Docker). Create a with 3 partitions. Write a producer that sends 10,000 events {order_id, seq} for 100 orders, with the key order_id. Write a of one consumer that checks that seq is increasing for each order_id. Now break the guarantee one clause at a time and watch the checker fail:

  1. Consume with a thread pool of 8 workers inside the consumer.
  2. Produce with retries, max.in.flight.requests.per.connection=5 and enable.idempotence=false, while you randomly kill the connection (or use a proxy that drops 5% of the responses).
  3. Increase the topic to 6 partitions in the middle of the run.

Then repair (1) by running one single-threaded worker per partition (or hashing the key to a worker thread), repair (2) with enable.idempotence=true, and note that (3) cannot be repaired in place.

The script puts a small proxy in front of the broker for (2), which drops 5% of the produce responses, and uses small fetches so the consumer interleaves partitions as a lagging consumer does.

The question. How many events are processed out of order when each clause is broken, and do the repairs bring it back to zero?


Predict

Growing the topic from 3 to 6 partitions mid-run: how many of 10,000 events are processed out of order?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you. This lab also runs a real system, so you need Docker.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    break-per-key-order-in-a.py143 linesDownload
    View the code
    break-per-key-order-in-a.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["confluent-kafka"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Break per-key order in a log, one clause at a time.
    
    10,000 events {order_id, seq} for 100 orders, keyed by order_id, into a
    3-partition topic; a checker verifies that seq increases per order_id. Then:
    (1) a thread pool of 8 workers inside the consumer; (2) a producer with
    retries, 5 requests in flight and idempotence off, behind a proxy that drops
    5% of produce responses; (3) the topic grown to 6 partitions mid-run. Then
    the repairs for (1) and (2).
    Needs the Kafka service from the compose file (ports 59092 and 59094).
    """
    import argparse, json, random, socket, struct, threading, time, uuid
    from concurrent.futures import ThreadPoolExecutor
    from confluent_kafka import Consumer, Producer
    from confluent_kafka.admin import AdminClient, NewPartitions, NewTopic
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--bootstrap", default="localhost:59092", help="Kafka bootstrap server.")
    p.add_argument("--events", type=int, default=10_000, help="Events produced per experiment.")
    p.add_argument("--orders", type=int, default=100, help="Distinct order ids (keys).")
    p.add_argument("--drop", type=float, default=0.05, help="Share of produce responses the proxy drops in experiment 2.")
    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()
    random.seed(args.seed)
    admin = AdminClient({"bootstrap.servers": args.bootstrap})
    
    
    def topic(parts):
        name = f"orders-{uuid.uuid4().hex[:8]}"
        admin.create_topics([NewTopic(name, parts, 1)])[name].result(); time.sleep(1)
        return name
    
    def produce(name, conf=None, grow_at=None):
        pr = Producer({"bootstrap.servers": args.bootstrap, "linger.ms": 5, "enable.idempotence": True, "batch.num.messages": 20,
                       "topic.metadata.refresh.interval.ms": 1000, **(conf or {})})
        seq = {}
        for i in range(args.events):
            o = random.randrange(args.orders); seq[o] = seq.get(o, 0) + 1
            pr.produce(name, key=str(o), value=json.dumps({"order_id": o, "seq": seq[o]}))
            pr.poll(0)
            if grow_at and i == grow_at:
                pr.flush(); admin.create_partitions([NewPartitions(name, 6)])[name].result(); time.sleep(2)
                pr.list_topics(name, timeout=10)                 # the producer learns the new partition count
        pr.flush(60)
    
    def consume(name, workers=1, by_key=False):
        c = Consumer({"bootstrap.servers": args.bootstrap, "group.id": uuid.uuid4().hex, "auto.offset.reset": "earliest",
                      "topic.metadata.refresh.interval.ms": 1000,
                      "max.partition.fetch.bytes": 2048, "fetch.max.bytes": 8192, "message.max.bytes": 4096})       # small fetches interleave the partitions, as a lagging consumer's do
        c.subscribe([name])
        applied, lock, got = [], threading.Lock(), 0
        def work(v):
            time.sleep(random.random() / 1000)               # a little uneven processing time
            with lock: applied.append(v)
        pools = [ThreadPoolExecutor(1) for _ in range(workers)] if by_key else None
        pool = ThreadPoolExecutor(workers) if workers > 1 and not by_key else None
        idle = 0
        while idle < 10:
            m = c.poll(0.5)
            if m is None or m.error(): idle += 1; continue
            idle = 0; got += 1
            v = json.loads(m.value())
            if by_key: pools[v["order_id"] % workers].submit(work, v)              # one worker per key: order kept
            elif pool: pool.submit(work, v)
            else: work(v)
        for x in (pools or []) + ([pool] if pool else []): x.shutdown(wait=True)
        c.close()
        last, bad = {}, 0
        for v in applied:
            if v["seq"] < last.get(v["order_id"], 0): bad += 1
            last[v["order_id"]] = max(last.get(v["order_id"], 0), v["seq"])
        return bad, got
    
    
    class DroppingProxy:
        """Forwards Kafka traffic; drops a share of Produce responses (api key 0), matched by correlation id."""
        def __init__(self, listen, target, drop):
            self.listen, self.target, self.drop, self.dropped = listen, target, drop, 0
        def start(self):
            s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1); s.bind(("127.0.0.1", self.listen)); s.listen(16)
            self.s = s; threading.Thread(target=self.loop, daemon=True).start()
        def loop(self):
            while True:
                try: c, _ = self.s.accept()
                except OSError: return
                u = socket.create_connection(("127.0.0.1", self.target))
                produce_ids = set()
                threading.Thread(target=self.pipe, args=(c, u, produce_ids, True), daemon=True).start()
                threading.Thread(target=self.pipe, args=(u, c, produce_ids, False), daemon=True).start()
        def pipe(self, a, b, produce_ids, requests):
            f = a.makefile("rb")
            try:
                while True:
                    head = f.read(4)
                    if len(head) < 4: break
                    body = f.read(struct.unpack(">i", head)[0])
                    if requests:
                        api_key, _, corr = struct.unpack(">hhi", body[:8])
                        if api_key == 0: produce_ids.add(corr)
                    else:
                        corr = struct.unpack(">i", body[:4])[0]
                        if corr in produce_ids:
                            produce_ids.discard(corr)
                            if random.random() < self.drop: self.dropped += 1; continue      # the ack is lost
                    b.sendall(head + body)
            except OSError: pass
            finally:
                for x in (a, b):
                    try: x.close()
                    except OSError: pass
    
    
    rows = []
    def record(name, bad, got, note=""):
        rows.append({"experiment": name, "violations": bad, "consumed": got, "note": note})
        print(f"{name:<52} {bad:>5} out-of-order events of {got:,} consumed {note}")
    
    t = topic(3); produce(t); record("baseline: 1 consumer thread, idempotent producer", *consume(t))
    record("(1) consumer with a pool of 8 workers", *consume(t, workers=8))
    record("    repair: one worker per key (hash to a thread)", *consume(t, workers=8, by_key=True))
    
    proxy = DroppingProxy(59093, 59094, args.drop); proxy.start()
    lossy = {"bootstrap.servers": "localhost:59093", "enable.idempotence": False, "max.in.flight.requests.per.connection": 5,
             "retries": 10, "request.timeout.ms": 2000, "socket.timeout.ms": 2000, "message.timeout.ms": 120000,
             "retry.backoff.ms": 50, "linger.ms": 1, "batch.num.messages": 50}
    t = topic(3); produce(t, lossy); bad, got = consume(t)
    record("(2) producer: retries, 5 in flight, no idempotence", bad, got, f"({got - args.events:+,} vs produced; {proxy.dropped} acks dropped)")
    proxy.dropped = 0
    t = topic(3); produce(t, {**lossy, "enable.idempotence": True}); bad, got = consume(t)
    record("    repair: enable.idempotence=true", bad, got, f"({got - args.events:+,} vs produced; {proxy.dropped} acks dropped)")
    
    t = topic(3); produce(t, grow_at=args.events // 2); record("(3) topic grown from 3 to 6 partitions mid-run", *consume(t))
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"experiments": rows}}, f)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    break-per-key-order-in-a.compose.yml24 linesDownload
    View the code
    break-per-key-order-in-a.compose.yml
    # A single Kafka broker (KRaft, no ZooKeeper). Clients connect on localhost:59092;
    # the PROXY listener (advertised as localhost:59093) is for labs that put a proxy in front of the broker.
    services:
      kafka:
        image: apache/kafka:3.9.1
        ports: ["59092:9092", "59094:9094"]
        environment:
          KAFKA_NODE_ID: 1
          KAFKA_PROCESS_ROLES: broker,controller
          KAFKA_LISTENERS: PLAINTEXT://:9092,PROXY://:9094,CONTROLLER://:9093
          KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:59092,PROXY://localhost:59093
          KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
          KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PROXY:PLAINTEXT
          KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
          KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
          KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
          KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
          KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
          KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
        healthcheck:
          test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 > /dev/null 2>&1 || exit 1"]
          interval: 3s
          retries: 40
    
  4. 4Start the services, run the script with the defaults first, then change a knob from the table below and run it again. Remove the services when you are done.

    Terminal
    docker compose -f break-per-key-order-in-a.compose.yml up -d --wait
    uv run break-per-key-order-in-a.py
    uv run break-per-key-order-in-a.py --help     # every knob
    docker compose -f break-per-key-order-in-a.compose.yml down -v
Knobs
Knob · defaultWhat it changes
--bootstraplocalhost:59092 Kafka bootstrap server.
--events10,000 Events produced per experiment.
--orders100 Distinct order ids (keys).
--drop0.05 Share of produce responses the proxy drops in experiment 2.
--seed0 Random seed.

Our run

10,000 events for 100 orders

10,000 events for 100 orders
ExperimentOut-of-order eventsConsumed
baseline: 1 consumer thread, idempotent producer010K—
(1) consumer with a pool of 8 workers56210K—
repair: one worker per key (hash to a thread)010K—
(2) producer: retries, 5 in flight, no idempotence64210.8K(+773 vs produced; 16 acks dropped)
repair: enable.idempotence=true010K(+0 vs produced; 12 acks dropped)
(3) topic grown from 3 to 6 partitions mid-run2,53010K—
Reference runApple M2 · 8 cores · 8 GB
Output · uv run break-per-key-order-in-a.py
baseline: 1 consumer thread, idempotent producer         0 out-of-order events of 10,000 consumed 
(1) consumer with a pool of 8 workers                  562 out-of-order events of 10,000 consumed 
    repair: one worker per key (hash to a thread)        0 out-of-order events of 10,000 consumed 
(2) producer: retries, 5 in flight, no idempotence     642 out-of-order events of 10,773 consumed (+773 vs produced; 16 acks dropped)
    repair: enable.idempotence=true                      0 out-of-order events of 10,000 consumed (+0 vs produced; 12 acks dropped)
(3) topic grown from 3 to 6 partitions mid-run        2529 out-of-order events of 10,000 consumed
macOS 26.6.2 · Python 3.13.3 · confluent-kafka 2.16.0 · apache/kafka:3.9.1 · Docker 28.5.1 · run 2026-10-20

What we found

The baseline had 0 out-of-order events. A pool of 8 workers in the consumer produced 562; hashing each key to one worker brought it back to 0. With lost , five requests in flight and idempotence off, the producer's retries reordered 642 events and also wrote duplicates: 10.8K events were consumed for 10,000 produced. Idempotence fixed both (0 out of order, 10K consumed). Growing the topic to 6 partitions mid-run produced 2,530 out-of-order events, as expected: keys that moved to a new partition were read there before their older records in the old one.


Read it

Choosing a log gives you and per-key order. That caps parallelism at N, the number of partitions, and it makes N a decision you cannot easily change: growing a keyed topic breaks order for every key that moves. Choose N with room to grow (the next lab), keep one thread per partition or per key, and leave idempotence on, which modern clients now do by default.