What a rebalance repeats
Simulate a modelNeeds Python
Problem
Which member of a consumer group reads which partition 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 offset 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 timeout its partitions back with no rebalance at all.
Simulate the group from the worked example: 12 consumers read a 24-partition topic, 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
With eager rebalancing all 24 partitions stop, and each may repeat up to 5 s × 400 = 2,000 records: up to 48,000 repeats for one deploy. With cooperative rebalancing only the 2 partitions that move stop, up to 4,000 repeats. With static membership and a restart inside the session timeout, nothing moves and nothing repeats.
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-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)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
| Knob · default | What 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
| Protocol | Commit on revoke | Partitions paused | Mean repeats | p99 repeats | Upper bound |
|---|---|---|---|---|---|
| eager | no | 24 | 24K | 33.1K | 48K |
| cooperative | no | 2 | 2,000 | 3,960 | 4,000 |
| static membership | no | 0 | 0 | 0 | 0 |
| eager | yes | 24 | 0 | 0 | 0 |
| cooperative | yes | 2 | 0 | 0 | 0 |
| static membership | yes | 0 | 0 | 0 | 0 |
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 0What 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 commit interval. 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.