namalzi

← All posts

Digging into Kafka's Source 11: The Consumer and the Group Coordinator — Inside poll() and Rebalancing

Kafka · Published

KafkaConsumer is a thin shell that hands every call to either ClassicKafkaConsumer or AsyncKafkaConsumer, depending on group.protocol. With the default, classic, the thread that calls poll() runs the rebalance itself through JoinGroup and SyncGroup, and a leader consumer computes the assignment. With the consumer protocol (KIP-848), a background thread talks to the broker with a single ConsumerGroupHeartbeat request and the broker decides the assignment. We follow both, then the broker-side group coordinator and its CoordinatorRuntime, which writes group state to __consumer_offsets as records and rebuilds it by reading them back — checked with an example that adds two consumers one after the other, and with kafka-dump-log.sh.

What this post covers

This is the eleventh post in the "Digging into Kafka's Source" series. In post 8 we saw how the broker hands out records for a Fetch request, and in post 10 we saw how the controller keeps the cluster's ledger (the metadata log). Now we go to the receiving side: the consumer. Pulling bytes in with Fetch was already covered in post 8, so this post focuses on the problem that only consumers have — "how do several consumers share partitions, and when one joins or leaves, who splits them up again?"

Where this post zooms in on the post 2 map. In region A, KafkaConsumer hands its work to ClassicKafkaConsumer or AsyncKafkaConsumer depending on group.protocol. Those send JoinGroup, SyncGroup and Heartbeat, or ConsumerGroupHeartbeat, plus OffsetCommit requests to regions B and C (SocketServer → KafkaApis, post 4); reading records with Fetch (post 8) is a separate path. KafkaApis passes them to GroupCoordinatorService in region F, under which CoordinatorRuntime runs a state machine per __consumer_offsets partition, and GroupMetadataManager and OffsetMetadataManager handle groups, members, assignments and offsets. CoordinatorRuntime writes the state as records to __consumer_offsets, which goes to regions D and E (ReplicaManager → UnifiedLog, posts 6–9). This post zooms in on the consumer inside region A (clients) and the group coordinator inside region F (coordinators) of the post 2 map. Grey boxes belong to earlier posts.

It tries to answer four questions:

Item Details
Project Apache Kafka
Version 4.5.0-SNAPSHOT (trunk)
Commit ab1e816411 (2026-10-24)
Main classes Client (clients): KafkaConsumer, internals.ConsumerDelegateCreator·ClassicKafkaConsumer·ConsumerCoordinator·AbstractCoordinator·AsyncKafkaConsumer·ConsumerNetworkThread·ConsumerHeartbeatRequestManager·AbstractMembershipManager / Broker: kafka.server.KafkaApis (Scala), and in group-coordinator GroupCoordinatorService·GroupMetadataManager·OffsetMetadataManager·modern.consumer.CurrentAssignmentBuilder, and in coordinator-common CoordinatorRuntime·CoordinatorLoaderImpl

All line numbers in this post refer to this commit. How to get the same source and build the example Docker image is in post 1. The classes read here are nearly identical to the latest stable release most people use, 4.4.0. Comparing with the 4.4.0 tag, two differences stand out. Trunk has a new rebalance-listener API (RebalanceListener, KafkaConsumer.setRebalanceListener()) that 4.4.0 does not have. And 4.4.0's SubscriptionState class has been split in trunk into ConsumerSubscriptionState and a shared interface, AbstractSubscriptionState (which the share consumer's ShareSubscriptionState also implements). The rest of the quoted code is the same as 4.4.0 or differs by a few lines.

On the consumer side alone, AsyncKafkaConsumer.java is 2,557 lines and ConsumerCoordinator.java 1,763 lines, and the broker's GroupMetadataManager.java is 10,188 lines. Those files also hold code for Kafka Streams groups and share groups. This post picks only the main path a newcomer can follow; what was left out is listed by name and location in "What this post leaves out" at the end.

Terms to know first

Consumer group and group coordinator: consumers that use the same group.id form a consumer group, and each partition of a topic goes to only one consumer in the group. Deciding and remembering who gets which partition is the job of one of the brokers; that role (more precisely, a component inside the broker) is called the group coordinator. Each group has its own broker in charge, and that broker is the leader of the __consumer_offsets partition chosen from the group id (section 5).

Rebalance: redistributing partitions when the group's members change (join, leave, stop responding) or the subscribed topics' partition count changes. In your app you see it through the onPartitionsRevoked (give up) and onPartitionsAssigned (receive) callbacks of a ConsumerRebalanceListener.

group.protocol: a consumer setting that picks how rebalances are carried out. classic is the long-standing way: it uses JoinGroup, SyncGroup and Heartbeat requests, and one of the consumers (the leader) computes the assignment. consumer is the new way introduced by KIP-848: it uses only the ConsumerGroupHeartbeat request, and the broker computes the assignment (the javadoc of GroupProtocol.java calls the two values "pre KIP-848" and "KIP-848"). At this commit the default is still classic (section 1).

Generation and epoch: numbers that go up each time a rebalance finishes. In classic, the whole group has one generation. In the consumer protocol there are separate numbers: the group epoch (goes up when the group's makeup changes), the target assignment epoch, and the member epoch (how far this member has caught up). Requests carrying an old number are rejected — this stops a "zombie" consumer from committing offsets late for partitions it has already given up.

Assignor: a class that takes a list of members and a list of partitions and computes "who gets what". In classic it is the client's partition.assignment.strategy (RangeAssignor, CooperativeStickyAssignor …); in the consumer protocol it is the broker's group.consumer.assignors (uniform, range).

Eager and cooperative: two ways of giving up partitions during a rebalance. Eager means every member gives up all its partitions when a rebalance starts and then receives a new assignment. Cooperative means a member gives up only the partitions that actually move to another member; the rest can still be read during the rebalance.

__consumer_offsets: the internal topic the group coordinator uses. Despite the name, it holds not only committed offsets but also group state such as members and assignments — all as records (section 6). It has 50 partitions by default (offsets.topic.num.partitions, lines 120–121 of GroupCoordinatorConfig.java).

A few terms from earlier posts come back. The high watermark (HW) is the offset boundary up to which records are replicated to every ISR replica and count as "committed" (post 9). The request handler threads (data-plane-kafka-request-handler-N) and the match in KafkaApis.handle() were covered in post 4.

1. KafkaConsumer is a shell — two implementations split by group.protocol

What gets created when your app calls new KafkaConsumer<>(props)? Open KafkaConsumer and it has essentially one field.

    private static final ConsumerDelegateCreator CREATOR = new ConsumerDelegateCreator();

    private final ConsumerDelegate<K, V> delegate;
    // ...
    KafkaConsumer(ConsumerConfig config, Deserializer<K> keyDeserializer, Deserializer<V> valueDeserializer) {
        delegate = CREATOR.create(config, keyDeserializer, valueDeserializer);
    }

clients/src/main/java/org/apache/kafka/clients/consumer/KafkaConsumer.java lines 554–629

Public methods like poll(), subscribe() and commitSync() all forward in one line, like delegate.poll(timeout) (for example, poll() is lines 986–989). Most of the 2,027-line file is javadoc. The real implementation is chosen in ConsumerDelegateCreator.

    public <K, V> ConsumerDelegate<K, V> create(ConsumerConfig config,
                                                Deserializer<K> keyDeserializer,
                                                Deserializer<V> valueDeserializer) {
        try {
            GroupProtocol groupProtocol = GroupProtocol.valueOf(config.getString(ConsumerConfig.GROUP_PROTOCOL_CONFIG).toUpperCase(Locale.ROOT));

            if (groupProtocol == GroupProtocol.CONSUMER)
                return new AsyncKafkaConsumer<>(config, keyDeserializer, valueDeserializer, Optional.empty());
            else
                return new ClassicKafkaConsumer<>(config, keyDeserializer, valueDeserializer);
        } catch (KafkaException e) {
            throw e;
        } catch (Throwable t) {
            throw new KafkaException("Failed to construct Kafka consumer", t);
        }
    }

clients/src/main/java/org/apache/kafka/clients/consumer/internals/ConsumerDelegateCreator.java lines 57–72

group.protocol=consumer gives you AsyncKafkaConsumer; anything else gives ClassicKafkaConsumer. The default is in ConsumerConfig.

    public static final String GROUP_PROTOCOL_CONFIG = "group.protocol";
    public static final String DEFAULT_GROUP_PROTOCOL = GroupProtocol.CLASSIC.name().toLowerCase(Locale.ROOT);
    public static final String GROUP_PROTOCOL_DOC = "The group protocol that the consumer uses. The " +
        "supported values are <code>classic</code> or <code>consumer</code>. The default value is <code>classic</code>.";

clients/src/main/java/org/apache/kafka/clients/consumer/ConsumerConfig.java lines 120–123

define(GROUP_PROTOCOL_CONFIG, Type.STRING, DEFAULT_GROUP_PROTOCOL, …) (lines 684–689) uses this constant as the default. So an app that doesn't set it still uses ClassicKafkaConsumer today. 4.4.0 is the same.

The two implementations of KafkaConsumer. KafkaConsumer forwards every method to delegate, and ConsumerDelegateCreator reads group.protocol to pick the implementation. In classic (the default), the app thread calls ConsumerCoordinator.poll(), the Fetcher and ConsumerNetworkClient.poll() all inside poll(), while kafka-coordinator-heartbeat-thread only sends Heartbeat; the two share one network client under a lock. In consumer, the app thread puts events into the applicationEventQueue, waits on the FetchBuffer and runs rebalance callbacks, while consumer_background_thread runs the RequestManagers, does all network I/O, and notifies the app thread through the backgroundEventQueue. The two implementations already differ in how they split work between threads. Blue boxes are threads; yellow boxes are the queues between them.

Easy to get wrong: some settings are forbidden depending on the protocol

The two implementations also accept slightly different settings. With group.protocol=consumer, ConsumerConfig throws if you set partition.assignment.strategy, heartbeat.interval.ms or session.timeout.ms (the two lists at lines 423–438 and checkUnsupportedConfigsPostProcess() at lines 819–842). In the new protocol these three moved to broker-side settings (group.consumer.assignors, group.consumer.heartbeat.interval.ms, group.consumer.session.timeout.ms). The other way round, with classic you can't set group.remote.assignor (the setting that picks a broker-side assignor by name). If your existing app's config contains session.timeout.ms, changing only group.protocol won't even let the consumer be created.

2. The classic consumer's poll() — one thread does almost everything

Let's start with the default, ClassicKafkaConsumer. poll(Duration) creates a timer and runs this loop.

            do {
                client.maybeTriggerWakeup();

                // try to update assignment metadata BUT do not need to block on the timer for join group
                updateAssignmentMetadataIfNeeded(timer, false);

                final Fetch<K, V> fetch = pollForFetches(timer);
                if (!fetch.isEmpty()) {
                    // ...
                    if (sendFetches() > 0 || client.hasPendingRequests()) {
                        client.transmitSends();
                    }
                    // ...
                    return this.interceptors.onConsume(new ConsumerRecords<>(fetch.records(), fetch.nextOffsets()));
                }
            } while (timer.notExpired());

clients/src/main/java/org/apache/kafka/clients/consumer/internals/ClassicKafkaConsumer.java lines 673–698

One pass of the loop has two steps.

  1. updateAssignmentMetadataIfNeeded() — the group work. It calls coordinator.poll() (ConsumerCoordinator) and then decides the starting position of any partition that doesn't have one yet (the committed offset or auto.offset.reset) (lines 712–718).
  2. pollForFetches() — getting records. If some are already buffered it returns them right away; otherwise it sends Fetch requests (sendFetches()) and waits for responses in the network client's poll() (lines 723–756). The inside of Fetch is post 8's job.

Also worth noting: right before returning records it sends the next Fetch ahead of time (the comment at lines 681–689), so the next batch is on its way while your app processes the current one.

The heart of the group work, ConsumerCoordinator.poll(), looks like this.

    public boolean poll(Timer timer, boolean waitForJoinGroup) {
        maybeUpdateSubscriptionMetadata();

        invokeCompletedOffsetCommitCallbacks();

        if (subscriptions.hasAutoAssignedPartitions()) {
            // ...
            pollHeartbeat(timer.currentTimeMs());
            if (coordinatorUnknownAndUnreadySync(timer)) {
                return false;
            }

            if (rejoinNeededOrPending()) {
                // ...
                // if not wait for join group, we would just use a timer of 0
                if (!ensureActiveGroup(waitForJoinGroup ? timer : time.timer(0L))) {
                    // ...
                    return false;
                }
            }
        } else {
            // ...
        }

        maybeAutoCommitOffsetsAsync(timer.currentTimeMs());
        return true;
    }

clients/src/main/java/org/apache/kafka/clients/consumer/internals/ConsumerCoordinator.java lines 513–579

If you subscribed with subscribe() (hasAutoAssignedPartitions()), it does the following in order.

  1. pollHeartbeat() — records the time "the app just called poll()". The heartbeat thread below measures max.poll.interval.ms from this time.
  2. coordinatorUnknownAndUnreadySync() — if it doesn't know which broker is the group coordinator, it finds out with a FindCoordinator request (AbstractCoordinator.lookupCoordinator(), line 331).
  3. If rejoinNeededOrPending(), it (re)joins the group with ensureActiveGroup(). The rebalance happens right here, inside the poll() your app called. That is also why rebalance callbacks run on the thread that called poll().
  4. maybeAutoCommitOffsetsAsync() — if enable.auto.commit=true (the default, ConsumerConfig.java lines 489–493), it commits offsets asynchronously every auto.commit.interval.ms (default 5000, lines 494–499). Auto-commit has no thread of its own; each poll() just checks whether it's time.

ensureActiveGroup() (AbstractCoordinator.java lines 415–424) checks the coordinator, starts the heartbeat thread with startHeartbeatThreadIfNeeded(), and joins the group with joinGroupIfNeeded(). The heartbeat thread is created once, the first time the consumer joins. Its name is "kafka-coordinator-heartbeat-thread" + " | " + group.id (the constant at line 124 and line 1467). This thread only sends Heartbeat requests; the rebalance itself (JoinGroup and SyncGroup) is done by the app thread in joinGroupIfNeeded().

The two clocks the heartbeat thread watches

The key lines of the heartbeat thread's run() loop are these.

                        } else if (heartbeat.sessionTimeoutExpired(now)) {
                            // the session timeout has expired without seeing a successful heartbeat, so we should
                            // probably make sure the coordinator is still healthy.
                            markCoordinatorUnknown("session timed out without receiving a "
                                    + "heartbeat response");
                        } else if (heartbeat.pollTimeoutExpired(now)) {
                            // the poll timeout has expired, which means that the foreground thread has stalled
                            // in between calls to poll().
                            handlePollTimeoutExpiry();

clients/src/main/java/org/apache/kafka/clients/consumer/internals/AbstractCoordinator.java lines 1523–1531

There are two clocks.

The heartbeat interval is heartbeat.interval.ms (default 3000). I checked all three defaults in the define(…) calls at lines 467–476 and 658–663 of ConsumerConfig.java. And max.poll.interval.ms also serves as the rebalance timeout when joining the group (GroupRebalanceConfig.java line 55). That value comes back in the next section.

The two threads of the classic consumer. In one round of the application thread's poll(), 1) updateAssignmentMetadataIfNeeded() calls ConsumerCoordinator.poll(), in which pollHeartbeat() notes the time poll() was just called, lookupCoordinator() sends FindCoordinator if the coordinator is unknown, ensureActiveGroup() runs JoinGroup, SyncGroup and the rebalance callbacks if rejoinNeededOrPending() (section 3), and maybeAutoCommitOffsetsAsync() commits asynchronously if auto.commit.interval.ms (5000) has passed. 2) pollForFetches() calls sendFetches() and waits for the response if there are no records on hand (Part 8). The kafka-coordinator-heartbeat-thread | group.id thread, created once on first join, sends only Heartbeat requests every heartbeat.interval.ms (3000) and watches two clocks. If the session clock, session.timeout.ms (45000), passes with no successful heartbeat response, it calls markCoordinatorUnknown(); the poll clock, max.poll.interval.ms (300000), counts from the time pollHeartbeat() noted, and when it runs out handlePollTimeoutExpiry() makes the consumer leave the group itself Rebalances and auto commit happen inside the poll() the application calls; the two clocks live in the heartbeat thread. The poll clock is measured from the time the application thread noted. Numbers in parentheses are defaults.

3. The classic rebalance — JoinGroup, SyncGroup and the leader consumer

A classic rebalance is two round trips.

  1. JoinGroup: each member says "I'm joining this group, I subscribe to these topics, and I can use these assignors" (sendJoinGroupRequest(), AbstractCoordinator.java lines 608–638). The coordinator holds the responses until all members have come in, then picks one as the leader and gives only the leader everyone's subscriptions.
  2. SyncGroup: the leader computes the assignment inside the client from those subscriptions and sends it in SyncGroup; the other members send an empty SyncGroup (onJoinFollower(), lines 755–770). The coordinator splits the leader's assignment per member and returns it in the SyncGroup responses.

When JoinGroupResponseHandler gets the JoinGroup response, it branches on joinResponse.isLeader(): the leader goes to onLeaderElected(), everyone else to onJoinFollower() (lines 678–682). On the leader, ConsumerCoordinator.onLeaderElected() unpacks the members' subscriptions and computes the assignment with assignor.assign(metadata.fetch(), new GroupSubscription(subscriptions)) (line 693). The broker never interprets the assignment; it just passes the bytes along. That is why, in classic, changing the assignment strategy or using a new assignor meant redeploying every consumer app.

The default is eager

The default partition.assignment.strategy is two assignors, [RangeAssignor, CooperativeStickyAssignor] (ConsumerConfig.java lines 477–482). But the rebalance protocol is chosen as the most advanced one that every assignor supports.

        if (!assignors.isEmpty()) {
            List<RebalanceProtocol> supportedProtocols = new ArrayList<>(assignors.get(0).supportedProtocols());

            for (ConsumerPartitionAssignor assignor : assignors) {
                supportedProtocols.retainAll(assignor.supportedProtocols());
            }

            // ...
            Collections.sort(supportedProtocols);

            protocol = supportedProtocols.get(supportedProtocols.size() - 1);

clients/src/main/java/org/apache/kafka/clients/consumer/internals/ConsumerCoordinator.java lines 256–271

RangeAssignor supports only EAGER, so after the intersection (retainAll) the default rebalance protocol is EAGER. Having CooperativeStickyAssignor in the list leaves a path open to drop Range later and move to cooperative; it does not mean you get cooperative behaviour today. Here is what EAGER does right before a rebalance (onJoinPrepare()).

            switch (protocol) {
                case EAGER:
                    // revoke all partitions
                    revokedPartitions.addAll(subscriptions.assignedPartitions());
                    exception = rebalanceListenerInvoker.invokePartitionsRevoked(revokedPartitions);

                    subscriptions.assignFromSubscribed(Collections.emptySet());

                    break;

                case COOPERATIVE:
                    // only revoke those partitions that are not in the subscription anymore.
                    Set<TopicPartition> ownedPartitions = new HashSet<>(subscriptions.assignedPartitions());
                    revokedPartitions.addAll(ownedPartitions.stream()
                        .filter(tp -> !subscriptions.subscription().contains(tp.topic()))
                        .collect(Collectors.toSet()));

                    if (!revokedPartitions.isEmpty()) {
                        exception = rebalanceListenerInvoker.invokePartitionsRevoked(revokedPartitions);

                        ownedPartitions.removeAll(revokedPartitions);
                        subscriptions.assignFromSubscribed(ownedPartitions);
                    }

                    break;
            }

clients/src/main/java/org/apache/kafka/clients/consumer/internals/ConsumerCoordinator.java lines 828–853

EAGER gives up all its partitions (all of them go to onPartitionsRevoked) and sends JoinGroup with an empty assignment. Until it gets a new assignment, this consumer reads nothing.

The order of a classic rebalance. 1 Consumer B sends JoinGroup (subscription: orders). 2 The coordinator goes to PreparingRebalance and parks B's JoinGroup. 3 A's heartbeat thread sends Heartbeat and 4 gets REBALANCE_IN_PROGRESS. 5 On its next poll(), A calls onPartitionsRevoked [0..5] and 6 sends JoinGroup (subscription) while owning nothing. 7 The coordinator tells A: generation 2, you are the leader, here are everyone's subscriptions; 8 it tells B: generation 2, you are a follower. 9 A computes the assignment with RangeAssignor and 10 sends SyncGroup (A=[0,1,2], B=[3,4,5]). 11 B sends SyncGroup with an empty assignment. 12 A gets [0,1,2] and is Stable; 13 B gets [3,4,5]. B joins while consumer A holds all six partitions alone. This is the same order as the run of example 1 below. In reality the new member B's first JoinGroup comes back with a MEMBER_ID_REQUIRED error carrying only a member id, and B immediately resends with that id (GroupMetadataManager.classicGroupJoinNewDynamicMember(), lines 7416–7437); the diagram folds that round trip into step 1.

Steps 3–4 matter. The coordinator has no way to reach out to A first and say "let's rebalance". It can only put REBALANCE_IN_PROGRESS in A's next heartbeat response (HeartbeatResponseHandler, requestRejoin() at lines 1257–1268), and A sends JoinGroup on the poll() after that. So it takes up to about heartbeat.interval.ms (3 s) after B joins before A gives up its partitions.

Group states on the coordinator side

On the broker, a classic group moves between five states. The javadoc of ClassicGroupState describes, almost like a table, how each state answers each request, and the allowed previous states are in a static block.

    static {
        EMPTY.addValidPreviousStates(PREPARING_REBALANCE);
        PREPARING_REBALANCE.addValidPreviousStates(STABLE, COMPLETING_REBALANCE, EMPTY);
        COMPLETING_REBALANCE.addValidPreviousStates(PREPARING_REBALANCE);
        STABLE.addValidPreviousStates(COMPLETING_REBALANCE);
        DEAD.addValidPreviousStates(STABLE, PREPARING_REBALANCE, COMPLETING_REBALANCE, EMPTY, DEAD);
    }

group-coordinator/src/main/java/org/apache/kafka/coordinator/group/classic/ClassicGroupState.java lines 109–115

State transitions of a classic group. From Empty, a JoinGroup from a new member leads to PreparingRebalance. From PreparingRebalance, when everyone has joined or the rebalance timeout passes it goes to CompletingRebalance, and when all members leave it goes back to Empty. From CompletingRebalance, when the leader's SyncGroup brings the assignment it goes to Stable, and a new member, a member leaving or an expired session sends it back to PreparingRebalance. From Stable, a new member, a member leaving, an expired session or a subscription change leads to PreparingRebalance. From Empty, when all offsets have expired too, it goes to Dead. The two yellow states are "rebalancing". Besides the one arrow shown, a group can go to Dead from any state when its __consumer_offsets partition moves to another broker.

How long PreparingRebalance waits is decided by GroupMetadataManager.prepareRebalance() (lines 7952–7985). When the first member joins an empty group, the coordinator waits group.initial.rebalance.delay.ms (default 3000, GroupCoordinatorConfig.java line 180) for more members, so that several apps starting at nearly the same time don't trigger rebalance after rebalance. For a group that already has members, it waits until all existing members have sent JoinGroup again, at most the group's rebalance timeout (the largest max.poll.interval.ms among the members, ClassicGroup.rebalanceTimeoutMs() lines 767–773) (maybeCompleteJoinElseSchedule(), lines 7994–8011). The examples in this post start the server with group.initial.rebalance.delay.ms=0, set by the helper script.

Example 1: adding two consumers one after the other (classic)

This example was run with Kafka 4.5.0-SNAPSHOT built as-is from the reference commit (ab1e816411), in a Docker container (Eclipse Temurin JDK 25). We put 60 records into a six-partition topic orders, then start consumer A in one JVM. Once A has all six, we start B; once the two have three each, we wait 20 seconds and close B. Each consumer runs poll(100ms) on its own thread (app-A, app-B) and prints when, and on which thread, its rebalance callbacks run.

// This example was written for this post (it is not Kafka source)
// ...
                consumer.subscribe(List.of(topic), new ConsumerRebalanceListener() {
                    public void onPartitionsRevoked(Collection<TopicPartition> p) {
                        log(name, "onPartitionsRevoked  " + parts(p) + "  thread=" + Thread.currentThread().getName());
                    }
                    // ...
                });
                while (running.get()) {
                    consumed.addAndGet(consumer.poll(Duration.ofMillis(100)).count());
                }

The whole program is a single Rebalance.java that takes group.protocol and the group id as arguments. Here is the run with group.protocol=classic (times and member ids differ on every run).

group.protocol=classic  group.id=g-classic  -> __consumer_offsets-18
  1013 ms [main] start A
  3089 ms [A] onPartitionsAssigned [0, 1, 2, 3, 4, 5]  thread=app-A  now owns [0, 1, 2, 3, 4, 5]
  3118 ms [main] A is stable. client threads:
         thread: app-A
         thread: kafka-coordinator-heartbeat-thread | g-classic
  3121 ms [main] start B
  6116 ms [A] onPartitionsRevoked  [0, 1, 2, 3, 4, 5]  thread=app-A
  6147 ms [A] onPartitionsAssigned [0, 1, 2]  thread=app-A  now owns [0, 1, 2]
  6148 ms [B] onPartitionsAssigned [3, 4, 5]  thread=app-B  now owns [3, 4, 5]
  6167 ms [main] A and B are stable. client threads:
         thread: app-A
         thread: app-B
         thread: kafka-coordinator-heartbeat-thread | g-classic
 24451 ms [main] stop B
 24501 ms [B] close() (consumed 0 records)
 24513 ms [B] onPartitionsRevoked  [3, 4, 5]  thread=app-B
 24706 ms [B] closed
 27154 ms [A] onPartitionsRevoked  [0, 1, 2]  thread=app-A
 27161 ms [A] onPartitionsAssigned [0, 1, 2, 3, 4, 5]  thread=app-A  now owns [0, 1, 2, 3, 4, 5]
 27195 ms [main] stop A
 27288 ms [A] close() (consumed 60 records)
 27289 ms [A] onPartitionsRevoked  [0, 1, 2, 3, 4, 5]  thread=app-A
 27399 ms [A] closed
 27401 ms [main] records consumed: A=60 B=0

Three things to read here.

The time until A first got all six partitions varied from 2 to 8 seconds across the four runs I made (about 2 seconds in the output above). This group is the first on the broker to use __consumer_offsets, so the first FindCoordinator makes the broker create that internal topic (50 partitions), and until the topic is ready the client gets COORDINATOR_NOT_AVAILABLE (KafkaApis.scala lines 1292–1295). I believe that wait explains the variation, but I did not break the time down to confirm it.

4. The consumer protocol — a background thread and a single heartbeat

Now AsyncKafkaConsumer, which you get with group.protocol=consumer. As the "Async" in the name suggests, the app thread never touches the network directly. The constructor starts one ConsumerNetworkThread, named consumer_background_thread (ConsumerNetworkThread.java line 68). The poll() loop looks like classic's, but does different work.

        if (inflightPoll == null) {
            inflightPoll = new AsyncPollEvent(calculateDeadlineMs(timer), time.milliseconds());
            newlySubmittedEvent = true;
            log.trace("Inflight event {} submitted", inflightPoll);
            applicationEventHandler.add(inflightPoll);
        }

        try {
            // Note: this is calling user-supplied code, so make sure that any errors thrown here are caught and
            // the inflight event is cleared.
            offsetCommitCallbackInvoker.executeCallbacks();
            processBackgroundEvents();

clients/src/main/java/org/apache/kafka/clients/consumer/internals/AsyncKafkaConsumer.java lines 1006–1017

The app thread puts an AsyncPollEvent into the event queue (applicationEventQueue) to tell the background thread "poll was called", and handles events sent back by the background thread (backgroundEventQueue) in processBackgroundEvents(). Then, in pollForFetches(), it waits on the FetchBuffer that the background thread fills (fetchBuffer.awaitWakeup(pollTimer), line 2026). Turning the received bytes into records is done on the app thread by FetchCollector.

One pass of the background thread looks like this.

        long pollWaitTimeMs = MAX_POLL_TIMEOUT_MS;

        for (RequestManager rm : requestManagers.entries()) {
            NetworkClientDelegate.PollResult pollResult = rm.poll(currentTimeMs);
            long timeoutMs = networkClientDelegate.addAll(pollResult);
            pollWaitTimeMs = Math.min(pollWaitTimeMs, timeoutMs);
        }

        networkClientDelegate.poll(pollWaitTimeMs, currentTimeMs);

clients/src/main/java/org/apache/kafka/clients/consumer/internals/ConsumerNetworkThread.java lines 220–228

runOnce() (lines 210–242) first processes the events the app thread queued (processApplicationEvents()), then asks each request manager in turn "do you have a request to send now?" (rm.poll()), and hands the collected requests to the network client, which sends them and receives responses. The list of request managers is in the RequestManagers constructor — coordinator lookup (CoordinatorRequestManager), offset commits (CommitRequestManager), heartbeats (ConsumerHeartbeatRequestManager), membership (ConsumerMembershipManager), offset lookups, topic metadata and Fetch (FetchRequestManager), in that order (RequestManagers.java lines 94–105). Each kind of request has its own class, and a single thread calls them in rotation. Much like the broker's Processor in post 4 looked after many connections with one selector, here one thread looks after many kinds of requests.

The heartbeat is the rebalance

In the new protocol, practically the only request the consumer exchanges with the group is ConsumerGroupHeartbeat (API key 68). It is used to join, to receive assignments, to report that partitions were given up, and to leave. What goes into the heartbeat tells you how the protocol works.

            // MemberEpoch - always sent
            data.setMemberEpoch(membershipManager.memberEpoch());

            // ...
            boolean sendAllFields = membershipManager.state() == MemberState.JOINING;

            // RebalanceTimeoutMs - only sent when joining or if it has changed since the last heartbeat
            if (sendAllFields || sentFields.rebalanceTimeoutMs != rebalanceTimeoutMs) {
                data.setRebalanceTimeoutMs(rebalanceTimeoutMs);
                sentFields.rebalanceTimeoutMs = rebalanceTimeoutMs;
            }

            // SubscribedTopicNames - only sent if it has changed since the last heartbeat
            TreeSet<String> subscribedTopicNames = new TreeSet<>(this.subscriptions.subscription());
            if (sendAllFields || !subscribedTopicNames.equals(sentFields.subscribedTopicNames)) {
                data.setSubscribedTopicNames(new ArrayList<>(this.subscriptions.subscription()));
                sentFields.subscribedTopicNames = subscribedTopicNames;
            }

clients/src/main/java/org/apache/kafka/clients/consumer/internals/ConsumerHeartbeatRequestManager.java lines 266–285

The group id, member id and member epoch go out every time; the rest — subscribed topics, rebalance timeout, the partitions it currently owns (TopicPartitions, lines 304–314) — are sent only when they change (sentFields remembers the last values). When joining (JOINING), everything is sent. Unlike classic, the member id is generated by the client up front (the comment at line 263, and Uuid.randomUuid() at line 82 of AbstractMembershipManager.java). The heartbeat interval also comes not from a client setting but from the value the broker puts in the response (group.consumer.heartbeat.interval.ms, default 5000).

When a response brings a new assignment, AbstractMembershipManager.maybeReconcile() compares it with what the consumer owns.

        // Partitions to assign (not previously owned)
        SortedSet<TopicPartition> addedPartitions = new TreeSet<>(TOPIC_PARTITION_COMPARATOR);
        addedPartitions.addAll(assignedTopicPartitions);
        addedPartitions.removeAll(ownedPartitions);

        // Partitions to revoke
        SortedSet<TopicPartition> revokedPartitions = new TreeSet<>(TOPIC_PARTITION_COMPARATOR);
        revokedPartitions.addAll(ownedPartitions);
        revokedPartitions.removeAll(assignedTopicPartitions);

clients/src/main/java/org/apache/kafka/clients/consumer/internals/AbstractMembershipManager.java lines 916–924

"To add = new assignment − owned" and "to revoke = owned − new assignment". It then goes commit (if auto-commit is on) → onPartitionsRevoked → onPartitionsAssigned (lines 956–970, revokeAndAssign() lines 994–1036), and when done switches to ACKNOWLEDGING so that the next heartbeat goes out right away, without waiting for the interval, to say "done" (line 1031). Only the partitions being given up reach the callback, so this protocol is cooperative from the start.

The background thread doesn't call the callbacks itself.

    private CompletableFuture<Void> enqueueConsumerRebalanceListenerCallback(ConsumerRebalanceListenerMethodName methodName,
                                                                             Set<TopicPartition> partitions) {
        SortedSet<TopicPartition> sortedPartitions = new TreeSet<>(TOPIC_PARTITION_COMPARATOR);
        sortedPartitions.addAll(partitions);

        CompletableBackgroundEvent<Void> event = new PartitionsRemovedEvent(methodName, sortedPartitions);
        backgroundEventHandler.add(event);
        log.debug("The event to trigger the {} method execution was enqueued successfully", methodName.fullyQualifiedMethodName());
        return event.future();
    }

clients/src/main/java/org/apache/kafka/clients/consumer/internals/ConsumerMembershipManager.java lines 415–424

The background thread puts a "please run this callback" event into the app-side queue; the app thread runs the callback in processBackgroundEvents() during its next poll() and completes the future. The background thread's rebalance handling waits until that future completes. You need to be able to call consumer methods such as commitSync() inside a callback, and KafkaConsumer is meant to be used from a single thread, so the callbacks run on the app thread.

With the consumer protocol, the application thread's poll() puts an AsyncPollEvent in applicationEventQueue to tell consumer_background_thread. The background thread's runOnce() handles events with processApplicationEvents(), asks the RequestManagers for coordinator, commit, heartbeat, membership, offset lookup, metadata and Fetch in turn with rm.poll(), and sends and receives the gathered requests with networkClientDelegate.poll(). It sends ConsumerGroupHeartbeat to the group coordinator, with the member epoch every time and the rest only when changed. When the response brings a new assignment, maybeReconcile() computes to add = new assignment − owned and to revoke = owned − new assignment, and sends a PartitionsRemovedEvent (onPartitionsRevoked and so on) to the application thread through backgroundEventQueue. In processBackgroundEvents() of the next poll(), the application thread runs the rebalance callback (so it can call commitSync() and the like) and completes the event's future. The background thread changes its state to ACKNOWLEDGING and sends a heartbeat right away, without waiting for the interval, to say it is done The background thread does all the networking, but the rebalance callback, being user code, is handed to the application thread as an event and run there.

max.poll.interval.ms is now measured by the background thread

The poll clock that classic's heartbeat thread kept is measured here by the heartbeat request manager (AbstractHeartbeatRequestManager.poll(), lines 171–186). If poll() isn't called for too long, it sends a heartbeat that leaves the group. The "Consumer poll timeout has expired" warning you often see in app logs comes from here (line 173). The client-side member states (MemberState — JOINING, RECONCILING, ACKNOWLEDGING, STABLE, FENCED, PREPARE_LEAVING, LEAVING, STALE and others, 10 in total) are defined in MemberState.java, and the allowed transitions are in the static block at lines 118–142. When the poll clock runs out, the member goes through LEAVING to STALE, and on the next poll() it rejoins through JOINING.

Example 1, continued: the same thing with the consumer protocol

Here is the same Rebalance.java run with group.protocol=consumer.

group.protocol=consumer  group.id=g-consumer  -> __consumer_offsets-32
  1097 ms [main] start A
  2741 ms [A] onPartitionsAssigned [0, 1, 2, 3, 4, 5]  thread=app-A  now owns [0, 1, 2, 3, 4, 5]
  2781 ms [main] A is stable. client threads:
         thread: app-A
         thread: consumer_background_thread
  2784 ms [main] start B
  3037 ms [B] onPartitionsAssigned []  thread=app-B  now owns []
  7957 ms [A] onPartitionsRevoked  [3, 4, 5]  thread=app-A
  8040 ms [A] onPartitionsAssigned []  thread=app-A  now owns [0, 1, 2]
 13102 ms [B] onPartitionsAssigned [3, 4, 5]  thread=app-B  now owns [3, 4, 5]
 13147 ms [main] A and B are stable. client threads:
         thread: app-A
         thread: app-B
         thread: consumer_background_thread
 31443 ms [main] stop B
 31526 ms [B] close() (consumed 0 records)
 31536 ms [B] onPartitionsRevoked  [3, 4, 5]  thread=app-B
 31908 ms [B] closed
 33084 ms [A] onPartitionsAssigned [3, 4, 5]  thread=app-A  now owns [0, 1, 2, 3, 4, 5]
 33093 ms [main] stop A
 33161 ms [A] close() (consumed 60 records)
 33167 ms [A] onPartitionsRevoked  [0, 1, 2, 3, 4, 5]  thread=app-A
 33598 ms [A] closed
 33600 ms [main] records consumed: A=60 B=0

Put next to classic, the differences are clear.

Easy to get wrong: the new listener API and the old one

The example uses subscribe(topics, ConsumerRebalanceListener), which app developers know well. At this commit (trunk), the KafkaConsumer javadoc calls this method "deprecated" and suggests calling subscribe(topics) and setRebalanceListener(RebalanceListener) separately (lines 713–715 and 885–915). Callbacks of the new RebalanceListener get a RebalanceConsumer (a consumer view that exposes only methods safe to use during a rebalance) as a second argument. However, the method carries no @Deprecated annotation, so there is no compiler warning, and 4.4.0 has no RebalanceListener at all. If your code must run on 4.4.0, keep using ConsumerRebalanceListener as in the example.

5. On the broker: where the group coordinator lives and how it decides assignments

Now we cross over to the side that receives these requests. The group coordinator is not a separate server; it is a component inside every broker, and only the broker in charge differs from group to group. A client first sends FindCoordinator to any broker to ask which one is in charge.

      val (partition, internalTopicName) = CoordinatorType.forId(keyType) match {
        case CoordinatorType.GROUP =>
          (groupCoordinator.partitionFor(key), GROUP_METADATA_TOPIC_NAME)

        case CoordinatorType.TRANSACTION =>
          (txnCoordinator.partitionFor(key), TRANSACTION_STATE_TOPIC_NAME)

        case CoordinatorType.SHARE =>
          // We know that shareCoordinator is defined at this stage.
          (shareCoordinator.partitionFor(SharePartitionKey.getInstance(key)), SHARE_GROUP_STATE_TOPIC_NAME)
      }

core/src/main/scala/kafka/server/KafkaApis.scala lines 1277–1287

Reading Scala — x match { case A => … case B => … } is like a Java switch expression, and the last expression of the chosen branch becomes the value. Here each branch returns a tuple (partition number, topic name) (two values bundled together), and val (partition, internalTopicName) = … unpacks that tuple into two variables.

For a group, it gets the __consumer_offsets partition number from groupCoordinator.partitionFor(group id) and returns that partition's leader broker as the coordinator (lines 1300–1307). The calculation is simple.

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

group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorService.java lines 455–460

It's the group id's hashCode() modulo the partition count (50 by default). In the example, g-classic landed on partition 18 and g-consumer on partition 32 (the example computed and printed them with the same formula). This spreads groups evenly over the 50 partitions, and so over the brokers that lead them. And because a group's state is written to that partition, when the leader changes the coordinator naturally moves to the new leader. That comes for free from the replication of post 9.

How one request reaches the coordinator

Let's follow ConsumerGroupHeartbeat. KafkaApis.handleConsumerGroupHeartbeat() (lines 2680–2718) checks authorization, calls groupCoordinator.consumerGroupHeartbeat(…), and sends the response when the returned CompletableFuture completes. GroupCoordinatorService schedules it as a write operation.

        return runtime.scheduleWriteOperation(
            "consumer-group-heartbeat",
            topicPartitionFor(request.groupId()),
            coordinator -> coordinator.consumerGroupHeartbeat(context, request)
        ).exceptionally(exception -> handleOperationException(

group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorService.java lines 535–539

A "heartbeat" being a write may look odd at first. It's because a heartbeat can change group state (members, assignments, epochs), and changed state has to be written to __consumer_offsets. What to change is decided by GroupMetadataManager.consumerGroupHeartbeat(). An epoch of -1 (or -2 for a static member) branches off as a request to leave the group (lines 5729–5737); everything else goes through three steps, which the comments in the method number for us. Step 1 creates or updates the member and, if its subscription changed, bumps the group epoch (lines 2635–2703). Steps 2 and 3 look like this.

        // 2. Update the target assignment if the group epoch is larger than the target assignment epoch. The delta between
        // the existing and the new target assignment is persisted to the partition.
        UpdateTargetAssignmentResult<Assignment> updateTargetAssignmentResult = maybeUpdateTargetAssignment(
            group,
            groupEpoch,
            member,
            updatedMember,
            subscriptionType,
            records
        );

        // 3. Reconcile the member's assignment with the target assignment if the member is not
        // fully reconciled yet.
        updatedMember = maybeReconcile(
            groupId,
            updatedMember,
            group::currentPartitionEpoch,
            updateTargetAssignmentResult.targetAssignmentEpoch(),
            updateTargetAssignmentResult.targetAssignment(),
            group.resolvedRegularExpressions(),
            // Force consistency with the subscription when the subscription has changed.
            hasSubscriptionChanged,
            ownedTopicPartitions,
            records
        );

group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupMetadataManager.java lines 2705–2729

  1. Target assignment: if the group epoch is larger than the target assignment epoch, the broker's assignor (uniform by default, GroupCoordinatorConfig.java lines 221–233) computes a new target assignment for the whole group (maybeUpdateTargetAssignment(), lines 4346–4429). The job of computing assignments has moved from the client leader to the broker. But if group.consumer.assignment.interval.ms (default 1000) hasn't passed since the previous computation, it skips the computation this time and leaves it to the next heartbeat that comes in (from any member) (lines 4359–4366, canComputeNextTargetAssignment() lines 4312–4333). This keeps the broker from recomputing assignments over and over when members join in a burst.
  2. Reconcile: the target is the picture for the whole group; what this member can hold right now is computed separately, by CurrentAssignmentBuilder inside maybeReconcile().

All records go into the records list and are returned together with the response as a CoordinatorResult at the end (line 2750). The assignment is put into the response only when the member just joined, the request carried all fields, or this member's assignment changed (lines 2739–2748).

Between target and current: CurrentAssignmentBuilder

Even if the target becomes A=[0,1,2], B=[3,4,5], giving 3, 4 and 5 to B right away would let A and B read the same partitions at the same time, because A hasn't yet reported letting go of them. CurrentAssignmentBuilder.computeNextAssignment() closes that gap.

            // New Assigned Partitions = Previous Assigned Partitions ∩ Target
            Map<Integer, Integer> assignedPartitions = new HashMap<>(currentAssignedPartitions);
            assignedPartitions.keySet().retainAll(target);

            // Partitions Pending Revocation = Previous Assigned Partitions - New Assigned Partitions
            Map<Integer, Integer> partitionsPendingRevocation = new HashMap<>(currentAssignedPartitions);
            partitionsPendingRevocation.keySet().removeAll(assignedPartitions.keySet());

            // Partitions Pending Assignment = Target - New Assigned Partitions - Unreleased Partitions
            Set<Integer> partitionsPendingAssignment = new HashSet<>(target);
            partitionsPendingAssignment.removeAll(assignedPartitions.keySet());
            hasUnreleasedPartitions = partitionsPendingAssignment.removeIf(partitionId ->
                currentPartitionEpoch.apply(topicId, partitionId) != -1 &&
                // Don't consider a partition unreleased if it is owned by the current member
                // because it is pending revocation. This is safe to do since only a single member
                // can own a partition at a time.
                !member.partitionsPendingRevocation().getOrDefault(topicId, Map.of()).containsKey(partitionId)
            ) || hasUnreleasedPartitions;

group-coordinator/src/main/java/org/apache/kafka/coordinator/group/modern/consumer/CurrentAssignmentBuilder.java lines 393–410

The comments are set formulas.

If there is something to give up, the member can't move to the new epoch and goes to UNREVOKED_PARTITIONS (lines 425–435). If something it should receive is still held by another member, it moves to the new epoch but waits in UNRELEASED_PARTITIONS (lines 436–465). If neither, it is STABLE. These three states (plus UNKNOWN, reserved for the future) are the broker-side member states defined in modern/MemberState.java — a different enum with the same name as the client-side MemberState of section 4, so be careful not to mix them up.

Broker-side member state transitions. A member that just joined starts in STABLE. From STABLE, if it still holds partitions dropped from its new target assignment, it goes to UNREVOKED_PARTITIONS. UNREVOKED_PARTITIONS stays put while the heartbeat's owned list still has those partitions; once it gives them all up it moves to the new epoch and STABLE, or, if partitions it should get are still held by another member, to UNRELEASED_PARTITIONS. From STABLE, if partitions it should get are held by another member, it goes to UNRELEASED_PARTITIONS, and once the other member lets go and it gets them all, it returns to STABLE. The switch in CurrentAssignmentBuilder.build() (lines 187–260) as a picture. Rare arrows, such as the target changing again while in one of the two waiting (yellow) states, are left out.

Thanks to this, a partition is owned by only one member at any moment, while partitions that don't move keep being read. It gets the safety that classic's EAGER got by "everyone puts everything down and picks it up again", but per partition.

The order of a consumer-protocol rebalance. 1 Consumer B sends ConsumerGroupHeartbeat (epoch 0, subscribes to orders). 2 The coordinator bumps the group epoch to 3 but leaves the target assignment unchanged because of the 1-second interval. 3 B gets member epoch 2 and an empty assignment. 4 When A sends its regular heartbeat (every 5 s), 5 the coordinator recomputes the target assignment with the uniform assignor, A=[0,1,2], B=[3,4,5], and 6 gives A the assignment [0,1,2] (meaning give up 3,4,5, UNREVOKED). 7 A calls onPartitionsRevoked [3,4,5] on its app thread. 8 B's regular heartbeat gets 9 the answer: epoch 3, but nothing to take yet (A still owns 3,4,5, UNRELEASED). 10 A sends a heartbeat with its owned partitions [0,1,2] and 11 becomes epoch 3, Stable. 12 B sends its regular heartbeat 5 seconds later and 13 gets [3,4,5], becomes Stable, and calls onPartitionsAssigned on its app thread. The consumer-protocol run of example 1 (the output above) drawn request by request, matched with the records of example 3. The order of steps 8–11 can change from run to run. The one onPartitionsAssigned [] call on B after step 3 is left out of the picture.

Example 2: the two groups through kafka-consumer-groups.sh

While example 1 was running, once A and B had settled, we called kafka-consumer-groups.sh --describe --state and --describe --members --verbose in the same container.

=== 1. group.protocol=classic
--- kafka-consumer-groups.sh --describe --state (while A and B are stable)

GROUP           COORDINATOR (ID)          ASSIGNMENT-STRATEGY  STATE                #MEMBERS
g-classic       localhost:9092  (1)       range                Stable               2
--- kafka-consumer-groups.sh --describe --members

GROUP           CONSUMER-ID                                      HOST            CLIENT-ID       #PARTITIONS     CURRENT-EPOCH   CURRENT-ASSIGNMENT   TARGET-EPOCH    TARGET-ASSIGNMENT   
g-classic       g-classic-A-386cac32-ece8-4c7b-a804-fec4152c1e6e /127.0.0.1      g-classic-A     3               -               orders:0,1,2         -               -                   
g-classic       g-classic-B-e2fd2ef9-937d-4eb2-8c87-370a1089cb72 /127.0.0.1      g-classic-B     3               -               orders:3,4,5         -               -
=== 2. group.protocol=consumer
--- kafka-consumer-groups.sh --describe --state (while A and B are stable)

GROUP           COORDINATOR (ID)          ASSIGNMENT-STRATEGY  STATE                #MEMBERS
g-consumer      localhost:9092  (1)       uniform              Stable               2
--- kafka-consumer-groups.sh --describe --members

GROUP           CONSUMER-ID            HOST            CLIENT-ID       #PARTITIONS     CURRENT-EPOCH   CURRENT-ASSIGNMENT   TARGET-EPOCH    TARGET-ASSIGNMENT   
g-consumer      dHHCFy_6RmWBvpyk1U7leQ /127.0.0.1      g-consumer-A    3               3               orders:0,1,2         3               orders:0,1,2        
g-consumer      90SoJ7ajR6eTABfmOMF6Fw /127.0.0.1      g-consumer-B    3               3               orders:3,4,5         3               orders:3,4,5

6. CoordinatorRuntime — write state to the log, rebuild it from the log

In section 5, GroupMetadataManager only returned a list of records and a response; it never wrote to disk. Writing is done by CoordinatorRuntime in the coordinator-common module, a shared runtime used by the group coordinator and the share-group coordinator (the transaction coordinator of post 12 is still Scala code that doesn't use it).

The runtime keeps one state machine per __consumer_offsets partition. When the broker becomes the leader of a partition, it creates that partition's state machine, and every request for the groups in it is handled there. Work goes into an event queue and is run by group-coordinator-event-processor-N threads. Their number is group.coordinator.threads (default 4, GroupCoordinatorConfig.java line 103), and only one event per partition runs at a time (the javadoc of EventAccumulator, lines 35–45). That is why there are almost no locks inside GroupMetadataManager — a group's state is only ever touched by one thread at a time.

Write operations run in CoordinatorWriteEvent.run().

        public void run() {
            try {
                // Get the context of the coordinator or fail if the coordinator is not in active state.
                withActiveContextOrThrow(tp, context -> {
                    // Execute the operation.
                    result = op.generateRecordsAndResult(context.coordinator.coordinator());

                    // Append the records and replay them to the state machine.
                    context.append(
                        producerId,
                        producerEpoch,
                        verificationGuard,
                        result.records(),
                        result.replayRecords(),
                        result.isAtomic(),
                        this
                    );

                    // ...
                });
            } catch (Throwable t) {
                complete(t);
            }
        }

coordinator-common/src/main/java/org/apache/kafka/coordinator/common/runtime/CoordinatorRuntime.java lines 1317–1347

op.generateRecordsAndResult(…) is where a method like section 5's consumerGroupHeartbeat() gets called, and context.append(…) handles the result. The order inside append() is the core of this design.

                    try {
                        if (replay) {
                            coordinator.replay(
                                currentBatch.nextOffset,
                                producerId,
                                producerEpoch,
                                recordToReplay
                            );
                        }

                        currentBatch.builder.append(recordToAppend);
                        currentBatch.nextOffset++;

coordinator-common/src/main/java/org/apache/kafka/coordinator/common/runtime/CoordinatorRuntime.java lines 1008–1019

  1. The record is first applied to the in-memory state (replay()). For a consumer-protocol group, GroupMetadataManager doesn't modify state directly when it handles a request; it only produces records that say "this is how it should change", and the state changes in replay(). (Classic groups are the exception: member additions and removals and state transitions such as PreparingRebalance are applied directly to the ClassicGroup object — e.g. group.transitionTo(PREPARING_REBALANCE) in prepareRebalance(), line 7979 — and the result is recorded as one GroupMetadata record when the generation changes.)
  2. The same record is appended to a batch (MemoryRecordsBuilder, the record batch of post 6).
  3. When the batch is flushed (flushCurrentBatch()), partitionWriter.append(…) writes it to __consumer_offsets-N (lines 712–717). This takes the same road as a producer's records — through ReplicaManager to UnifiedLog.
  4. The response isn't sent yet. The event goes into deferredEventQueue with the batch's offset (line 738), and completes once the partition's high watermark passes that offset (onHighWatermarkUpdated() → deferredEventQueue.completeUpTo(newHighWatermark), lines 1810–1831). The client hears "done" only when the records are replicated to the whole ISR and can't be lost even if the leader changes.

What if the in-memory state was changed first and then the write fails? The in-memory state is built from revertible data structures such as TimelineHashMap (the structures on top of SnapshotRegistry seen in post 10). Each time a batch is written to the log, a snapshot is taken at that offset (SnapshottableCoordinator.updateLastWrittenOffset(), lines 139–148), and if the next batch fails, failCurrentBatch() rolls back to that snapshot with revertLastWrittenOffset(batch base offset) (CoordinatorRuntime.java lines 786–792, SnapshottableCoordinator.java lines 85–96). Snapshots older than the high watermark are no longer needed and are deleted (deleteSnapshotsUpTo() in updateLastCommittedOffset(), lines 158–172). The controller does the same with the same SnapshotRegistry for the metadata log (metadata/…/controller/OffsetControlManager.java lines 271 and 328).

Offset commits take exactly the same road. GroupCoordinatorService.commitOffsets() calls scheduleWriteOperation() under the name "commit-offset" (lines 2374–2384), and OffsetMetadataManager.commitOffset() returns one GroupCoordinatorRecordHelpers.newOffsetCommitRecord(group, topic, partition, offset) record per committed partition (lines 665–670). When commitSync() returns, it means these records were written to __consumer_offsets and passed the high watermark.

The write path of CoordinatorRuntime. When a request-handler thread calls GroupCoordinatorService from KafkaApis, the work goes into the event queue (EventAccumulator, one event per partition at a time), and a group-coordinator-event-processor-N thread runs it in CoordinatorWriteEvent.run(). GroupMetadataManager and friends build a record list and a response; the records are first applied to the in-memory state (Timeline structures) with replay() and written to __consumer_offsets-N with partitionWriter.append(). The response waits in deferredEventQueue and goes out via completeUpTo() once the high watermark passes that offset. On restart, or when becoming leader, CoordinatorLoaderImpl reads the log from the start, rebuilds the in-memory state with replay(), and moves from LOADING to ACTIVE. Yellow boxes are where state lives. The dotted path is the recovery path when the broker restarts or becomes the partition's leader.

Rebuilding from the log

Because the log holds every change to the state, reading the log from the start and calling replay() gives the same state again (a classic group comes back from its last GroupMetadata record with that generation's members, as Empty or Stable — a rebalance in progress is not in the log, line 7094). When a broker becomes the leader of a __consumer_offsets partition (BrokerMetadataPublisher calls groupCoordinator.onElection, line 158), the runtime puts that partition's state machine into LOADING and lets CoordinatorLoaderImpl read it.

            long currentOffset = log.logStartOffset();
            LoadStats stats = new LoadStats();

            long lastCommittedOffset = -1L;
            while (shouldFetchNextBatch(currentOffset, logEndOffset(tp), stats.readAtLeastOneRecord)) {
                FetchDataInfo fetchDataInfo = log.read(currentOffset, loadBufferSize, FetchIsolation.LOG_END, true);

                stats.readAtLeastOneRecord = fetchDataInfo.records.sizeInBytes() > 0;

                // ...
                MemoryRecords memoryRecords = toReadableMemoryRecords(tp, fetchDataInfo.records, buffer);
                // ...

                ReplayResult replayResult = processMemoryRecords(tp, log, memoryRecords, coordinator, stats, currentOffset, lastCommittedOffset);
                currentOffset = replayResult.nextOffset;
                lastCommittedOffset = replayResult.lastCommittedOffset;
            }

coordinator-common/src/main/java/org/apache/kafka/coordinator/common/runtime/CoordinatorLoaderImpl.java lines 138–156

It reads from the log start offset to the end with UnifiedLog.read() (the same method we saw in post 8) and calls coordinator.replay(…) for every record (inside processMemoryRecords(), lines 281–286). When it's done, the state machine becomes ACTIVE and logs "Finished loading of metadata from …" (CoordinatorRuntime.java lines 594–600). Until then, requests for groups in that partition get a COORDINATOR_LOAD_IN_PROGRESS error (CoordinatorRuntime.java lines 2096–2097), and clients retry a little later (the classic side's handling is at AbstractCoordinator.java lines 686–689).

The log doesn't grow forever because __consumer_offsets is a compacted topic (deleting older values of the same key and keeping only the last, post 7) — the topic config is at line 2610 of GroupCoordinatorService.java. Record keys are things like "group + member" or "group + topic + partition", so after compaction only the latest state per key remains. When a member leaves, a record with no value (a tombstone) is written for that member's keys so compaction removes them.

Example 3: the records inside __consumer_offsets

After example 1 finished, we decoded the partitions the two groups wrote to (18 and 32) with kafka-dump-log.sh --offsets-decoder. The tool prints keys and values as JSON, but the lines are long, so we replaced the key's type number with the record name and kept only a few fields, one line per record (OffsetCommit records are only counted; the fmt.awk used for this is in the example folder). The member ids, and the record numbers and OffsetCommit count (which depend on where auto-commits land), differ from run to run.

=== 4. __consumer_offsets records
--- g-classic -> __consumer_offsets-18
  0  GroupMetadata                                       generation=0 protocol=null leader=null members=0
  1  GroupMetadata                                       generation=1 protocol=range leader=g-classic-A-3.. members=1
  8  GroupMetadata                                       generation=2 protocol=range leader=g-classic-A-3.. members=2
 36  GroupMetadata                                       generation=3 protocol=range leader=g-classic-A-3.. members=1
 37  GroupMetadata                                       generation=4 protocol=null leader=null members=0
(+ 33 OffsetCommit records)
--- g-consumer -> __consumer_offsets-32
  0  ConsumerGroupMemberMetadata            member=dHHC  clientId=g-consumer-A
  1  ConsumerGroupMetadata                               groupEpoch=2
  2  ConsumerGroupTargetAssignmentMember    member=dHHC  target=[0,1,2,3,4,5]
  3  ConsumerGroupTargetAssignmentMetadata               assignmentEpoch=2
  4  ConsumerGroupCurrentMemberAssignment   member=dHHC  memberEpoch=2 state=0 assigned=[0,1,2,3,4,5] pending=
  5  ConsumerGroupMemberMetadata            member=90So  clientId=g-consumer-B
  6  ConsumerGroupMetadata                               groupEpoch=3
  7  ConsumerGroupCurrentMemberAssignment   member=90So  memberEpoch=2 state=0 assigned= pending=
  8  ConsumerGroupTargetAssignmentMember    member=dHHC  target=[0,1,2]
  9  ConsumerGroupTargetAssignmentMember    member=90So  target=[3,4,5]
 10  ConsumerGroupTargetAssignmentMetadata               assignmentEpoch=3
 11  ConsumerGroupCurrentMemberAssignment   member=dHHC  memberEpoch=2 state=1 assigned=[0,1,2] pending=[3,4,5]
 24  ConsumerGroupCurrentMemberAssignment   member=90So  memberEpoch=3 state=2 assigned= pending=
 25  ConsumerGroupCurrentMemberAssignment   member=dHHC  memberEpoch=3 state=0 assigned=[0,1,2] pending=
 26  ConsumerGroupCurrentMemberAssignment   member=90So  memberEpoch=3 state=0 assigned=[3,4,5] pending=
 51  ConsumerGroupCurrentMemberAssignment   member=90So  <DELETE>
 52  ConsumerGroupTargetAssignmentMember    member=90So  <DELETE>
 53  ConsumerGroupMemberMetadata            member=90So  <DELETE>
 54  ConsumerGroupMetadata                               groupEpoch=4
 55  ConsumerGroupTargetAssignmentMember    member=dHHC  target=[0,1,2,3,4,5]
 56  ConsumerGroupTargetAssignmentMetadata               assignmentEpoch=4
 57  ConsumerGroupCurrentMemberAssignment   member=dHHC  memberEpoch=4 state=0 assigned=[0,1,2,3,4,5] pending=
 64  ConsumerGroupCurrentMemberAssignment   member=dHHC  <DELETE>
 65  ConsumerGroupTargetAssignmentMember    member=dHHC  <DELETE>
 66  ConsumerGroupMemberMetadata            member=dHHC  <DELETE>
 67  ConsumerGroupMetadata                               groupEpoch=5
 68  ConsumerGroupTargetAssignmentMetadata               assignmentEpoch=5
(+ 42 OffsetCommit records)

The key's type number is the apiKey of the record schema JSON, which I checked in group-coordinator/src/main/resources/common/message/ — OffsetCommitKey (1), GroupMetadataKey (2), ConsumerGroupMetadataKey (3), ConsumerGroupMemberMetadataKey (5), ConsumerGroupTargetAssignmentMetadataKey (6), ConsumerGroupTargetAssignmentMemberKey (7), ConsumerGroupCurrentMemberAssignmentKey (8). Like the requests and responses of post 3, these schemas are turned into Java classes at build time.

Example 4: restarting the broker

Finally, we shut the server down cleanly, started it again, and looked at the load log lines of the two partitions and the committed offsets of g-consumer. At the end we pulled only the group coordinator thread names out of the broker's jstack.

=== 5. restart the server: the coordinator replays its partitions
Finished loading of metadata from __consumer_offsets-18 with epoch 2 in 290ms where 248ms was spent in the scheduler. Loaded 38 records which total to 4268 bytes. (org.apache.kafka.coordinator.common.runtime.CoordinatorRuntime)
Finished loading of metadata from __consumer_offsets-32 with epoch 2 in 246ms where 7ms was spent in the scheduler. Loaded 69 records which total to 6233 bytes. (org.apache.kafka.coordinator.common.runtime.CoordinatorRuntime)

GROUP           TOPIC           PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG             CONSUMER-ID     HOST            CLIENT-ID
g-consumer      orders          0          10              10              0               -               -               -
g-consumer      orders          1          9               9               0               -               -               -
g-consumer      orders          2          13              13              0               -               -               -
g-consumer      orders          3          6               6               0               -               -               -
g-consumer      orders          4          14              14              0               -               -               -
g-consumer      orders          5          8               8               0               -               -               -
--- group coordinator threads in the broker (jstack)
"group-coordinator-event-processor-0"
"group-coordinator-event-processor-1"
"group-coordinator-event-processor-2"
"group-coordinator-event-processor-3"
"group-coordinator-reaper"
stopped

Loaded N records is the number of records read from the start of the partition and fed to replay(). It equals the count from example 3 (group records + OffsetCommit); the count, time and bytes differ from run to run. The group state in memory vanished with the server, but it was rebuilt from the log as it was, and the committed offsets are the same as before the restart. The threads are group-coordinator-event-processor-0 to -3 (4 by default, GroupCoordinatorService.java lines 263–269) and group-coordinator-reaper, which drives timers such as session expiry and rebalance timeouts (BrokerServer.scala lines 695–698). The group-coordinator-background-… pool for heavy work such as resolving regular expressions (lines 271–276) doesn't appear: that pool extends the JDK's ThreadPoolExecutor, which creates a thread when the first task arrives, and this example never gave it one.

7. Settings at a glance

Here are the settings that came up. Every default was checked against the constant passed to define(…).

Setting Where Default Used by · what it does
group.protocol consumer classic ConsumerDelegateCreator picks ClassicKafkaConsumer/AsyncKafkaConsumer
partition.assignment.strategy consumer (classic) [RangeAssignor, CooperativeStickyAssignor] the leader computes the assignment. The common protocol is EAGER, so the default is eager
heartbeat.interval.ms consumer (classic) 3000 the heartbeat thread's interval; how long it takes to notice a rebalance
session.timeout.ms consumer (classic) 45000 how long before a member with no heartbeats is removed
max.poll.interval.ms consumer 300000 the longest gap between poll() calls; past it the member leaves on its own. In classic it is also the rebalance timeout
enable.auto.commit / auto.commit.interval.ms consumer true / 5000 periodic async commits. classic does it inside poll(); consumer does it in the background thread when it gets the poll event (CommitRequestManager.updateTimerAndMaybeCommit())
group.remote.assignor consumer (consumer) none picks which broker assignor to use
group.consumer.assignors broker uniform, range the first is the default assignor
group.consumer.heartbeat.interval.ms broker 5000 the heartbeat interval sent to clients in responses
group.consumer.session.timeout.ms broker 45000 session timeout for consumer-protocol members
group.consumer.assignment.interval.ms broker 1000 the minimum interval between target assignment recomputations
group.initial.rebalance.delay.ms broker (classic) 3000 extra wait when the first member joins an empty group
group.coordinator.threads broker 4 number of group-coordinator-event-processor-N threads
offsets.topic.num.partitions broker 50 partitions of __consumer_offsets; the modulus in the group → partition formula

ConsumerConfig.java lines 460–499, GroupCoordinatorConfig.java lines 460–494

Where the settings are used. In the classic consumer, the application thread's poll() uses enable.auto.commit=true, auto.commit.interval.ms=5000 and partition.assignment.strategy (the leader consumer computes the assignment), and the heartbeat thread uses heartbeat.interval.ms=3000, session.timeout.ms=45000 and max.poll.interval.ms=300000 (also the rebalance timeout). In the consumer-protocol consumer, the background thread uses max.poll.interval.ms=300000 (measured by the heartbeat request manager), auto commit (CommitRequestManager) and group.remote.assignor (no default). Classic reaches the broker's group coordinator with JoinGroup, SyncGroup and Heartbeat, consumer with ConsumerGroupHeartbeat. On the broker side are group.initial.rebalance.delay.ms=3000 for classic; group.consumer.heartbeat.interval.ms=5000 (sent in the response), group.consumer.session.timeout.ms=45000, group.consumer.assignment.interval.ms=1000 and group.consumer.assignors=uniform, range (the first is the default) for consumer; and, for both, group.coordinator.threads=4 and offsets.topic.num.partitions=50 (__consumer_offsets) The settings from the table above, regrouped by where they are used. All values are the defaults from the table.

For the broker to accept the consumer protocol, the feature flag group.version must be 1 or higher and the broker setting group.coordinator.rebalance.protocols must include consumer (KafkaApis.isConsumerGroupProtocolEnabled(), lines 2675–2678). That setting's default contains classic, consumer and streams, and it is marked deprecated for removal in 5.0 (GroupCoordinatorConfig.java lines 82–92). The example's server was freshly created with kafka-storage.sh format, and the consumer-protocol run succeeded, so it was on from the start (the example's kafka-features.sh describe output is cut to its first six fields by run.sh, so it shows only the supported range up to SupportedMaxVersion: 1, not the finalized level). A cluster upgraded from an older version may need it raised separately (what I verified in the source is only this condition).

What this post leaves out

Summary

Concept One-line summary
KafkaConsumer a shell with one field (delegate). ConsumerDelegateCreator picks the implementation from group.protocol
group.protocol classic (default) = JoinGroup and SyncGroup, the leader consumer assigns / consumer = ConsumerGroupHeartbeat, the broker assigns
ClassicKafkaConsumer inside poll() the app thread does rebalancing, auto-commit and Fetch; the heartbeat thread only sends Heartbeat
classic's default rebalance the common protocol of [Range, CooperativeSticky] is EAGER, so everything is given up and reassigned
AsyncKafkaConsumer the app thread queues events and waits for results. consumer_background_thread does all networking through the RequestManagers
Rebalance callbacks in both implementations, on the thread that called poll(). The new one hands them over as background events
Where the group coordinator is the leader broker of __consumer_offsets partition number abs(groupId.hashCode()) % 50
Target vs current assignment the broker computes a target for the whole group, and CurrentAssignmentBuilder moves members step by step — give up first, then receive
CoordinatorRuntime a state machine per partition. Records are applied with replay() first, written to the log, and the response goes out once the HW passes them
__consumer_offsets holds not just offsets but groups, members and assignments as records. On restart they are read from the start and rebuilt with replay()

What's next

Committing an offset and processing a record are two separate actions. Crash after processing but before committing and you read the same record again; crash after committing but before processing and you lose it. A producer, too, can write the same record twice while retrying. The last post looks at the mechanisms that close these gaps — the idempotent producer that blocks duplicates with a producer id and sequence numbers, transactions that bundle writes to several partitions together with an offset commit (sendOffsetsToTransaction() is the road into this post's __consumer_offsets), and the consumer's isolation.level=read_committed — in post 12: Idempotence and Transactions — How Exactly-Once Is Built, and Wrapping Up the Series.

The idea in this post — "write state to the log as records, then read them back to rebuild it" — is the same idea as what the controller of post 10 does with the metadata log. Read the two side by side and you'll see that Kafka trusts its own state to Kafka's log, too.

More posts