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

bbejeck 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 362a0371c6e KAFKA-20198: Fix StickyTaskAssignor capacity calculation 
to use proportional instance limit (#22006)
362a0371c6e is described below

commit 362a0371c6e34e12830b666045f4ff0d283e3fe4
Author: Mingi Cho <[email protected]>
AuthorDate: Fri Jul 24 06:38:39 2026 +0900

    KAFKA-20198: Fix StickyTaskAssignor capacity calculation to use 
proportional instance limit (#22006)
    
    [KAFKA-20198](https://issues.apache.org/jira/browse/KAFKA-20198)
    
    Saw the discussion on Confluent Slack about `StickyTaskAssignor` taking
    many rebalance rounds to converge with the classic group protocol when
    scaling up. Dug into it and found the root cause in
    `hasRoomForActiveTask`.
    
    The per-instance limit is computed as `capacity * (taskCount /
    totalCapacity)`, but the integer division floors — so with 450 tasks and
    20 threads, each instance gets a limit of `10 * 22 = 220` instead of the
    fair share 225. The 10-task gap pushes overflow into
    `findBestClientForTask`, which picks differently each round due to
    HashMap iteration order, creating a feedback loop that prevents
    convergence.
    
    Fixed by computing the instance limit directly as a proportional share:
    `(taskCount * capacity + totalCapacity - 1) / totalCapacity`, giving
    `ceil(450 * 10 / 20) = 225`. Same approach as
    `AbstractStickyAssignor.maxQuota`.
    
    Simulated with the actual `StickyTaskAssignor` across cooperative
    rebalance rounds:
    
    | Scenario | Before | After | |----------|--------|-------| | 450p/10t |
    5 rounds | 2 rounds | | 100p/4t | 4 rounds | 2 rounds | | 200p/8t | 6
    rounds | 2 rounds | | 1000p/16t | 9 rounds | 5 rounds | | 500p/12t | 9
    rounds | 3 rounds | | Even cases | 2 rounds | 2 rounds |
    
    All existing tests pass.
    
    Reviewers: gabriellafu (github:gabriellefu), Hari Mohan (github:harimm),
     Bill Bejeck <[email protected]>
---
 .../assignment/assignors/StickyTaskAssignor.java   | 10 +--
 .../assignment/CustomStickyTaskAssignorTest.java   | 77 ++++++++++++++++++++++
 2 files changed, 82 insertions(+), 5 deletions(-)

diff --git 
a/streams/src/main/java/org/apache/kafka/streams/processor/assignment/assignors/StickyTaskAssignor.java
 
b/streams/src/main/java/org/apache/kafka/streams/processor/assignment/assignors/StickyTaskAssignor.java
index ec16c0dde3d..0e7c80a30b9 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/processor/assignment/assignors/StickyTaskAssignor.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/processor/assignment/assignors/StickyTaskAssignor.java
@@ -148,7 +148,6 @@ public class StickyTaskAssignor implements TaskAssignor {
         final int totalCapacity = computeTotalProcessingThreads(clients);
         final Set<TaskId> allTaskIds = applicationState.allTasks().keySet();
         final int taskCount = allTaskIds.size();
-        final int activeTasksPerThread = taskCount / totalCapacity;
         final Set<TaskId> unassigned = new HashSet<>(allTaskIds);
 
         // first try and re-assign existing active tasks to clients that 
previously had
@@ -156,7 +155,7 @@ public class StickyTaskAssignor implements TaskAssignor {
         for (final TaskId taskId : 
assignmentState.previousActiveAssignment.keySet()) {
             final ProcessId previousClientForTask = 
assignmentState.previousActiveAssignment.get(taskId);
             if (allTaskIds.contains(taskId)) {
-                if (mustPreserveActiveTaskAssignment || 
assignmentState.hasRoomForActiveTask(previousClientForTask, 
activeTasksPerThread)) {
+                if (mustPreserveActiveTaskAssignment || 
assignmentState.hasRoomForActiveTask(previousClientForTask, taskCount, 
totalCapacity)) {
                     assignmentState.finalizeAssignment(taskId, 
previousClientForTask, AssignedTask.Type.ACTIVE);
                     unassigned.remove(taskId);
                 }
@@ -169,7 +168,7 @@ public class StickyTaskAssignor implements TaskAssignor {
             final TaskId taskId = iterator.next();
             final Set<ProcessId> previousClientsForStandbyTask = 
assignmentState.previousStandbyAssignment.getOrDefault(taskId, new HashSet<>());
             for (final ProcessId client: previousClientsForStandbyTask) {
-                if (assignmentState.hasRoomForActiveTask(client, 
activeTasksPerThread)) {
+                if (assignmentState.hasRoomForActiveTask(client, taskCount, 
totalCapacity)) {
                     assignmentState.finalizeAssignment(taskId, client, 
AssignedTask.Type.ACTIVE);
                     iterator.remove();
                     break;
@@ -297,14 +296,15 @@ public class StickyTaskAssignor implements TaskAssignor {
             this.newAssignments = optimizedAssignments;
         }
 
-        private boolean hasRoomForActiveTask(final ProcessId processId, final 
int activeTasksPerThread) {
+        private boolean hasRoomForActiveTask(final ProcessId processId, final 
int taskCount, final int totalCapacity) {
             final int capacity = clients.get(processId).numProcessingThreads();
             final int newActiveTaskCount = 
newAssignments.computeIfAbsent(processId, k -> 
KafkaStreamsAssignment.of(processId, new HashSet<>()))
                 .tasks().values()
                 .stream().filter(assignedTask -> assignedTask.type() == 
AssignedTask.Type.ACTIVE)
                 .collect(Collectors.toSet())
                 .size();
-            return newActiveTaskCount < capacity * activeTasksPerThread;
+            final int instanceLimit = (taskCount * capacity + totalCapacity - 
1) / totalCapacity;
+            return newActiveTaskCount < instanceLimit;
         }
 
         private ProcessId findBestClientForTask(final TaskId taskId, final 
Set<ProcessId> clientsWithin) {
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/processor/internals/assignment/CustomStickyTaskAssignorTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/processor/internals/assignment/CustomStickyTaskAssignorTest.java
index fa92a55dcae..a9a71d16f59 100644
--- 
a/streams/src/test/java/org/apache/kafka/streams/processor/internals/assignment/CustomStickyTaskAssignorTest.java
+++ 
b/streams/src/test/java/org/apache/kafka/streams/processor/internals/assignment/CustomStickyTaskAssignorTest.java
@@ -39,6 +39,8 @@ import org.junit.jupiter.params.provider.ValueSource;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -78,6 +80,7 @@ import static org.hamcrest.Matchers.is;
 import static org.hamcrest.Matchers.lessThanOrEqualTo;
 import static org.hamcrest.Matchers.not;
 import static org.hamcrest.Matchers.notNullValue;
+import static org.junit.jupiter.api.Assertions.fail;
 
 public class CustomStickyTaskAssignorTest {
 
@@ -913,4 +916,78 @@ public class CustomStickyTaskAssignorTest {
             assertThat(topicGroupIds, equalTo(asList(1, 2)));
         }
     }
+
+    // --- KAFKA-20198 regression tests ---
+
+    @Test
+    public void shouldRetainFairShareOfPreviousTasksWhenScalingUp() {
+        final int numTasks = 450;
+        final int threadsPerInstance = 10;
+        final Map<TaskId, TaskInfo> tasks = buildStatelessTasks(numTasks);
+        final Set<TaskId> firstHalf = buildTaskIdRange(0, numTasks / 2);
+
+        final Map<ProcessId, KafkaStreamsState> streamStates = mkMap(
+            mkStreamState(1, threadsPerInstance, Optional.empty(), firstHalf, 
Set.of()),
+            mkStreamState(2, threadsPerInstance, Optional.empty())
+        );
+
+        final Map<ProcessId, KafkaStreamsAssignment> assignments =
+            assign(streamStates, tasks, 
StreamsConfig.RACK_AWARE_ASSIGNMENT_STRATEGY_NONE);
+
+        final Set<TaskId> instance1Tasks = activeTasks(assignments, 1);
+        final int retained = (int) 
firstHalf.stream().filter(instance1Tasks::contains).count();
+
+        assertThat("Instance should retain all of its fair share tasks, but 
only retained " + retained + " of " + firstHalf.size(),
+            retained, equalTo(firstHalf.size()));
+    }
+
+    @Test
+    public void shouldConvergeWithinTwoRoundsWhenScalingUp() {
+        // Reporter's scenario: 450 tasks with 10+10=20 threads.
+        // 450/20 = 22.5, floor gives limit 220 per instance (10 task 
overflow),
+        // causing repeated reassignment across rounds.
+        final int numTasks = 450;
+        final int maxRounds = 2;
+        final Map<TaskId, TaskInfo> tasks = buildStatelessTasks(numTasks);
+
+        Set<TaskId> instance1Prev = buildTaskIdRange(0, numTasks);
+        Set<TaskId> instance2Prev = Set.of();
+
+        for (int round = 1; round <= maxRounds; round++) {
+            final Map<ProcessId, KafkaStreamsState> streamStates = mkMap(
+                mkStreamState(1, 10, Optional.empty(), instance1Prev, 
Set.of()),
+                mkStreamState(2, 10, Optional.empty(), instance2Prev, Set.of())
+            );
+
+            final Map<ProcessId, KafkaStreamsAssignment> assignments =
+                assign(streamStates, tasks, 
StreamsConfig.RACK_AWARE_ASSIGNMENT_STRATEGY_NONE);
+
+            final Set<TaskId> newInstance1 = activeTasks(assignments, 1);
+            final Set<TaskId> newInstance2 = activeTasks(assignments, 2);
+
+            if (newInstance1.equals(instance1Prev) && 
newInstance2.equals(instance2Prev)) {
+                return; // converged
+            }
+            instance1Prev = newInstance1;
+            instance2Prev = newInstance2;
+        }
+        fail("Assignment did not converge within " + maxRounds + " rounds");
+    }
+
+    private static Map<TaskId, TaskInfo> buildStatelessTasks(final int count) {
+        final Map<TaskId, TaskInfo> tasks = new HashMap<>();
+        for (int i = 0; i < count; i++) {
+            final TaskId taskId = new TaskId(0, i);
+            tasks.put(taskId, mkTaskInfo(taskId, false).getValue());
+        }
+        return tasks;
+    }
+
+    private static Set<TaskId> buildTaskIdRange(final int from, final int to) {
+        final Set<TaskId> set = new HashSet<>();
+        for (int i = from; i < to; i++) {
+            set.add(new TaskId(0, i));
+        }
+        return set;
+    }
 }

Reply via email to