This is an automated email from the ASF dual-hosted git repository.

lianetm pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/kafka.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 673107f49d7 KAFKA-20778: Avoid unneeded rebalance on classic consumer 
when replica unavailable (#22768)
673107f49d7 is described below

commit 673107f49d7cb01249d9214cfea6e112d8876a39
Author: Lianet Magrans <[email protected]>
AuthorDate: Wed Jul 8 10:47:39 2026 -0400

    KAFKA-20778: Avoid unneeded rebalance on classic consumer when replica 
unavailable (#22768)
    
    Improve to avoid unnecessary double rebalance on the classic consumer
    when a replica is bounced.
    
    Before this PR, when a replica is bounced it resolves to an empty node
    (no host) with a null rack on the consumer metadta view. The consumer's
    metadata comparison treated that as a rack change (rack1 to null) and
    triggered a rebalance, then a second one when the broker came back (null
    to rack1). This affects the classic consumer, every assignor, when
    client.rack is configured.
    
    This PR improves the rack comparison to track racks by replica id and
    treat an unavailable replica (empty node) as having an unknown rack
    rather than a changed one: two metadata snapshots are equivalent if they
    expose the same set of racks for available replicas, tolerating a
    missing/unknown rack only when the replica it belongs to is unavailable.
    
    Reviewers: David Jacot <[email protected]>
---
 .../consumer/internals/ConsumerCoordinator.java    |  86 +++++++++---
 .../internals/ConsumerCoordinatorTest.java         | 154 +++++++++++++++++++--
 2 files changed, 207 insertions(+), 33 deletions(-)

diff --git 
a/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ConsumerCoordinator.java
 
b/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ConsumerCoordinator.java
index 476fe8bbd2c..fb0ffe11771 100644
--- 
a/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ConsumerCoordinator.java
+++ 
b/clients/src/main/java/org/apache/kafka/clients/consumer/internals/ConsumerCoordinator.java
@@ -75,7 +75,6 @@ import org.slf4j.Logger;
 
 import java.nio.ByteBuffer;
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -1619,7 +1618,29 @@ public final class ConsumerCoordinator extends 
AbstractCoordinator {
         }
 
         boolean matches(MetadataSnapshot other) {
-            return version == other.version || 
partitionsPerTopic.equals(other.partitionsPerTopic);
+            if (version == other.version)
+                return true;
+
+            // A rebalance is only needed if the metadata changed in a way 
that could change the
+            // assignment: a subscribed topic was added or removed, a topic's 
partition count
+            // changed, or the racks of a partition's replicas changed. But 
rack differences that are
+            // only due to a replica broker being temporarily offline (host 
and rack become unknown)
+            // are tolerated, so that a broker bounce does not trigger 
unnecessary rebalances.
+            // See PartitionRackInfo#equivalentTo.
+            if (partitionsPerTopic.size() != other.partitionsPerTopic.size())
+                return false;
+
+            for (Map.Entry<String, List<PartitionRackInfo>> entry : 
partitionsPerTopic.entrySet()) {
+                List<PartitionRackInfo> partitionRacks = entry.getValue();
+                List<PartitionRackInfo> otherPartitionRacks = 
other.partitionsPerTopic.get(entry.getKey());
+                if (otherPartitionRacks == null || otherPartitionRacks.size() 
!= partitionRacks.size())
+                    return false;
+                for (int i = 0; i < partitionRacks.size(); i++) {
+                    if 
(!partitionRacks.get(i).equivalentTo(otherPartitionRacks.get(i)))
+                        return false;
+                }
+            }
+            return true;
         }
 
         @Override
@@ -1656,36 +1677,61 @@ public final class ConsumerCoordinator extends 
AbstractCoordinator {
     }
 
     private static class PartitionRackInfo {
-        private final Set<String> racks;
+        // Racks of the online replicas, keyed by replica id. Replicas on 
brokers without a
+        // configured rack are not tracked, since they cannot affect 
rack-aware assignment.
+        private final Map<Integer, String> onlineRacks;
+        // Ids of the offline replicas, whose broker is not in the current 
live-broker list (they
+        // resolve to an empty node), so their rack is unknown. Tracked by id 
so that a broker
+        // that is temporarily offline is not mistaken for a rack change.
+        private final Set<Integer> offlineReplicas;
 
         PartitionRackInfo(Optional<String> clientRack, PartitionInfo 
partition) {
+            Map<Integer, String> onlineRacks = new HashMap<>();
+            Set<Integer> offlineReplicas = new HashSet<>();
             if (clientRack.isPresent() && partition.replicas() != null) {
-                racks = 
Arrays.stream(partition.replicas()).map(Node::rack).collect(Collectors.toSet());
-            } else {
-                racks = Collections.emptySet();
+                for (Node replica : partition.replicas()) {
+                    if (replica.isEmpty())
+                        offlineReplicas.add(replica.id());
+                    else if (replica.rack() != null)
+                        onlineRacks.put(replica.id(), replica.rack());
+                }
             }
+            this.onlineRacks = onlineRacks;
+            this.offlineReplicas = offlineReplicas;
         }
 
-        @Override
-        public boolean equals(Object o) {
-            if (this == o) {
-                return true;
-            }
-            if (!(o instanceof PartitionRackInfo)) {
-                return false;
-            }
-            PartitionRackInfo rackInfo = (PartitionRackInfo) o;
-            return Objects.equals(racks, rackInfo.racks);
+        /**
+         * Returns true if the two snapshots expose the same set of racks for 
assignment purposes.
+         * Replica changes that leave the set of racks unchanged (e.g. a 
replica moved to a
+         * different broker on one of the same racks) are not considered a 
change. A rack that is
+         * present in one snapshot but missing from the other is tolerated 
only when the replica
+         * it belongs to is offline (same replica id, rack unknown rather than 
changed),
+         * so a temporarily offline broker is not a rack change, while
+         * a rack that is actually added or removed still is.
+         */
+        boolean equivalentTo(PartitionRackInfo other) {
+            return racksAccountedForIn(other) && 
other.racksAccountedForIn(this);
         }
 
-        @Override
-        public int hashCode() {
-            return Objects.hash(racks);
+        /**
+         * Returns true if every online rack in this PartitionRackInfo 
snapshot is either still
+         * present in {@code other}, or belongs to a replica that is offline 
in {@code other}
+         * (its rack is unknown there, rather than changed).
+         */
+        private boolean racksAccountedForIn(PartitionRackInfo other) {
+            for (Map.Entry<Integer, String> replicaRack : 
onlineRacks.entrySet()) {
+                if (!other.onlineRacks.containsValue(replicaRack.getValue()) &&
+                        !other.offlineReplicas.contains(replicaRack.getKey())) 
{
+                    return false;
+                }
+            }
+            return true;
         }
 
         @Override
         public String toString() {
-            return racks.isEmpty() ? "NO_RACKS" : "racks=" + racks;
+            String racks = onlineRacks.isEmpty() ? "NO_RACKS" : "racks=" + 
onlineRacks;
+            return offlineReplicas.isEmpty() ? racks : racks + ", 
offlineReplicas=" + offlineReplicas;
         }
     }
 
diff --git 
a/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ConsumerCoordinatorTest.java
 
b/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ConsumerCoordinatorTest.java
index c9d050c161d..d98d2d5fd70 100644
--- 
a/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ConsumerCoordinatorTest.java
+++ 
b/clients/src/test/java/org/apache/kafka/clients/consumer/internals/ConsumerCoordinatorTest.java
@@ -1545,8 +1545,104 @@ public abstract class ConsumerCoordinatorTest {
                 true, false);
     }
 
+    @Test
+    public void testRackAwareConsumerNoRebalanceWhenReplicaBounced() {
+        // A broker hosting a replica is bounced: it first goes offline (drops 
out of the
+        // live-broker list, while replica 1 remains in the partition replicas 
with an unknown
+        // rack) and then comes back online on the same replica. Neither 
transition is a rack
+        // change, so no rebalance is expected at any point.
+        List<String> racks = List.of("rack-a", "rack-b", "rack-c");
+        RackAwareAssignor assignor = new RackAwareAssignor(protocol);
+        createRackAwareCoordinator("rack-a", assignor);
+
+        Map<String, List<List<Integer>>> replicas = Map.of(topic1,
+                List.of(List.of(0, 1), List.of(1, 2), List.of(2, 0)));
+        MetadataResponse allBrokersUp = rackAwareMetadata(6, racks, Set.of(), 
replicas);
+        MetadataResponse broker1Down = rackAwareMetadata(6, racks, Set.of(1), 
replicas);
+
+        subscribeAndJoinAsLeader(Optional.of("rack-a"), assignor, 
allBrokersUp);
+
+        // Broker 1 goes down.
+        client.updateMetadata(broker1Down);
+        coordinator.poll(time.timer(0));
+        assertEquals(0, client.requests().size());
+
+        // Broker 1 comes back.
+        client.updateMetadata(allBrokersUp);
+        coordinator.poll(time.timer(0));
+        assertEquals(0, client.requests().size());
+    }
+
+    @Test
+    public void 
testRackAwareConsumerRebalanceWithReplicaReassignedToOfflineBroker() {
+        // Partition 0 is reassigned from broker 0 (rack-a) to broker 6, which 
is not in the
+        // live-broker list. This is a replica change, not just an offline 
broker, so a
+        // rebalance is expected even though broker 6's rack is unknown.
+        verifyRackAwareConsumerRebalance(
+                List.of(List.of(0, 1), List.of(1, 2), List.of(2, 0)),
+                List.of(List.of(6, 1), List.of(1, 2), List.of(2, 0)),
+                true, true);
+    }
+
+    @Test
+    public void testRackAwareConsumerRebalanceWithNewRackWhileBrokerOffline() {
+        // The assignment was taken while broker 1 was offline. A replica on a 
new rack (broker 5,
+        // rack-c) is added later: the offline broker must not mask that real 
rack change.
+        verifyRackAwareConsumerRebalance(
+                List.of(List.of(0, 1), List.of(1, 2), List.of(2, 0)),
+                List.of(List.of(0, 1, 5), List.of(1, 2), List.of(2, 0)),
+                Set.of(1), Set.of(),
+                true, true);
+    }
+
+    @Test
+    public void testRackAwareConsumerRebalanceWhenOnlineReplicaLosesRack() {
+        // Broker 1 stays online (in the live-broker list) but loses its rack 
config. Unlike an
+        // offline broker, its rack really disappeared from the replicas of 
partitions 0
+        // and 1, so a rebalance is expected.
+        RackAwareAssignor assignor = new RackAwareAssignor(protocol);
+        createRackAwareCoordinator("rack-a", assignor);
+        Map<String, List<List<Integer>>> replicas = Map.of(topic1,
+                List.of(List.of(0, 1), List.of(1, 2), List.of(2, 0)));
+        MetadataResponse response1 = rackAwareMetadata(
+                List.of("rack-a", "rack-b", "rack-c", "rack-a", "rack-b", 
"rack-c"),
+                Set.of(), replicas);
+        MetadataResponse response2 = rackAwareMetadata(
+                Arrays.asList("rack-a", null, "rack-c", "rack-a", "rack-b", 
"rack-c"),
+                Set.of(), replicas);
+        verifyRebalanceWithMetadataChange(Optional.of("rack-a"), assignor, 
response1, response2, true);
+    }
+
+    @Test
+    public void 
testRackAwareConsumerNoRebalanceWhenRedundantReplicaLosesRack() {
+        // Broker 3 loses its rack config, but partition 0 still has a replica 
on the same rack
+        // (broker 0, rack-a), so the racks visible to the assignor are 
unchanged and no
+        // rebalance is expected.
+        RackAwareAssignor assignor = new RackAwareAssignor(protocol);
+        createRackAwareCoordinator("rack-a", assignor);
+        Map<String, List<List<Integer>>> replicas = Map.of(topic1,
+                List.of(List.of(0, 3), List.of(1, 2), List.of(2, 0)));
+        MetadataResponse response1 = rackAwareMetadata(
+                List.of("rack-a", "rack-b", "rack-c", "rack-a", "rack-b", 
"rack-c"),
+                Set.of(), replicas);
+        MetadataResponse response2 = rackAwareMetadata(
+                Arrays.asList("rack-a", "rack-b", "rack-c", null, "rack-b", 
"rack-c"),
+                Set.of(), replicas);
+        verifyRebalanceWithMetadataChange(Optional.of("rack-a"), assignor, 
response1, response2, false);
+    }
+
+    private void verifyRackAwareConsumerRebalance(List<List<Integer>> 
partitionReplicas1,
+                                                  List<List<Integer>> 
partitionReplicas2,
+                                                  boolean rackAwareConsumer,
+                                                  boolean expectRebalance) {
+        verifyRackAwareConsumerRebalance(partitionReplicas1, 
partitionReplicas2,
+                Set.of(), Set.of(), rackAwareConsumer, expectRebalance);
+    }
+
     private void verifyRackAwareConsumerRebalance(List<List<Integer>> 
partitionReplicas1,
                                                   List<List<Integer>> 
partitionReplicas2,
+                                                  Set<Integer> downNodes1,
+                                                  Set<Integer> downNodes2,
                                                   boolean rackAwareConsumer,
                                                   boolean expectRebalance) {
         List<String> racks = Arrays.asList("rack-a", "rack-b", "rack-c");
@@ -1558,23 +1654,24 @@ public abstract class ConsumerCoordinatorTest {
             createRackAwareCoordinator(consumerRackId, assignor);
         }
 
-        MetadataResponse metadataResponse1 = rackAwareMetadata(6, racks, 
Collections.singletonMap(topic1, partitionReplicas1));
-        MetadataResponse metadataResponse2 = rackAwareMetadata(6, racks, 
Collections.singletonMap(topic1, partitionReplicas2));
+        MetadataResponse metadataResponse1 = rackAwareMetadata(6, racks, 
downNodes1, Collections.singletonMap(topic1, partitionReplicas1));
+        MetadataResponse metadataResponse2 = rackAwareMetadata(6, racks, 
downNodes2, Collections.singletonMap(topic1, partitionReplicas2));
         verifyRebalanceWithMetadataChange(Optional.ofNullable(consumerRackId), 
assignor, metadataResponse1, metadataResponse2, expectRebalance);
     }
 
-    private void verifyRebalanceWithMetadataChange(Optional<String> rackId,
-                                                   MockPartitionAssignor 
partitionAssignor,
-                                                   MetadataResponse 
metadataResponse1,
-                                                   MetadataResponse 
metadataResponse2,
-                                                   boolean expectRebalance) {
+    /**
+     * Subscribes to {@code topic1} and {@code topic2} and completes a first 
rebalance as the
+     * group leader, with the given metadata as the initial cluster state, so 
that the
+     * assignment metadata snapshot is taken from it.
+     */
+    private void subscribeAndJoinAsLeader(Optional<String> rackId,
+                                          MockPartitionAssignor 
partitionAssignor,
+                                          MetadataResponse initialMetadata) {
         final String consumerId = "leader";
         final List<String> topics = Arrays.asList(topic1, topic2);
-        final List<TopicPartition> partitions = 
metadataResponse1.topicMetadata().stream()
-                .flatMap(t -> t.partitionMetadata().stream().map(p -> new 
TopicPartition(t.topic(), p.partition())))
-                .collect(Collectors.toList());
+        final List<TopicPartition> partitions = 
partitionsFromMetadata(initialMetadata);
         subscriptions.subscribe(Set.copyOf(topics), 
Optional.of(rebalanceListener));
-        client.updateMetadata(metadataResponse1);
+        client.updateMetadata(initialMetadata);
         coordinator.maybeUpdateSubscriptionMetadata();
 
         client.prepareResponse(groupCoordinatorResponse(node, Errors.NONE));
@@ -1594,6 +1691,25 @@ public abstract class ConsumerCoordinatorTest {
         assertEquals(0, rebalanceListener.revokedCount);
         assertNull(rebalanceListener.revoked);
         assertEquals(1, rebalanceListener.assignedCount);
+    }
+
+    private static List<TopicPartition> 
partitionsFromMetadata(MetadataResponse metadata) {
+        return metadata.topicMetadata().stream()
+                .flatMap(t -> t.partitionMetadata().stream().map(p -> new 
TopicPartition(t.topic(), p.partition())))
+                .collect(Collectors.toList());
+    }
+
+    private void verifyRebalanceWithMetadataChange(Optional<String> rackId,
+                                                   MockPartitionAssignor 
partitionAssignor,
+                                                   MetadataResponse 
metadataResponse1,
+                                                   MetadataResponse 
metadataResponse2,
+                                                   boolean expectRebalance) {
+        final String consumerId = "leader";
+        final List<String> topics = Arrays.asList(topic1, topic2);
+        final List<TopicPartition> partitions = 
partitionsFromMetadata(metadataResponse1);
+        Map<String, List<String>> initialSubscription = 
singletonMap(consumerId, topics);
+
+        subscribeAndJoinAsLeader(rackId, partitionAssignor, metadataResponse1);
 
         // Change metadata to trigger rebalance.
         client.updateMetadata(metadataResponse2);
@@ -4113,10 +4229,22 @@ public abstract class ConsumerCoordinatorTest {
 
     private static MetadataResponse rackAwareMetadata(int numNodes,
                                                       List<String> racks,
+                                                      Set<Integer> downNodes,
                                                       Map<String, 
List<List<Integer>>> partitionReplicas) {
-        final List<Node> nodes = new ArrayList<>(numNodes);
+        List<String> nodeRacks = new ArrayList<>(numNodes);
         for (int i = 0; i < numNodes; i++)
-            nodes.add(new Node(i, "localhost", 1969 + i, racks.get(i % 
racks.size())));
+            nodeRacks.add(racks.get(i % racks.size()));
+        return rackAwareMetadata(nodeRacks, downNodes, partitionReplicas);
+    }
+
+    private static MetadataResponse rackAwareMetadata(List<String> nodeRacks,
+                                                      Set<Integer> downNodes,
+                                                      Map<String, 
List<List<Integer>>> partitionReplicas) {
+        final List<Node> nodes = new ArrayList<>(nodeRacks.size());
+        for (int i = 0; i < nodeRacks.size(); i++) {
+            if (!downNodes.contains(i))
+                nodes.add(new Node(i, "localhost", 1969 + i, 
nodeRacks.get(i)));
+        }
 
         List<MetadataResponse.TopicMetadata> topicMetadata = new ArrayList<>();
         for (Map.Entry<String, List<List<Integer>>> topicPartitionCountEntry : 
partitionReplicas.entrySet()) {

Reply via email to