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 07e1f099aca KAFKA-20665: Change task roles in place in the reconciler 
(#22981)
07e1f099aca is described below

commit 07e1f099acad26200b2681411e05616bd3fe7879
Author: Matthias J. Sax <[email protected]>
AuthorDate: Thu Jul 30 07:28:33 2026 -0700

    KAFKA-20665: Change task roles in place in the reconciler (#22981)
    
    Follow up to https://github.com/apache/kafka/pull/22748
    
    Extends the in-place warm-up promotion to every role change a member can
    make for a task it already holds, so that the client recycles the task
    instead of closing and re-creating it (cf. KAFKA-9501):
    
    - standby -> active and warm-up -> active (promotion): the member
    keeps the standby or warm-up task until the active task is actually
    granted, because that grant waits for the previous owner to release it.
    
    - active -> standby (demotion): the standby task is granted in the same
    step in which the active task is revoked. The active task still needs an
    acknowledgement, so that the group observes its release before the task
    is granted to its next owner.
    
    - standby <-> warm-up (conversion): both roles run the same code
    on the client, so the roles are swapped in a single step and no
    revocation acknowledgement is needed at all.
    
    Each of the three "unreleased task" predicates blocks a grant while the
    member's *process* holds the task in another role, which is what keeps a
    process from running the same task twice. These checks are now
    member-precise: a task the member holds itself is a candidate for a role
    change rather than a blocker, while a task held by a sibling member on
    the same process still blocks.
    
    Reviewers: Sean Quah <[email protected]>
---
 .../group/streams/CurrentAssignmentBuilder.java    | 311 ++++++++++++++----
 .../streams/CurrentAssignmentBuilderTest.java      | 352 +++++++++++++++++++++
 .../integration/RestoreIntegrationTest.java        |   9 +-
 3 files changed, 609 insertions(+), 63 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 8544bed067b..0b256a82089 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
@@ -424,20 +424,7 @@ 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 ||
-                    // 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))
+            (subtopologyId, partitionId) -> 
isUnreleasedActiveTask(memberAssignedTasks, subtopologyId, partitionId)
         );
 
         boolean hasUnreleasedStandbyTasks = computeAssignmentDifference(
@@ -446,13 +433,7 @@ public class CurrentAssignmentBuilder {
             newStandbyAssignedTasks,
             newStandbyTasksPendingRevocation,
             newStandbyTasksPendingAssignment,
-            (subtopologyId, partitionId) ->
-                Objects.equals(currentActiveTaskProcessId.apply(subtopologyId, 
partitionId),
-                    member.processId()) ||
-                    currentStandbyTaskProcessIds.apply(subtopologyId, 
partitionId)
-                        .contains(member.processId()) ||
-                    currentWarmupTaskProcessIds.apply(subtopologyId, 
partitionId)
-                        .contains(member.processId())
+            (subtopologyId, partitionId) -> 
isUnreleasedStandbyTask(memberAssignedTasks, subtopologyId, partitionId)
         );
 
         boolean hasUnreleasedWarmupTasks = computeAssignmentDifference(
@@ -461,22 +442,45 @@ public class CurrentAssignmentBuilder {
             newWarmupAssignedTasks,
             newWarmupTasksPendingRevocation,
             newWarmupTasksPendingAssignment,
-            (subtopologyId, partitionId) ->
-                Objects.equals(currentActiveTaskProcessId.apply(subtopologyId, 
partitionId),
-                    member.processId()) ||
-                    currentStandbyTaskProcessIds.apply(subtopologyId, 
partitionId)
-                        .contains(member.processId()) ||
-                    currentWarmupTaskProcessIds.apply(subtopologyId, 
partitionId)
-                        .contains(member.processId())
+            (subtopologyId, partitionId) -> 
isUnreleasedWarmupTask(memberAssignedTasks, subtopologyId, partitionId)
         );
 
-        TasksTupleWithEpochs newTasksPendingRevocation = applyWarmupPromotions(
-            memberAssignedTasks,
-            newActiveTasksPendingRevocation,
+        // A role change that does not depend on any other member takes effect 
right away: the target role is moved
+        // from the pending assignment to the assigned tasks, so that the 
member is told about both halves of the
+        // change in the same heartbeat and the client recycles the task 
instead of closing and re-creating it.
+        // A demoted active task still stays in the revocation set, because 
the group must observe its release
+        // before the task can be granted to its next owner.
+
+        // The three calls below modify the maps passed to them in place, so 
they have to run before the new member is built.
+
+        // revoked active tasks are already reflected in 
`newActiveTasksPendingRevocation`;
+        // we only need to take care of standby task part
+        demoteActiveTasksToStandby(memberAssignedTasks, 
newStandbyTasksPendingAssignment, newStandbyAssignedTasks);
+        // convert standby tasks to warmup task
+        convertReplicaRole(
+            memberAssignedTasks.standbyTasks(),
             newStandbyTasksPendingRevocation,
+            newWarmupTasksPendingAssignment,
+            newWarmupAssignedTasks
+        );
+        // convert warmup tasks to standby tasks
+        convertReplicaRole(
+            memberAssignedTasks.warmupTasks(),
             newWarmupTasksPendingRevocation,
+            newStandbyTasksPendingAssignment,
+            newStandbyAssignedTasks
+        );
+
+        // A promotion to active, in contrast, has to wait for the previous 
owner to release the active task, so the
+        // member keeps its standby or warm-up task until the active task is 
actually granted.
+        TasksTupleWithEpochs newTasksPendingRevocation = 
applyPromotionsToActive(
+            memberAssignedTasks,
+            newActiveTasksPendingRevocation,
+            newStandbyTasksPendingRevocation, // modified in-place by 
applyPromotionsToActive
+            newWarmupTasksPendingRevocation, // modified in-place by 
applyPromotionsToActive
             newActiveTasksPendingAssignment,
-            newWarmupAssignedTasks // modified in-place by 
applyWarmupPromotions
+            newStandbyAssignedTasks, // modified in-place by 
applyPromotionsToActive
+            newWarmupAssignedTasks // modified in-place by 
applyPromotionsToActive
         );
 
         return buildNewMember(
@@ -497,32 +501,162 @@ 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.
+     * An active task can only be granted once its previous owner anywhere in 
the group has released it -- a task has
+     * exactly one active owner. It can also not be granted to a _process_ 
that still holds the task as a standby or
+     * warm-up, because a single process must never run the same task twice. 
The exception is an in-place promotion: if
+     * THIS MEMBER holds the standby or warm-up task, the active task 
supersedes it in a single step, so that the client
+     * recycles the task.
+     */
+    private boolean isUnreleasedActiveTask(TasksTupleWithEpochs 
memberAssignedTasks,
+                                           String subtopologyId,
+                                           Integer partitionId) {
+        return currentActiveTaskProcessId.apply(subtopologyId, partitionId) != 
null
+            || heldByAnotherMemberOnThisProcess(currentStandbyTaskProcessIds, 
memberAssignedTasks.standbyTasks(), subtopologyId, partitionId)
+            || heldByAnotherMemberOnThisProcess(currentWarmupTaskProcessIds, 
memberAssignedTasks.warmupTasks(), subtopologyId, partitionId);
+    }
+
+    /**
+     * A standby task can only be granted once the previous holder WITHIN THE 
SAME PROCESS has released it -- unlike an
+     * active task, the same standby task also exists on other processes, and 
those do not block. It can also not be
+     * granted to a process that holds the task as an active or warm-up task, 
because a single process must never run
+     * the same task twice. The exceptions are in-place role changes of THIS 
MEMBER's own task: demoting its active task
+     * to a standby task, or converting its warm-up task into a standby task.
+     */
+    private boolean isUnreleasedStandbyTask(TasksTupleWithEpochs 
memberAssignedTasks,
+                                            String subtopologyId,
+                                            Integer partitionId) {
+        return runByAnotherMemberOnThisProcess(memberAssignedTasks, 
subtopologyId, partitionId)
+            || currentStandbyTaskProcessIds.apply(subtopologyId, 
partitionId).contains(member.processId())
+            || heldByAnotherMemberOnThisProcess(currentWarmupTaskProcessIds, 
memberAssignedTasks.warmupTasks(), subtopologyId, partitionId);
+    }
+
+    /**
+     * A warm-up task can only be granted once the previous holder WITHIN THE 
SAME PROCESS has released it -- as for
+     * standby tasks, holders on other processes do not block. It can also not 
be granted to a process that holds the
+     * task as an active or standby task, because a single process must never 
run the same task twice. The exception is
+     * an in-place conversion of THIS MEMBER's own standby task into a warm-up 
task.
+     */
+    private boolean isUnreleasedWarmupTask(TasksTupleWithEpochs 
memberAssignedTasks,
+                                           String subtopologyId,
+                                           Integer partitionId) {
+        return Objects.equals(currentActiveTaskProcessId.apply(subtopologyId, 
partitionId), member.processId())
+            || heldByAnotherMemberOnThisProcess(currentStandbyTaskProcessIds, 
memberAssignedTasks.standbyTasks(), subtopologyId, partitionId)
+            || currentWarmupTaskProcessIds.apply(subtopologyId, 
partitionId).contains(member.processId());
+    }
+
+    /**
+     * Checks whether the task is run as an active task on this member's 
process by a member other than this one. The
+     * member's own active task is a candidate for an in-place role change 
rather than a blocker.
+     */
+    private boolean runByAnotherMemberOnThisProcess(TasksTupleWithEpochs 
memberAssignedTasks,
+                                                    String subtopologyId,
+                                                    Integer partitionId) {
+        return Objects.equals(currentActiveTaskProcessId.apply(subtopologyId, 
partitionId), member.processId())
+            && 
!memberAssignedTasks.activeTasksWithEpochs().getOrDefault(subtopologyId, 
Map.of()).containsKey(partitionId);
+    }
+
+    /**
+     * Checks whether the task is held in the given replica role on this 
member's process by a member other than this
+     * one. A task the member holds itself is a candidate for an in-place role 
change rather than a blocker.
      *
-     * @return the member's pending revocation after the promoted warm-ups 
have been removed from it.
+     * @param currentProcessIds The process IDs currently holding the task in 
that role.
+     * @param memberTasks       The tasks this member holds in that role.
      */
-    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);
+    private boolean heldByAnotherMemberOnThisProcess(BiFunction<String, 
Integer, Set<String>> currentProcessIds,
+                                                     Map<String, Set<Integer>> 
memberTasks,
+                                                     String subtopologyId,
+                                                     Integer partitionId) {
+        return currentProcessIds.apply(subtopologyId, 
partitionId).contains(member.processId())
+            && !memberTasks.getOrDefault(subtopologyId, 
Set.of()).contains(partitionId);
+    }
+
+    /**
+     * Demotes the member's active tasks to standby tasks in place. The active 
task remains pending revocation -- the
+     * group must observe its release before its next owner can run it -- but 
the standby task is granted right away,
+     * so the client recycles the task's state store instead of closing it and 
restoring a new standby task from the
+     * changelog (cf. KAFKA-9501).
+     *
+     * @param memberAssignedTasks               The tasks this member 
currently holds.
+     * @param newStandbyTasksPendingAssignment  Modified in place: the demoted 
task is removed, as the standby is granted
+     *                                          in this step rather than left 
pending.
+     * @param newStandbyAssignedTasks           Modified in place: the demoted 
task is added as an assigned standby.
+     */
+    private static void demoteActiveTasksToStandby(TasksTupleWithEpochs 
memberAssignedTasks,
+                                                   Map<String, Set<Integer>> 
newStandbyTasksPendingAssignment,
+                                                   Map<String, Set<Integer>> 
newStandbyAssignedTasks) {
+        for (Map.Entry<String, Map<Integer, Integer>> activeTasks : 
memberAssignedTasks.activeTasksWithEpochs().entrySet()) {
+            String subtopologyId = activeTasks.getKey();
+            for (Integer partitionId : activeTasks.getValue().keySet()) {
+                if (isPendingAssignment(newStandbyTasksPendingAssignment, 
subtopologyId, partitionId)) {
+                    grantNow(newStandbyTasksPendingAssignment, 
newStandbyAssignedTasks, subtopologyId, partitionId);
                 }
             }
         }
+    }
+
+    /**
+     * Converts one of the member's replica roles into the other in place, 
i.e. a standby task into a warm-up task or
+     * vice versa. Both roles run the same code on the client, so the 
conversion needs no client-side action at all
+     * and hence no revocation ack either: the outgoing role is dropped in the 
same step in which the incoming role is
+     * granted. This keeps the task on the member throughout, which is what 
the process-level exclusivity checks of
+     * the other members rely on.
+     *
+     * @param memberOutgoingTasks                The tasks this member holds 
in the role it gives up.
+     * @param newOutgoingTasksPendingRevocation  Modified in place: the 
converted task is removed, since the conversion
+     *                                           needs no client-side 
revocation ack.
+     * @param newIncomingTasksPendingAssignment  Modified in place: the 
converted task is removed, as it is granted in
+     *                                           this step rather than left 
pending.
+     * @param newIncomingAssignedTasks           Modified in place: the 
converted task is added, granting the new role.
+     */
+    private static void convertReplicaRole(Map<String, Set<Integer>> 
memberOutgoingTasks,
+                                           Map<String, Set<Integer>> 
newOutgoingTasksPendingRevocation,
+                                           Map<String, Set<Integer>> 
newIncomingTasksPendingAssignment,
+                                           Map<String, Set<Integer>> 
newIncomingAssignedTasks) {
+        for (Map.Entry<String, Set<Integer>> outgoingTasks : 
memberOutgoingTasks.entrySet()) {
+            String subtopologyId = outgoingTasks.getKey();
+            for (Integer partitionId : outgoingTasks.getValue()) {
+                if (isPendingAssignment(newIncomingTasksPendingAssignment, 
subtopologyId, partitionId)) {
+                    grantNow(newIncomingTasksPendingAssignment, 
newIncomingAssignedTasks, subtopologyId, partitionId);
+                    removeFromTaskSet(newOutgoingTasksPendingRevocation, 
subtopologyId, partitionId);
+                }
+            }
+        }
+    }
+
+    /**
+     * Applies promotions to active to the freshly computed reconciliation 
result. A task the member holds as a standby
+     * or 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 standby or warm-up is never 
routed through the (ack-based) revocation path.
+     * <p>
+     * Must be called before the caller builds the member's new assigned tasks 
and pending revocation, because four of
+     * the maps below are modified in place here rather than being returned.
+     *
+     * @param memberAssignedTasks              The tasks this member currently 
holds; the standby and warm-up sets are
+     *                                         the promotion candidates.
+     * @param newActiveTasksPendingRevocation  Not modified; passed through 
into the returned pending revocation.
+     * @param newStandbyTasksPendingRevocation Modified in place: promoted 
standby tasks are removed, since a promotion
+     *                                         needs no client-side revocation 
ack.
+     * @param newWarmupTasksPendingRevocation  Modified in place: as above, 
for warm-up tasks.
+     * @param newActiveTasksPendingAssignment  Not modified; consulted to see 
whether the active task is granted in
+     *                                         this step, which decides 
whether the standby or warm-up is kept.
+     * @param newStandbyAssignedTasks          Modified in place: a standby 
whose promotion is not granted in this step
+     *                                         is added back, so the member 
keeps it for now.
+     * @param newWarmupAssignedTasks           Modified in place: as above, 
for warm-up tasks.
+     * @return the member's pending revocation after the promoted standby and 
warm-up tasks have been removed from it.
+     */
+    private TasksTupleWithEpochs applyPromotionsToActive(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>> newStandbyAssignedTasks,
+                                                         Map<String, 
Set<Integer>> newWarmupAssignedTasks) {
+        // If we promote a standby or warm-up to active, that task does not 
need an explicit client-side revocation.
+        // Thus, it can be removed from the revocation set -- we don't expect 
a client ack back. We need to remove it
+        // regardless of whether the promotion happens now, or is still 
pending on the active task revocation.
+        dropPromotedTasksFromRevocation(memberAssignedTasks.standbyTasks(), 
newStandbyTasksPendingRevocation);
+        dropPromotedTasksFromRevocation(memberAssignedTasks.warmupTasks(), 
newWarmupTasksPendingRevocation);
 
         TasksTupleWithEpochs newTasksPendingRevocation = new 
TasksTupleWithEpochs(
             newActiveTasksPendingRevocation,
@@ -532,23 +666,82 @@ public class CurrentAssignmentBuilder {
         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
+        // keep the standby or warm-up task and don't revoke it if we are not 
ready for the promotion to active
         //  - 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();
+
+        // keep standby tasks on this member and wait on active to be 
released, for later in-place promotion
+        keepPromotedTasksUntilGranted(
+            memberAssignedTasks.standbyTasks(),
+            newStandbyAssignedTasks,
+            newActiveTasksPendingAssignment,
+            hasTasksToBeRevoked
+        );
+        // keep warm-up tasks on this member and wait on active to be 
released, for later in-place promotion
+        keepPromotedTasksUntilGranted(
+            memberAssignedTasks.warmupTasks(),
+            newWarmupAssignedTasks,
+            newActiveTasksPendingAssignment,
+            hasTasksToBeRevoked
+        );
+
+        return newTasksPendingRevocation;
+    }
+
+    /**
+     * Takes the tasks that are being promoted to active out of the revocation 
set of the role they are promoted from.
+     */
+    private void dropPromotedTasksFromRevocation(Map<String, Set<Integer>> 
memberTasks,
+                                                 Map<String, Set<Integer>> 
newTasksPendingRevocation) {
+        for (Map.Entry<String, Set<Integer>> standbyOrWarmup : 
memberTasks.entrySet()) {
+            String subtopologyId = standbyOrWarmup.getKey();
+            Set<Integer> targetActiveTasks = 
targetAssignment.activeTasks().getOrDefault(subtopologyId, Set.of());
+            for (Integer partitionId : standbyOrWarmup.getValue()) {
+                if (targetActiveTasks.contains(partitionId)) {
+                    removeFromTaskSet(newTasksPendingRevocation, 
subtopologyId, partitionId);
+                }
+            }
+        }
+    }
+
+    /**
+     * Keeps the tasks that are being promoted to active assigned in the role 
they are promoted from, for as long as
+     * the active task is not granted in this step.
+     */
+    private void keepPromotedTasksUntilGranted(Map<String, Set<Integer>> 
memberTasks,
+                                               Map<String, Set<Integer>> 
newAssignedTasks,
+                                               Map<String, Map<Integer, 
Integer>> newActiveTasksPendingAssignment,
+                                               boolean hasTasksToBeRevoked) {
+        for (Map.Entry<String, Set<Integer>> standbyOrWarmup : 
memberTasks.entrySet()) {
+            String subtopologyId = standbyOrWarmup.getKey();
             Set<Integer> targetActiveTasks = 
targetAssignment.activeTasks().getOrDefault(subtopologyId, Set.of());
             Map<Integer, Integer> grantedActiveTasks = 
newActiveTasksPendingAssignment.getOrDefault(subtopologyId, Map.of());
-            for (Integer partitionId : warmup.getValue()) {
+            for (Integer partitionId : standbyOrWarmup.getValue()) {
                 boolean promotedThisStep = !hasTasksToBeRevoked && 
grantedActiveTasks.containsKey(partitionId);
                 if (targetActiveTasks.contains(partitionId) && 
!promotedThisStep) {
-                    newWarmupAssignedTasks.computeIfAbsent(subtopologyId, __ 
-> new HashSet<>()).add(partitionId);
+                    newAssignedTasks.computeIfAbsent(subtopologyId, __ -> new 
HashSet<>()).add(partitionId);
                 }
             }
         }
+    }
 
-        return newTasksPendingRevocation;
+    private static boolean isPendingAssignment(Map<String, Set<Integer>> 
tasksPendingAssignment,
+                                               String subtopologyId,
+                                               Integer partitionId) {
+        return tasksPendingAssignment.getOrDefault(subtopologyId, 
Set.of()).contains(partitionId);
+    }
+
+    /**
+     * Grants a task in this step instead of leaving it pending, by moving it 
from the pending assignment to the
+     * assigned tasks.
+     */
+    private static void grantNow(Map<String, Set<Integer>> 
tasksPendingAssignment,
+                                 Map<String, Set<Integer>> assignedTasks,
+                                 String subtopologyId,
+                                 Integer partitionId) {
+        removeFromTaskSet(tasksPendingAssignment, subtopologyId, partitionId);
+        assignedTasks.computeIfAbsent(subtopologyId, __ -> new 
HashSet<>()).add(partitionId);
     }
 
     /**
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 3473d90f4ac..eb0a84833e9 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
@@ -1173,4 +1173,356 @@ public class CurrentAssignmentBuilderTest {
             updatedMember
         );
     }
+
+    @Test
+    public void testStandbyPromotedToActiveWhenActiveIsReleased() {
+        final int memberEpoch = 10;
+
+        StreamsGroupMember member = new StreamsGroupMember.Builder(MEMBER_NAME)
+            .setState(MemberState.STABLE)
+            .setProcessId(PROCESS_ID)
+            .setMemberEpoch(memberEpoch)
+            .setPreviousMemberEpoch(memberEpoch)
+            .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.STANDBY, 
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
+        // standby is promoted in place: the active is granted and the standby 
is dropped in one step, which lets the
+        // client recycle the task instead of closing it and restoring it 
again as an active task.
+        StreamsGroupMember updatedMember = new CurrentAssignmentBuilder(member)
+            .withTargetAssignment(memberEpoch + 1, 
mkTasksTuple(TaskRole.ACTIVE,
+                mkTasks(SUBTOPOLOGY_ID1, 0)))
+            .withCurrentActiveTaskProcessId((subtopologyId, partitionId) -> 
null)
+            .withCurrentStandbyTaskProcessIds((subtopologyId, partitionId) -> 
Set.of(PROCESS_ID))
+            .withCurrentWarmupTaskProcessIds((subtopologyId, partitionId) -> 
Set.of())
+            .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 
testStandbyPromotionHoldsStandbyWhileActiveOwnedByAnotherMember() {
+        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.STANDBY, 
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 standby (so it stays caught up) and waits in 
UNRELEASED_TASKS -- it is not asked to
+        // revoke the standby.
+        StreamsGroupMember updatedMember = new CurrentAssignmentBuilder(member)
+            .withTargetAssignment(memberEpoch + 1, 
mkTasksTuple(TaskRole.ACTIVE,
+                mkTasks(SUBTOPOLOGY_ID1, 0)))
+            .withCurrentActiveTaskProcessId((subtopologyId, partitionId) -> 
otherProcessId)
+            .withCurrentStandbyTaskProcessIds((subtopologyId, partitionId) -> 
Set.of(PROCESS_ID))
+            .withCurrentWarmupTaskProcessIds((subtopologyId, partitionId) -> 
Set.of())
+            .build();
+
+        assertEquals(
+            new StreamsGroupMember.Builder(MEMBER_NAME)
+                .setState(MemberState.UNRELEASED_TASKS)
+                .setProcessId(PROCESS_ID)
+                .setMemberEpoch(memberEpoch + 1)
+                .setPreviousMemberEpoch(memberEpoch)
+                
.setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.STANDBY, memberEpoch,
+                    mkTasks(SUBTOPOLOGY_ID1, 0)))
+                .setTasksPendingRevocation(TasksTupleWithEpochs.EMPTY)
+                .build(),
+            updatedMember
+        );
+    }
+
+    @Test
+    public void 
testActiveNotGrantedWhileAnotherMemberOnSameProcessHoldsStandby() {
+        final int memberEpoch = 10;
+
+        // This member does not hold the standby itself; a *different* member 
on the same process does (reflected by
+        // currentStandbyTaskProcessIds returning this member's process). The 
active must not be granted -- the process
+        // would otherwise run the task as both active (this member) and 
standby (the sibling).
+        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(PROCESS_ID))
+            .withCurrentWarmupTaskProcessIds((subtopologyId, partitionId) -> 
Set.of())
+            .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 testActiveDemotedToStandbyInPlace() {
+        final int memberEpoch = 10;
+
+        StreamsGroupMember member = 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();
+
+        // The target wants this member to keep the task as a standby. The 
standby is granted in the same step in
+        // which the active task is revoked, so the client recycles the task. 
The active task still needs an
+        // acknowledgement, because the group must observe its release before 
its next owner can run it.
+        StreamsGroupMember updatedMember = new CurrentAssignmentBuilder(member)
+            .withTargetAssignment(memberEpoch + 1, 
mkTasksTuple(TaskRole.STANDBY,
+                mkTasks(SUBTOPOLOGY_ID1, 0)))
+            .withCurrentActiveTaskProcessId((subtopologyId, partitionId) -> 
PROCESS_ID)
+            .withCurrentStandbyTaskProcessIds((subtopologyId, partitionId) -> 
Set.of())
+            .withCurrentWarmupTaskProcessIds((subtopologyId, partitionId) -> 
Set.of())
+            .build();
+
+        assertEquals(
+            new StreamsGroupMember.Builder(MEMBER_NAME)
+                .setState(MemberState.UNREVOKED_TASKS)
+                .setProcessId(PROCESS_ID)
+                .setMemberEpoch(memberEpoch)
+                .setPreviousMemberEpoch(memberEpoch)
+                
.setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.STANDBY, memberEpoch,
+                    mkTasks(SUBTOPOLOGY_ID1, 0)))
+                
.setTasksPendingRevocation(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 
memberEpoch,
+                    mkTasks(SUBTOPOLOGY_ID1, 0)))
+                .build(),
+            updatedMember
+        );
+    }
+
+    @Test
+    public void testActiveDemotionCompletesWhenRevocationIsAcknowledged() {
+        final int memberEpoch = 10;
+
+        // The member was told to demote its active task to a standby task in 
the previous step: it already holds the
+        // standby, and the active task is pending revocation.
+        StreamsGroupMember member = new StreamsGroupMember.Builder(MEMBER_NAME)
+            .setState(MemberState.UNREVOKED_TASKS)
+            .setProcessId(PROCESS_ID)
+            .setMemberEpoch(memberEpoch)
+            .setPreviousMemberEpoch(memberEpoch)
+            .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.STANDBY, 
memberEpoch,
+                mkTasks(SUBTOPOLOGY_ID1, 0)))
+            
.setTasksPendingRevocation(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 
memberEpoch,
+                mkTasks(SUBTOPOLOGY_ID1, 0)))
+            .build();
+
+        // The member reports the task as a standby only, which acknowledges 
the release of the active task.
+        StreamsGroupMember updatedMember = new CurrentAssignmentBuilder(member)
+            .withTargetAssignment(memberEpoch + 1, 
mkTasksTuple(TaskRole.STANDBY,
+                mkTasks(SUBTOPOLOGY_ID1, 0)))
+            .withCurrentActiveTaskProcessId((subtopologyId, partitionId) -> 
PROCESS_ID)
+            .withCurrentStandbyTaskProcessIds((subtopologyId, partitionId) -> 
Set.of(PROCESS_ID))
+            .withCurrentWarmupTaskProcessIds((subtopologyId, partitionId) -> 
Set.of())
+            .withOwnedAssignment(mkTasksTuple(TaskRole.STANDBY, 
mkTasks(SUBTOPOLOGY_ID1, 0)))
+            .build();
+
+        assertEquals(
+            new StreamsGroupMember.Builder(MEMBER_NAME)
+                .setState(MemberState.STABLE)
+                .setProcessId(PROCESS_ID)
+                .setMemberEpoch(memberEpoch + 1)
+                .setPreviousMemberEpoch(memberEpoch)
+                
.setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.STANDBY, memberEpoch,
+                    mkTasks(SUBTOPOLOGY_ID1, 0)))
+                .setTasksPendingRevocation(TasksTupleWithEpochs.EMPTY)
+                .build(),
+            updatedMember
+        );
+    }
+
+    @Test
+    public void 
testActiveNotDemotedInPlaceWhileAnotherMemberOnSameProcessHoldsStandby() {
+        final int memberEpoch = 10;
+
+        // A *different* member on the same process already holds the task as 
a standby, so this member cannot take it
+        // on as a standby as well. The demotion falls back to a plain 
revocation, and the standby is granted only
+        // once the sibling has given it up.
+        StreamsGroupMember member = 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();
+
+        StreamsGroupMember updatedMember = new CurrentAssignmentBuilder(member)
+            .withTargetAssignment(memberEpoch + 1, 
mkTasksTuple(TaskRole.STANDBY,
+                mkTasks(SUBTOPOLOGY_ID1, 0)))
+            .withCurrentActiveTaskProcessId((subtopologyId, partitionId) -> 
PROCESS_ID)
+            .withCurrentStandbyTaskProcessIds((subtopologyId, partitionId) -> 
Set.of(PROCESS_ID))
+            .withCurrentWarmupTaskProcessIds((subtopologyId, partitionId) -> 
Set.of())
+            .build();
+
+        assertEquals(
+            new StreamsGroupMember.Builder(MEMBER_NAME)
+                .setState(MemberState.UNREVOKED_TASKS)
+                .setProcessId(PROCESS_ID)
+                .setMemberEpoch(memberEpoch)
+                .setPreviousMemberEpoch(memberEpoch)
+                .setAssignedTasks(TasksTupleWithEpochs.EMPTY)
+                
.setTasksPendingRevocation(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 
memberEpoch,
+                    mkTasks(SUBTOPOLOGY_ID1, 0)))
+                .build(),
+            updatedMember
+        );
+    }
+
+    @Test
+    public void testStandbyConvertedToWarmupInPlace() {
+        final int memberEpoch = 10;
+
+        StreamsGroupMember member = new StreamsGroupMember.Builder(MEMBER_NAME)
+            .setState(MemberState.STABLE)
+            .setProcessId(PROCESS_ID)
+            .setMemberEpoch(memberEpoch)
+            .setPreviousMemberEpoch(memberEpoch)
+            .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.STANDBY, 
memberEpoch,
+                mkTasks(SUBTOPOLOGY_ID1, 0)))
+            .setTasksPendingRevocation(TasksTupleWithEpochs.EMPTY)
+            .build();
+
+        // Standby and warm-up tasks run the same code on the client, so 
relabelling one into the other needs no
+        // client-side action and hence no revocation acknowledgement: the 
standby is dropped in the same step in
+        // which the warm-up is granted, and the task keeps running throughout.
+        StreamsGroupMember updatedMember = new CurrentAssignmentBuilder(member)
+            .withTargetAssignment(memberEpoch + 1, 
mkTasksTuple(TaskRole.WARMUP,
+                mkTasks(SUBTOPOLOGY_ID1, 0)))
+            .withCurrentActiveTaskProcessId((subtopologyId, partitionId) -> 
null)
+            .withCurrentStandbyTaskProcessIds((subtopologyId, partitionId) -> 
Set.of(PROCESS_ID))
+            .withCurrentWarmupTaskProcessIds((subtopologyId, partitionId) -> 
Set.of())
+            .build();
+
+        assertEquals(
+            new StreamsGroupMember.Builder(MEMBER_NAME)
+                .setState(MemberState.STABLE)
+                .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 testWarmupConvertedToStandbyInPlace() {
+        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 reverse of the conversion above, which is what happens when a 
planned migration is called off and the
+        // warm-up becomes a regular standby.
+        StreamsGroupMember updatedMember = new CurrentAssignmentBuilder(member)
+            .withTargetAssignment(memberEpoch + 1, 
mkTasksTuple(TaskRole.STANDBY,
+                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.STANDBY, memberEpoch,
+                    mkTasks(SUBTOPOLOGY_ID1, 0)))
+                .setTasksPendingRevocation(TasksTupleWithEpochs.EMPTY)
+                .build(),
+            updatedMember
+        );
+    }
+
+    @Test
+    public void 
testStandbyRevokedNormallyWhenConversionIsBlockedByAnotherMemberOnSameProcess() 
{
+        final int memberEpoch = 10;
+
+        // A *different* member on the same process already holds the task as 
a warm-up, so this member cannot convert
+        // its standby into a warm-up. The standby must then be revoked the 
regular way (with an acknowledgement),
+        // because the process may not hold the task twice.
+        StreamsGroupMember member = new StreamsGroupMember.Builder(MEMBER_NAME)
+            .setState(MemberState.STABLE)
+            .setProcessId(PROCESS_ID)
+            .setMemberEpoch(memberEpoch)
+            .setPreviousMemberEpoch(memberEpoch)
+            .setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.STANDBY, 
memberEpoch,
+                mkTasks(SUBTOPOLOGY_ID1, 0)))
+            .setTasksPendingRevocation(TasksTupleWithEpochs.EMPTY)
+            .build();
+
+        StreamsGroupMember updatedMember = new CurrentAssignmentBuilder(member)
+            .withTargetAssignment(memberEpoch + 1, 
mkTasksTuple(TaskRole.WARMUP,
+                mkTasks(SUBTOPOLOGY_ID1, 0)))
+            .withCurrentActiveTaskProcessId((subtopologyId, partitionId) -> 
null)
+            .withCurrentStandbyTaskProcessIds((subtopologyId, partitionId) -> 
Set.of(PROCESS_ID))
+            .withCurrentWarmupTaskProcessIds((subtopologyId, partitionId) -> 
Set.of(PROCESS_ID))
+            .build();
+
+        assertEquals(
+            new StreamsGroupMember.Builder(MEMBER_NAME)
+                .setState(MemberState.UNREVOKED_TASKS)
+                .setProcessId(PROCESS_ID)
+                .setMemberEpoch(memberEpoch)
+                .setPreviousMemberEpoch(memberEpoch)
+                .setAssignedTasks(TasksTupleWithEpochs.EMPTY)
+                
.setTasksPendingRevocation(mkTasksTupleWithCommonEpoch(TaskRole.STANDBY, 
memberEpoch,
+                    mkTasks(SUBTOPOLOGY_ID1, 0)))
+                .build(),
+            updatedMember
+        );
+    }
 }
diff --git 
a/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/RestoreIntegrationTest.java
 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/RestoreIntegrationTest.java
index 29d3cf367a3..8e7068de1d7 100644
--- 
a/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/RestoreIntegrationTest.java
+++ 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/RestoreIntegrationTest.java
@@ -621,10 +621,11 @@ public class RestoreIntegrationTest {
 
             assertThat(restoreListener.totalNumRestored(), 
CoreMatchers.equalTo(initialNunRestoredCount));
 
-            // After stopping instance 2 and letting instance 1 take over its 
tasks, we should have closed the stores on instance 2.
-            // Under the new group protocol, an extra store close can occur 
during rebalance; account for that here.
-            final int expectedAfterStreams2Close = initialStoreCloseCount + 
(useNewProtocol ? 3 : 2);
-            assertThat(CloseCountingInMemoryStore.numStoresClosed(), 
equalTo(expectedAfterStreams2Close));
+            // After stopping instance 2 and letting instance 1 take over its 
tasks, we should have closed just two stores
+            // total: the active and standby tasks on instance 2. The new 
protocol used to close one store more, because
+            // the standby that instance 1 already held was closed and 
re-created instead of being promoted in place;
+            // now that the reconciler changes the role in place, both 
protocols close the same two stores.
+            assertThat(CloseCountingInMemoryStore.numStoresClosed(), 
equalTo(initialStoreCloseCount + 2));
         } finally {
             streams1.close(Duration.ofSeconds(60));
         }

Reply via email to