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

mjsax 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 553f6635a93 KAFKA-20665: Promote a warm-up task to active in place in 
the reconciler (#22923)
553f6635a93 is described below

commit 553f6635a934f76e96570341b3acb2a257aa7055
Author: Matthias J. Sax <[email protected]>
AuthorDate: Tue Jul 28 10:52:48 2026 -0700

    KAFKA-20665: Promote a warm-up task to active in place in the reconciler 
(#22923)
    
    When the target assignment moves a task to a member that currently holds
    it as a warm-up, the reconciler now promotes the warm-up to active in
    place instead of revoking it and reassigning the active in a later step.
    This lets the member recycle the task's (in-memory) state store instead
    of closing it and restoring from the changelog (cf. KAFKA-9501).
    
    CurrentAssignmentBuilder:
     - The active-task "unreleased" predicate no longer treats a warm-up
       held by *this* member for the same task as a blocker (that is the 
promotion).
       It still blocks when a *different* member on the same process holds
       the warm-up, so a process never runs a task as both active and warm-up.
     - applyWarmupPromotions() keeps the warm-up as an assigned task
       while the active is still owned elsewhere, or while its grant is deferred
       by another pending revocation (buildNewMember gives revocation
       priority), and drops it only in the step in which the active is actually 
granted.
    
    Reviewers: Sean Quah <[email protected]>
---
 .../group/streams/CurrentAssignmentBuilder.java    | 122 +++++++++++--
 .../streams/CurrentAssignmentBuilderTest.java      | 199 +++++++++++++++++++++
 2 files changed, 306 insertions(+), 15 deletions(-)

diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/CurrentAssignmentBuilder.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/CurrentAssignmentBuilder.java
index 67c76616e73..8544bed067b 100644
--- 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/CurrentAssignmentBuilder.java
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/CurrentAssignmentBuilder.java
@@ -178,12 +178,10 @@ public class CurrentAssignmentBuilder {
                 // owned tasks set in the StreamsGroupHeartbeat API.
 
                 // If the member provides its owned tasks, we verify if it 
still
-                // owns any of the revoked tasks. If it did not provide it's
+                // owns any of the revoked tasks. If it did not provide its
                 // owned tasks, or we still own some of the revoked tasks, we
                 // cannot progress.
-                if (
-                    ownedTasks.isEmpty() || 
ownedTasks.get().containsAny(member.tasksPendingRevocation())
-                ) {
+                if (hasNotReleased(member.tasksPendingRevocation())) {
                     return member;
                 }
 
@@ -426,12 +424,20 @@ public class CurrentAssignmentBuilder {
             newActiveAssignedTasks,
             newActiveTasksPendingRevocation,
             newActiveTasksPendingAssignment,
+            // In general, an active task can only be assigned once its 
previous owner has released it.
+            // In addition, we cannot assign an active task to a _process_ 
that still holds a corresponding
+            // standby or warm-up task, because a single process must not run 
the same task twice.
+            // The one exception is an in-place warm-up promotion: if THIS 
MEMBER holds the task as a warm-up,
+            // we convert it to active in a single step (drop the warm-up, 
grant the active)
             (subtopologyId, partitionId) ->
+                // still owned as active by its previous owner
                 currentActiveTaskProcessId.apply(subtopologyId, partitionId) 
!= null ||
-                    currentStandbyTaskProcessIds.apply(subtopologyId, 
partitionId)
-                        .contains(member.processId()) ||
-                    currentWarmupTaskProcessIds.apply(subtopologyId, 
partitionId)
-                        .contains(member.processId())
+                    // this member's process still holds it as a standby
+                    currentStandbyTaskProcessIds.apply(subtopologyId, 
partitionId).contains(member.processId()) ||
+                    // this member's process holds it as a warm-up via a 
*different* member (if this member owns
+                    // the warm-up it is a promotion, which is allowed)
+                    (currentWarmupTaskProcessIds.apply(subtopologyId, 
partitionId).contains(member.processId())
+                        && 
!memberAssignedTasks.warmupTasks().getOrDefault(subtopologyId, 
Set.of()).contains(partitionId))
         );
 
         boolean hasUnreleasedStandbyTasks = computeAssignmentDifference(
@@ -464,13 +470,18 @@ public class CurrentAssignmentBuilder {
                         .contains(member.processId())
         );
 
+        TasksTupleWithEpochs newTasksPendingRevocation = applyWarmupPromotions(
+            memberAssignedTasks,
+            newActiveTasksPendingRevocation,
+            newStandbyTasksPendingRevocation,
+            newWarmupTasksPendingRevocation,
+            newActiveTasksPendingAssignment,
+            newWarmupAssignedTasks // modified in-place by 
applyWarmupPromotions
+        );
+
         return buildNewMember(
             memberEpoch,
-            new TasksTupleWithEpochs(
-                newActiveTasksPendingRevocation,
-                newStandbyTasksPendingRevocation,
-                newWarmupTasksPendingRevocation
-            ),
+            newTasksPendingRevocation,
             new TasksTupleWithEpochs(
                 newActiveAssignedTasks,
                 newStandbyAssignedTasks,
@@ -485,6 +496,87 @@ public class CurrentAssignmentBuilder {
         );
     }
 
+    /**
+     * Applies warm-up promotions to the freshly computed reconciliation 
result. A task the member holds as a
+     * warm-up that the target wants it to own as an active task is promoted 
in place rather than
+     * revoked-then-reassigned, so its recyclable state store survives instead 
of being closed and restored from
+     * the changelog (cf. KAFKA-9501). Such a warm-up is never routed through 
the (ack-based) revocation path.
+     *
+     * @return the member's pending revocation after the promoted warm-ups 
have been removed from it.
+     */
+    private TasksTupleWithEpochs applyWarmupPromotions(TasksTupleWithEpochs 
memberAssignedTasks,
+                                                       Map<String, 
Map<Integer, Integer>> newActiveTasksPendingRevocation,
+                                                       Map<String, 
Set<Integer>> newStandbyTasksPendingRevocation,
+                                                       Map<String, 
Set<Integer>> newWarmupTasksPendingRevocation,
+                                                       Map<String, 
Map<Integer, Integer>> newActiveTasksPendingAssignment,
+                                                       Map<String, 
Set<Integer>> newWarmupAssignedTasks) {
+        // If we promote a warm-up to active, the warm-up does not need an 
explicit client-side revocation.
+        // Thus, the warm-up can be removed from 
`newWarmupTasksPendingRevocation` -- we don't expect a client ack back.
+        // We need to remove it regardless of wether the warm-up to active 
promotion happens now, or is still pending on
+        // the active task revocation.
+        for (Map.Entry<String, Set<Integer>> warmup : 
memberAssignedTasks.warmupTasks().entrySet()) {
+            String subtopologyId = warmup.getKey();
+            Set<Integer> targetActiveTasks = 
targetAssignment.activeTasks().getOrDefault(subtopologyId, Set.of());
+            for (Integer partitionId : warmup.getValue()) {
+                if (targetActiveTasks.contains(partitionId)) {
+                    removeFromTaskSet(newWarmupTasksPendingRevocation, 
subtopologyId, partitionId);
+                }
+            }
+        }
+
+        TasksTupleWithEpochs newTasksPendingRevocation = new 
TasksTupleWithEpochs(
+            newActiveTasksPendingRevocation,
+            newStandbyTasksPendingRevocation,
+            newWarmupTasksPendingRevocation
+        );
+        boolean hasTasksToBeRevoked = !newTasksPendingRevocation.isEmpty()
+            && hasNotReleased(newTasksPendingRevocation);
+
+        // keep warm-up task and don't revoke if we are not ready for warm-up 
to active promotion
+        //  - this member still needs to complete its own revocation of other 
tasks
+        //    (which must complete before any assignment can happen)
+        //  - the active task was not released by its previous owner yet
+        for (Map.Entry<String, Set<Integer>> warmup : 
memberAssignedTasks.warmupTasks().entrySet()) {
+            String subtopologyId = warmup.getKey();
+            Set<Integer> targetActiveTasks = 
targetAssignment.activeTasks().getOrDefault(subtopologyId, Set.of());
+            Map<Integer, Integer> grantedActiveTasks = 
newActiveTasksPendingAssignment.getOrDefault(subtopologyId, Map.of());
+            for (Integer partitionId : warmup.getValue()) {
+                boolean promotedThisStep = !hasTasksToBeRevoked && 
grantedActiveTasks.containsKey(partitionId);
+                if (targetActiveTasks.contains(partitionId) && 
!promotedThisStep) {
+                    newWarmupAssignedTasks.computeIfAbsent(subtopologyId, __ 
-> new HashSet<>()).add(partitionId);
+                }
+            }
+        }
+
+        return newTasksPendingRevocation;
+    }
+
+    /**
+     * Removes a single subtopology/partition from a task set, dropping the 
subtopology entry entirely if it
+     * becomes empty (so the resulting map matches what the difference helpers 
would have produced).
+     */
+    private static void removeFromTaskSet(Map<String, Set<Integer>> tasks, 
String subtopologyId, Integer partitionId) {
+        Set<Integer> partitions = tasks.get(subtopologyId);
+        if (partitions != null && partitions.remove(partitionId) && 
partitions.isEmpty()) {
+            tasks.remove(subtopologyId);
+        }
+    }
+
+    /**
+     * Checks whether the member has not yet confirmed the release of the 
given tasks pending revocation.
+     * Revocation is ack-based: it is confirmed only once the member reports 
its currently owned tasks in a
+     * heartbeat and none of the given tasks appear among them.
+     *
+     * @param tasksPendingRevocation The tasks whose revocation we are waiting 
for.
+     * @return true if the release of any of those tasks is not yet confirmed.
+     */
+    private boolean hasNotReleased(TasksTupleWithEpochs 
tasksPendingRevocation) {
+        // Note that {@code ownedTasks} being empty means the member did not 
report its owned tasks in this
+        // heartbeat -- it is an {@link Optional}, not an empty task set. 
Without such a report we cannot prove
+        // the tasks were released, so we conservatively treat them as still 
held.
+        return ownedTasks.isEmpty() || 
ownedTasks.get().containsAny(tasksPendingRevocation);
+    }
+
     private StreamsGroupMember buildNewMember(final int memberEpoch,
                                               final TasksTupleWithEpochs 
newTasksPendingRevocation,
                                               final TasksTupleWithEpochs 
newAssignedTasks,
@@ -492,8 +584,8 @@ public class CurrentAssignmentBuilder {
                                               final boolean 
hasUnreleasedTasks) {
 
         final boolean hasTasksToBeRevoked =
-            (!newTasksPendingRevocation.isEmpty())
-                && (ownedTasks.isEmpty() || 
ownedTasks.get().containsAny(newTasksPendingRevocation));
+            !newTasksPendingRevocation.isEmpty()
+                && hasNotReleased(newTasksPendingRevocation);
 
         if (hasTasksToBeRevoked) {
             // If there are tasks to be revoked, the member remains in its 
current
diff --git 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/CurrentAssignmentBuilderTest.java
 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/CurrentAssignmentBuilderTest.java
index b383a35f786..3473d90f4ac 100644
--- 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/CurrentAssignmentBuilderTest.java
+++ 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/CurrentAssignmentBuilderTest.java
@@ -974,4 +974,203 @@ public class CurrentAssignmentBuilderTest {
             updatedMember
         );
     }
+
+    @Test
+    public void 
testWarmupPromotionHoldsWarmupWhileActiveOwnedByAnotherMember() {
+        final int memberEpoch = 10;
+        final String otherProcessId = "other_process_id";
+
+        StreamsGroupMember member = new StreamsGroupMember.Builder(MEMBER_NAME)
+            .setState(MemberState.STABLE)
+            .setProcessId(PROCESS_ID)
+            .setMemberEpoch(memberEpoch)
+            .setPreviousMemberEpoch(memberEpoch)
+            .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.WARMUP, 
memberEpoch,
+                mkTasks(SUBTOPOLOGY_ID1, 0)))
+            .setTasksPendingRevocation(TasksTupleWithEpochs.EMPTY)
+            .build();
+
+        // The target wants this member to own the task as an active task, but 
another member still owns it
+        // active. The member keeps the warm-up (so it stays caught up) and 
waits in UNRELEASED_TASKS -- it is
+        // not asked to revoke the warm-up.
+        StreamsGroupMember updatedMember = new CurrentAssignmentBuilder(member)
+            .withTargetAssignment(memberEpoch + 1, 
mkTasksTuple(TaskRole.ACTIVE,
+                mkTasks(SUBTOPOLOGY_ID1, 0)))
+            .withCurrentActiveTaskProcessId((subtopologyId, partitionId) -> 
otherProcessId)
+            .withCurrentStandbyTaskProcessIds((subtopologyId, partitionId) -> 
Set.of())
+            .withCurrentWarmupTaskProcessIds((subtopologyId, partitionId) -> 
Set.of(PROCESS_ID))
+            .build();
+
+        assertEquals(
+            new StreamsGroupMember.Builder(MEMBER_NAME)
+                .setState(MemberState.UNRELEASED_TASKS)
+                .setProcessId(PROCESS_ID)
+                .setMemberEpoch(memberEpoch + 1)
+                .setPreviousMemberEpoch(memberEpoch)
+                .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.WARMUP, 
memberEpoch,
+                    mkTasks(SUBTOPOLOGY_ID1, 0)))
+                .setTasksPendingRevocation(TasksTupleWithEpochs.EMPTY)
+                .build(),
+            updatedMember
+        );
+    }
+
+    @Test
+    public void testWarmupPromotedToActiveWhenActiveIsReleased() {
+        final int memberEpoch = 10;
+
+        StreamsGroupMember member = new StreamsGroupMember.Builder(MEMBER_NAME)
+            .setState(MemberState.STABLE)
+            .setProcessId(PROCESS_ID)
+            .setMemberEpoch(memberEpoch)
+            .setPreviousMemberEpoch(memberEpoch)
+            .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.WARMUP, 
memberEpoch,
+                mkTasks(SUBTOPOLOGY_ID1, 0)))
+            .setTasksPendingRevocation(TasksTupleWithEpochs.EMPTY)
+            .build();
+
+        // The target wants this member to own the task active and no other 
member owns it active any more, so
+        // the warm-up is promoted in place: the active is granted and the 
warm-up is dropped in one step
+        // (no revoke-then-reassign round trip).
+        StreamsGroupMember updatedMember = new CurrentAssignmentBuilder(member)
+            .withTargetAssignment(memberEpoch + 1, 
mkTasksTuple(TaskRole.ACTIVE,
+                mkTasks(SUBTOPOLOGY_ID1, 0)))
+            .withCurrentActiveTaskProcessId((subtopologyId, partitionId) -> 
null)
+            .withCurrentStandbyTaskProcessIds((subtopologyId, partitionId) -> 
Set.of())
+            .withCurrentWarmupTaskProcessIds((subtopologyId, partitionId) -> 
Set.of(PROCESS_ID))
+            .build();
+
+        assertEquals(
+            new StreamsGroupMember.Builder(MEMBER_NAME)
+                .setState(MemberState.STABLE)
+                .setProcessId(PROCESS_ID)
+                .setMemberEpoch(memberEpoch + 1)
+                .setPreviousMemberEpoch(memberEpoch)
+                .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 
memberEpoch + 1,
+                    mkTasks(SUBTOPOLOGY_ID1, 0)))
+                .setTasksPendingRevocation(TasksTupleWithEpochs.EMPTY)
+                .build(),
+            updatedMember
+        );
+    }
+
+    @Test
+    public void testWarmupPromotedToActiveFromUnreleasedState() {
+        final int memberEpoch = 11;
+
+        // The member already advanced to the target epoch holding the 
warm-up, waiting for the previous owner
+        // to release the active task.
+        StreamsGroupMember member = new StreamsGroupMember.Builder(MEMBER_NAME)
+            .setState(MemberState.UNRELEASED_TASKS)
+            .setProcessId(PROCESS_ID)
+            .setMemberEpoch(memberEpoch)
+            .setPreviousMemberEpoch(memberEpoch - 1)
+            .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.WARMUP, 
memberEpoch,
+                mkTasks(SUBTOPOLOGY_ID1, 0)))
+            .setTasksPendingRevocation(TasksTupleWithEpochs.EMPTY)
+            .build();
+
+        StreamsGroupMember updatedMember = new CurrentAssignmentBuilder(member)
+            .withTargetAssignment(memberEpoch, mkTasksTuple(TaskRole.ACTIVE,
+                mkTasks(SUBTOPOLOGY_ID1, 0)))
+            .withCurrentActiveTaskProcessId((subtopologyId, partitionId) -> 
null)
+            .withCurrentStandbyTaskProcessIds((subtopologyId, partitionId) -> 
Set.of())
+            .withCurrentWarmupTaskProcessIds((subtopologyId, partitionId) -> 
Set.of(PROCESS_ID))
+            .build();
+
+        assertEquals(
+            new StreamsGroupMember.Builder(MEMBER_NAME)
+                .setState(MemberState.STABLE)
+                .setProcessId(PROCESS_ID)
+                .setMemberEpoch(memberEpoch)
+                .setPreviousMemberEpoch(memberEpoch)
+                .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 
memberEpoch,
+                    mkTasks(SUBTOPOLOGY_ID1, 0)))
+                .setTasksPendingRevocation(TasksTupleWithEpochs.EMPTY)
+                .build(),
+            updatedMember
+        );
+    }
+
+    @Test
+    public void 
testActiveNotGrantedWhileAnotherMemberOnSameProcessHoldsWarmup() {
+        final int memberEpoch = 10;
+
+        // This member does not hold the warm-up itself; a *different* member 
on the same process does (reflected
+        // by currentWarmupTaskProcessIds returning this member's process). 
The active must not be granted -- the
+        // process would otherwise run the task as both active (this member) 
and warm-up (the sibling). Only the
+        // member that itself holds the warm-up is promoted; this one waits.
+        StreamsGroupMember member = new StreamsGroupMember.Builder(MEMBER_NAME)
+            .setState(MemberState.STABLE)
+            .setProcessId(PROCESS_ID)
+            .setMemberEpoch(memberEpoch)
+            .setPreviousMemberEpoch(memberEpoch)
+            .setAssignedTasks(TasksTupleWithEpochs.EMPTY)
+            .setTasksPendingRevocation(TasksTupleWithEpochs.EMPTY)
+            .build();
+
+        StreamsGroupMember updatedMember = new CurrentAssignmentBuilder(member)
+            .withTargetAssignment(memberEpoch + 1, 
mkTasksTuple(TaskRole.ACTIVE,
+                mkTasks(SUBTOPOLOGY_ID1, 0)))
+            .withCurrentActiveTaskProcessId((subtopologyId, partitionId) -> 
null)
+            .withCurrentStandbyTaskProcessIds((subtopologyId, partitionId) -> 
Set.of())
+            .withCurrentWarmupTaskProcessIds((subtopologyId, partitionId) -> 
Set.of(PROCESS_ID))
+            .build();
+
+        assertEquals(
+            new StreamsGroupMember.Builder(MEMBER_NAME)
+                .setState(MemberState.UNRELEASED_TASKS)
+                .setProcessId(PROCESS_ID)
+                .setMemberEpoch(memberEpoch + 1)
+                .setPreviousMemberEpoch(memberEpoch)
+                .setAssignedTasks(TasksTupleWithEpochs.EMPTY)
+                .setTasksPendingRevocation(TasksTupleWithEpochs.EMPTY)
+                .build(),
+            updatedMember
+        );
+    }
+
+    @Test
+    public void 
testWarmupNotDroppedWhilePromotionIsBlockedByAnotherRevocation() {
+        final int memberEpoch = 10;
+
+        // The member holds a warm-up for one task (which the target wants it 
to own active, and that active is
+        // free -> promotable) AND an active task the target no longer wants 
(-> pending revocation). Because any
+        // pending revocation keeps the member in UNREVOKED_TASKS, the 
promotion cannot be granted this step; the
+        // warm-up must be KEPT (its recyclable state preserved), not dropped 
in anticipation of a grant that does
+        // not happen.
+        StreamsGroupMember member = new StreamsGroupMember.Builder(MEMBER_NAME)
+            .setState(MemberState.STABLE)
+            .setProcessId(PROCESS_ID)
+            .setMemberEpoch(memberEpoch)
+            .setPreviousMemberEpoch(memberEpoch)
+            .setAssignedTasks(new TasksTupleWithEpochs(
+                Map.of(SUBTOPOLOGY_ID1, Map.of(1, memberEpoch)),   // active 
task the target no longer wants
+                Map.of(),
+                Map.of(SUBTOPOLOGY_ID1, Set.of(0))))               // warm-up 
the target wants promoted to active
+            .setTasksPendingRevocation(TasksTupleWithEpochs.EMPTY)
+            .build();
+
+        StreamsGroupMember updatedMember = new CurrentAssignmentBuilder(member)
+            .withTargetAssignment(memberEpoch + 1, 
mkTasksTuple(TaskRole.ACTIVE,
+                mkTasks(SUBTOPOLOGY_ID1, 0)))
+            .withCurrentActiveTaskProcessId((subtopologyId, partitionId) -> 
partitionId == 1 ? PROCESS_ID : null)
+            .withCurrentStandbyTaskProcessIds((subtopologyId, partitionId) -> 
Set.of())
+            .withCurrentWarmupTaskProcessIds((subtopologyId, partitionId) -> 
partitionId == 0 ? Set.of(PROCESS_ID) : Set.of())
+            .build();
+
+        assertEquals(
+            new StreamsGroupMember.Builder(MEMBER_NAME)
+                .setState(MemberState.UNREVOKED_TASKS)
+                .setProcessId(PROCESS_ID)
+                .setMemberEpoch(memberEpoch)
+                .setPreviousMemberEpoch(memberEpoch)
+                .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.WARMUP, 
memberEpoch,
+                    mkTasks(SUBTOPOLOGY_ID1, 0)))                    // 
warm-up kept, not dropped
+                
.setTasksPendingRevocation(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 
memberEpoch,
+                    mkTasks(SUBTOPOLOGY_ID1, 1)))
+                .build(),
+            updatedMember
+        );
+    }
 }

Reply via email to