Skip to content
About
Main content

What a rebalance repeats

Simulate a modelNeeds Python

Problem

Which member of a reads which is decided by the group itself, and decided again whenever a member joins, leaves or is declared dead. That redistribution is a rebalance. A partition moves with the its old owner last committed, so everything the old owner processed after that commit is processed again by the new owner: a rebalance is a source of duplicates, like a crash. How much of the group pays depends on the protocol. Eager rebalancing revokes every partition; cooperative revokes only the partitions that must move; static membership gives a member that restarts within the session its partitions back with no rebalance at all.

Simulate the group from the worked example: 12 read a 24-partition , two partitions each, at 400 records per second per partition, committing every 5 seconds. Redeploy one consumer 10,000 times, each at a random moment in every member's commit cycle, and count the records processed twice under each protocol. Then commit offsets when partitions are revoked and run it again.

The question. How many records does one deploy repeat under eager, cooperative and static membership, and what does committing on revoke change?


Predict

Without a commit on revoke, how many records does one deploy repeat on average under eager rebalancing?

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.

    what-a-rebalance-repeats.py60 linesDownload
    View the code
    what-a-rebalance-repeats.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """What a rebalance repeats: eager, cooperative and static membership.
    
    A consumer group reads a partitioned topic and commits each partition's offset on
    a fixed interval. One consumer is redeployed. A partition that changes owner restarts
    from its last committed offset, so everything processed since that commit is processed
    again. Eager rebalancing revokes every partition; cooperative revokes only the ones
    that move; static membership with a restart inside the session timeout moves nothing.
    Optionally, offsets are committed when partitions are revoked. Each deploy happens at a
    random moment in the commit cycle; the script repeats it many times.
    """
    import argparse, json, random
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--consumers", type=int, default=12, help="Consumers in the group.")
    p.add_argument("--partitions", type=int, default=24, help="Partitions of the topic.")
    p.add_argument("--rate", type=float, default=400, help="Records per second per partition.")
    p.add_argument("--commit", type=float, default=5.0, help="Seconds between offset commits.")
    p.add_argument("--deploys", type=int, default=10000, help="Simulated deploys (each at a random moment).")
    p.add_argument("--seed", type=int, default=1, help="Random seed.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    per_consumer = args.partitions // args.consumers
    
    
    def deploy(rnd, protocol, commit_on_revoke):
        # each consumer commits its partitions together, at its own phase of the commit cycle
        since_commit = [rnd.uniform(0, args.commit) for _ in range(args.consumers)]
        if protocol == "static membership":
            return 0, 0
        moved = range(args.consumers) if protocol == "eager" else [rnd.randrange(args.consumers)]
        paused = per_consumer * len(moved)
        if commit_on_revoke:
            # the redeployed consumer is being stopped cleanly too, so it commits before leaving
            return 0, paused
        return sum(since_commit[c] * args.rate * per_consumer for c in moved), paused
    
    
    rows = []
    for revoke in (False, True):
        for protocol in ("eager", "cooperative", "static membership"):
            rnd = random.Random(args.seed)
            res = [deploy(rnd, protocol, revoke) for _ in range(args.deploys)]
            reps = sorted(r[0] for r in res)
            rows.append({"protocol": protocol, "commit_on_revoke": "yes" if revoke else "no",
                         "partitions_paused": res[0][1], "mean_repeats": sum(reps) / len(reps),
                         "p99_repeats": reps[int(len(reps) * 0.99)], "max_possible": (args.partitions if protocol == "eager" else per_consumer if protocol == "cooperative" else 0) * args.rate * args.commit * (0 if revoke else 1)})
    for r in rows:
        print(f"{r['protocol']:<18} commit on revoke {r['commit_on_revoke']:<3}  partitions paused {r['partitions_paused']:>2}  "
              f"repeats per deploy: mean {r['mean_repeats']:8,.0f}  p99 {r['p99_repeats']:8,.0f}  bound {r['max_possible']:8,.0f}")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"protocols": 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 what-a-rebalance-repeats.py
    uv run what-a-rebalance-repeats.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--consumers12 Consumers in the group.
--partitions24 Partitions of the topic.
--rate400 Records per second per partition.
--commit5 Seconds between offset commits.
--deploys10,000 Simulated deploys (each at a random moment).
--seed1 Random seed.

Our run

Records processed twice per deploy

Records processed twice per deploy
ProtocolCommit on revokePartitions pausedMean repeatsp99 repeatsUpper bound
eagerno2424K33.1K48K
cooperativeno22,0003,9604,000
static membershipno0000
eageryes24000
cooperativeyes2000
static membershipyes0000
Reference runApple M2 · 8 cores · 8 GB
Output · uv run what-a-rebalance-repeats.py
eager              commit on revoke no   partitions paused 24  repeats per deploy: mean   24,001  p99   33,143  bound   48,000
cooperative        commit on revoke no   partitions paused  2  repeats per deploy: mean    2,002  p99    3,963  bound    4,000
static membership  commit on revoke no   partitions paused  0  repeats per deploy: mean        0  p99        0  bound        0
eager              commit on revoke yes  partitions paused 24  repeats per deploy: mean        0  p99        0  bound        0
cooperative        commit on revoke yes  partitions paused  2  repeats per deploy: mean        0  p99        0  bound        0
static membership  commit on revoke yes  partitions paused  0  repeats per deploy: mean        0  p99        0  bound        0
macOS 26.6.2 · Python 3.13.3 · run 2026-10-25

What we found

Without a commit on revoke, one deploy under eager rebalancing repeated 24K records on average (p99 33.1K, against the bound of 48K), and paused all 24 partitions. Cooperative rebalancing repeated 2K on average and paused 2, a twelfth of eager's cost; static membership repeated 0. The worked example's numbers are the upper bounds: the average is half of them, because a deploy lands on average halfway through a . Committing on revoke brought every protocol to 0 repeats for a clean deploy.


Read it

The protocol decides how many partitions pay; the commit interval decides how much each one pays. Committing on revoke removes the repeats of a planned move, but not of a crash, where no member gets the chance to commit: a crash still repeats up to one commit interval of every partition the dead member owned. So keep the commit interval short enough that what a crash repeats is something the sink can absorb.