DeepConcepts

Kafka / broker / group coordination

The Group Coordinator Does Not Assign Your Partitions

The misconception

That the group coordinator is a cluster-wide service which decides who gets which partition. Neither half holds. Which broker coordinates a group is decided by the characters in the group's name — Utils.abs(groupId.hashCode()) % offsets.topic.num.partitions picks a __consumer_offsets partition and the coordinator is that partition's leader — so renaming a group moves it to a different broker and unrelated groups pile up on the same one. And the broker never runs an assignor under the classic protocol: AbstractCoordinator's own javadoc says "the coordinator select the members of the group and chooses one member as the leader. The leader collects the metadata from all the members of the group and assigns state." A skewed assignment, an assignor you cannot roll out in one deploy, and a group that will not settle are therefore client-side faults that present as broker problems.

15 min

There is no cluster-wide service that decides which consumer reads which partition. There is one broker, picked by hashing the characters of your group's name, and under the protocol Kafka still ships as the default that broker does not compute an assignment at all.

The group coordinator is the broker that holds membership and committed offsets for one consumer group. Two questions about it have answers most people have never had to give: which broker is it, and what does it decide. The first answer is a hash of the group id. The second answer, for the classic protocol, is: almost nothing.

Start with the first. A consumer that wants to join a group sends a FindCoordinator request naming the group. Any broker can answer it, and every broker computes the same three lines. The panel below runs those lines. Type a group name you actually use.

Twenty-four realistic group names are already in the cluster; whatever you type is added to them. Nothing here is random — every number below comes from Utils.abs(groupId.hashCode()) % offsets.topic.num.partitions and the leadership of the partition it lands on.

coordinator for your group
__consumer_offsets partition
groups on the busiest broker
groups with no coordinator
Groups coordinated per broker

the broker coordinating the most groups. An even split would give every broker the same bar. Group ids are hashed, not dealt out, so they never do.

__consumer_offsets — which broker leads each partition

One cell per partition of __consumer_offsets, labelled with the broker that leads it. The outlined cell is the partition your group hashes to. means the partition has no leader, so every group hashing to it is frozen.

The hash is exact — it is Java's String.hashCode and it is what your brokers compute. Which broker leads which partition is a deterministic round-robin stand-in, because a real KRaft controller places replicas with a randomised striped placer; your partition-to-broker map will differ. The partition number will not.

At the defaults, orders-service hashes to 23943117, which is partition 17 of 50, which broker 2 leads. That is the entire coordinator election. Now change the group name to orders-service-v2: the hash becomes 324874364 and the partition becomes 14. Change it again to orders-service-v3 and the hash goes up by exactly one, the partition goes to 15, and the coordinator moves to broker 0. One character in a name your deployment tool generated moved your group to a different machine.

Look at the bar chart while you do it. Twenty-four groups across three brokers should be eight each. It is 10, 6 and 8. Drag brokers in cluster to 9 and it gets worse, not better: brokers 2 and 6 coordinate five groups each and broker 7 coordinates none at all. You added six machines and the group traffic did not spread, because nothing in this mechanism is trying to balance anything. It is a modulo.

Where the coordinator is, exactly

Three lines of Kafka decide it. In GroupCoordinatorService:

@Override
public int partitionFor(String groupId) {
    throwIfNotActive();
    return Utils.abs(groupId.hashCode()) % numPartitions;
}

numPartitions is offsets.topic.num.partitions, which defaults to 50. Its own config documentation says it should not change after deployment, and the reason is this line: change the modulus and every group in the cluster rehashes to a different partition, so every group's coordinator moves and every committed offset is looked for in the wrong place.

Utils.abs is not Math.abs: its javadoc says if the number is Int.MinValue return 0. This is different from java.lang.Math.abs, because Math.abs(Integer.MIN_VALUE) returns Integer.MIN_VALUE and would produce a negative partition. Exactly one 32-bit hash in four billion needs that branch, and Kafka has it.

The coordinator is then the leader replica of that partition. This is the sentence with all the consequences in it, because partition leadership is not a stable property of a broker. It moves on a rolling restart, on a preferred-leader election, on a disk failure, and on any reassignment. When it moves, the group's coordinator moves with it, and every client holding a connection to the old one gets NOT_COORDINATOR and has to send FindCoordinator again.

That is the mechanism behind the log line people file bugs about. Marking the coordinator dead is not a broker crash. It is a client discovering that the broker it was talking to is no longer the leader of one partition of an internal topic. The librdkafka issue titled Kafka consumer gets stuck (NOT_COORDINATOR) whilst rejoining a group after a broker rolling update ran for nineteen comments and eight months, and the shape of it is always the same: a rolling restart moves 50 partition leaderships, which moves every group's coordinator, and every consumer in the cluster re-discovers at once.

Take broker 0 offline in the panel with offsets.topic.replication.factor at 3 and read the log. Ten of the twenty-four groups had their coordinator there; all ten move to another replica and nothing breaks. Now drag the replication factor to 1 and take broker 0 offline again. The same ten groups hash to a partition whose only replica is gone, and they get COORDINATOR_NOT_AVAILABLE. They cannot join, they cannot heartbeat, and they cannot commit an offset — with healthy data topics and healthy consumers. This is why the single-broker development default of offsets.topic.replication.factor=1 is the most dangerous setting to carry into a real cluster: it is invisible until the first restart.

One more state exists between those two. When a broker becomes leader of an offsets partition it has to read the whole partition back into memory before it can answer for those groups, and while it does it returns COORDINATOR_LOAD_IN_PROGRESS. On a cluster with large __consumer_offsets segments this is the pause after a restart where the brokers are up, the topics are readable, and no consumer group can commit anything.

The broker does not run your assignor

Now the second question. Here is the class javadoc of AbstractCoordinator, the client-side base class for group membership, describing the protocol in its own words:

Kafka's group management protocol consists of the following sequence
of actions:

1. Group Registration: Group members register with the coordinator
   providing their own metadata (such as the set of topics they are
   interested in).
2. Group/Leader Selection: The coordinator select the members of the
   group and chooses one member as the leader.
3. State Assignment: The leader collects the metadata from all the
   members of the group and assigns state.
4. Group Stabilization: Each member receives the state assigned by the
   leader and begins processing.

Read who does what. The coordinator picks the members and picks a leader. A member — one of your consumer processes — collects the metadata and assigns the partitions. The broker runs no assignor. In ClassicGroup.add() the election is one branch: if (leaderId.isEmpty()) leaderId = Optional.of(member.memberId()). The first member to join is the leader. Not the oldest, not the least loaded, not one you can choose — the first to send JoinGroup after the group emptied.

The round trip is then: every member sends JoinGroup with its subscription; the coordinator replies to the leader with all of the members' subscriptions and to everyone else with an empty list; the leader calls onLeaderElected, whose javadoc is invoked when the leader is elected. This is used by the leader to perform the assignment if necessary and to push state to all the members of the group; and it sends the finished assignment back in SyncGroup. The coordinator stores those bytes and hands each member its own share. It does not parse them.

Three practical consequences follow from that, and none of them are obvious from the outside:

  • A lopsided assignment is a bug in one of your pods, not in the cluster. The RangeAssignor skew described in consumer groups and partition assignment is computed by a JVM you deployed.
  • If the elected leader is the slow pod, the whole group waits for it to return from your code before anyone gets an assignment. Which pod that is is decided by join order.
  • The assignor is negotiated between clients, so you cannot change it in one deploy. That one has a mechanism worth watching.

Why changing the assignor takes two deploys

The coordinator has to pick one assignor name for the group, from lists the members supply. ClassicGroup.selectProtocol() states the rule: Each member will vote for a protocol and the one with the most votes will be selected. Only a protocol that is supported by all members can be selected. A candidate protocol is one whose supporting-member count equals the group size; each member then votes for the first entry of its own partition.assignment.strategy list that is still a candidate.

There is a second rule underneath it, and it is the one that bites. Before a member is allowed into the group at all, ClassicGroup.supportsProtocols() asks whether any protocol the member offers is already supported by every member currently in the group. If none is, the coordinator answers that member's JoinGroup with INCONSISTENT_GROUP_PROTOCOL and leaves the existing group untouched. The pod that cannot join stops consuming. The pods that never left keep consuming everything.

That is an admission test followed by an intersection followed by a vote, and it behaves nothing like a setting. Run a rolling deploy through it.

Pods restart one at a time, oldest first. A restarting pod leaves the group, then asks to rejoin with its new list; the coordinator admits it only if one of the protocols it offers is already common to every remaining member. Rejected pods retry, so each step re-tests all of them. The default partition.assignment.strategy has been [range, cooperative-sticky] since Kafka 3.0, so most groups are already sitting at the start of step 2.

protocol in force now
rebalance protocol
pods in the group
protocol flips at pod
fewest pods consuming, whole rollout
Each member at the current step

Each row is one pod: its partition.assignment.strategy list, with the entry it votes for marked vote. An entry no other member offers is struck through — it is not a candidate and cannot be voted for, however high it sits in the list. A row marked locked out is a pod the coordinator refused with INCONSISTENT_GROUP_PROTOCOL; it is running, retrying, and consuming nothing.

The admission test, the candidate rule and the vote counting are Kafka's, read from ClassicGroup and GroupMetadataManager on the 4.3 branch. Restart order is a simplification: real pods come back in whatever order the orchestrator gives them.

Leave it on step 2 and drag pods rolled from 0 to 6. The group runs range at zero pods. At one pod — the first restart, five of six pods still on the old config — the protocol is already cooperative-sticky. It does not wait for a majority, because it is not a majority vote over preferences; it is a vote over the intersection, and the moment one member stops offering range the intersection has one element in it. All six pods stay in the group throughout, because all six still offer cooperative-sticky. That is the whole point of having done the first bounce: it put cooperative-sticky on every member's list so that the second bounce can flip the group on its first restart without locking anybody out.

Now switch to one step, which is what everybody tries first: take a group running plain [range] and set the new pods to [cooperative-sticky]. Watch pods in the group rather than the protocol. It goes 6, 5, 4, 3, 2, 1, and then back to 6. Every pod you restart is refused at JoinGroup and sits in a retry loop, while the pods you have not touched yet keep all the partitions. At five of six rolled, one pod is consuming the entire topic and the other five are idle. Consumption does not stop, which is exactly why this is hard to spot: lag climbs, the group reports Stable, and --describe shows a shrinking membership that looks like pods failing to start. The group only accepts the new configuration when the last old pod leaves and the group goes Empty — at that moment the admission test has no members to intersect against and lets the first newcomer set the protocol.

Move members in group anywhere from 2 to 12 and the floor is always one pod. The size of the group does not soften it; a twelve-pod deploy spends its eleventh step running the whole topic through a single consumer.

The CooperativeStickyAssignor javadoc gives the rule without the mechanism: To turn on cooperative rebalancing you must set all your consumers to use this PartitionAssignor, and if upgrading from 2.3 or earlier, you must follow a specific upgrade path. The mechanism is the admission test. And it is why Kafka 3.0 changed the client default to [range, cooperative-sticky] rather than to [cooperative-sticky]: shipping the two-element list to everybody put every group one bounce away from cooperative instead of two, and made the dangerous one-step path something you have to opt into.

Select step 1 and drag the slider across. Nothing happens at all: six pods in the group at every step, range throughout, and the protocol never flips. That is the correct outcome and the reason the first bounce is easy to skip — it is a deploy with no observable effect whose only job is to make the next one safe.

The add a custom assignor plan shows the same rule from the other side. New pods list a custom assignor first and keep range behind it, so they are admitted at every step. The group keeps running range until the last old pod is gone, then flips to the custom assignor at pod 6. A preference at the front of the list is worth nothing until the last member also has it. team-sticky is invented for this panel and is shown as cooperative because that is what a custom assignor returning RebalanceProtocol.COOPERATIVE from supportedProtocols() would do; the name is not a real Kafka assignor.

Checking it in a running system

Four checks, in the order that narrows fastest.

Which broker. kafka-consumer-groups.sh --bootstrap-server … --describe --group g --state prints a COORDINATOR (ID) column. Compare it against the hash: if you know your group id you can compute the partition yourself and check that the leadership of __consumer_offsets-<n> agrees. When several groups misbehave at once, hash all of them; if they share a partition or a broker, you have a coordinator problem rather than a consumer problem.

Whether the offsets topic can survive a restart. kafka-topics.sh --describe --topic __consumer_offsets. Any partition whose Isr list is shorter than its Replicas list is a group outage waiting for the next restart. At replication factor 1 each broker is the only replica for its share of the 50 partitions, so losing one broker out of n puts roughly one group in n into COORDINATOR_NOT_AVAILABLE — on a three-broker cluster, about a third of them at once. You cannot raise this with an alter on the config; the topic is already created, so it takes a partition reassignment.

Which client is the leader. Add --members --verbose to the --describe call and you get every member id, host and assignment. The group leader is not marked, but it is the member whose logs contain Finished assignment for group at generation after the last rebalance. ConsumerCoordinator.onLeaderElected logs that line at INFO, so it is there without changing any log level, and it appears in exactly one pod. Grep your fleet for it. Do not search for Performing assignment using strategy, which sits three lines above it in the same method: that one is log.debug and you will not see it at a default configuration. If the pod the INFO line names is also the one with the highest processing latency, the group's assignment latency is that pod's latency.

What protocol the group actually settled on. The --state output has an ASSIGNMENT-STRATEGY column, and it is the coordinator's answer, not your configuration file's. If it says range after you rolled out cooperative-sticky, one member is still offering an older list and the intersection has been decided by that member. If the group is stuck in PreparingRebalance and the client logs show INCONSISTENT_GROUP_PROTOCOL, the intersection is empty and you are mid-way through a one-step assignor change.

Whether a deploy is quietly shrinking the group. This is the one the panel above was built for, and it does not look like an error. Every time a classic group settles, the coordinator broker logs Stabilized group <id> generation <n> with <m> members at INFO. Watch <m> across a rolling deploy. If it counts down — 6, 5, 4 — while your pods are all reporting healthy, the restarted pods are being refused at JoinGroup and the group is running on the ones you have not touched yet. Grep those pods for INCONSISTENT_GROUP_PROTOCOL; the client logs the rejection and retries silently forever, so nothing crashes and nothing restarts. A member count that recovers to full only after the last pod restarts confirms it.

Two unrelated services both report their consumer groups hanging for thirty seconds at a time, always together, while a third service on the same cluster is fine. What is the first thing to compute?

What none of this fixes is the division of labour itself: an elected client computing the assignment, a global barrier every member has to reach, and a broker that can only wait. That is what the KIP-848 consumer group protocol rearranges — KIP-848 is Kafka Improvement Proposal 848 — by moving the assignor onto the coordinator and reconciling members one at a time. It reached general availability in Kafka 4.0, where GroupVersion.GV_1 (Version 1 enables the consumer rebalance protocol) became the latest production feature level and group.coordinator.rebalance.protocols gained consumer in its default. So the broker is ready and the consumer is not: group.protocol still defaults to classic on the 4.3 client, and you have to set it to consumer yourself. When you do, partition.assignment.strategy stops being a valid consumer config at all — ConsumerConfig lists it under CONSUMER_PROTOCOL_UNSUPPORTED_CONFIGS — because the vote this lesson just walked through no longer exists. Before that, the two clocks the coordinator runs are worth understanding on their own — what a rebalance actually costs — and if your groups churn on every deploy, static membership changes what the coordinator does when a member disappears.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.