Copilot commented on code in PR #23484:
URL: https://github.com/apache/kafka/pull/23484#discussion_r4043892789


##########
group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/AssignmentRefinerImpl.java:
##########
@@ -521,6 +522,208 @@ private static double currentProcessLoad(
             
.loadWith(newWarmupsByProcess.getOrDefault(candidate.currentProcessId(), 0));
     }
 
+    /**
+     * Decides which of the target assignment's standby placements this step 
has to hold back.
+     *
+     * <p>Nothing is invented or dropped permanently: every placement comes 
from the target assignment, and one held
+     * back here is emitted by a later step once its reason is gone. A 
placement of task {@code t} on member {@code m}
+     * of process {@code p} is withheld when:
+     * <ol>
+     *     <li>a staged migration keeps {@code t} running as an active task on 
{@code p}, and a process cannot hold
+     *     {@code t} twice.</li>
+     *     <li>{@code t}'s migration onto {@code p} borrowed an existing 
standby on {@code p}. To not run
+     *     {@code num.standby.replicas + 1} standbys, we hold back one 
assignment of the standby to a new owner.</li>
+     * </ol>
+     *
+     * @param targetAssignment
+     *        All members' target assignments, as computed by the task 
assignor.
+     * @param currentAssignment
+     *        The indexed current assignment, from {@link 
#indexCurrentAssignment}.
+     * @param decisions
+     *        What the case analysis decided, from {@link #analyzeTasks}.
+     * @param warmupPlan
+     *        How each staged migration is being warmed, from {@link 
#planWarmups}.
+     * @param members
+     *        All members of the group, used to resolve which process a member 
runs in.
+     * @param subtopologies
+     *        The resolved subtopologies, which tell whether a subtopology is 
stateful.
+     *
+     * @return The standby placements to withhold, as the tasks to drop from 
each member's target assignment, in
+     *         canonical order. A member with nothing withheld does not appear.
+     */
+    static SortedMap<String, SortedSet<TaskId>> filterStandbys(
+        final Map<String, TasksTuple> targetAssignment,
+        final CurrentAssignmentIndex currentAssignment,
+        final TaskDecisions decisions,
+        final WarmupPlan warmupPlan,
+        final Map<String, StreamsGroupMember> members,
+        final SortedMap<String, ConfiguredSubtopology> subtopologies
+    ) {
+        final StandbyConflicts conflicts = indexStandbyConflicts(
+            targetAssignment,
+            currentAssignment,
+            decisions,
+            warmupPlan,
+            members,
+            subtopologies
+        );
+        final SortedMap<String, SortedSet<TaskId>> withheld = new TreeMap<>();
+
+        targetAssignment.forEach((memberId, tasks) -> {
+            final StreamsGroupMember member = members.get(memberId);
+            if (member == null) {
+                // The target assignment can name a member the group has 
already removed. Its tasks reach nobody, so
+                // there is nothing to hold back and no process to resolve it 
against.
+                return;
+            }
+
+            forEachStatefulTask(tasks.standbyTasks(), subtopologies, task -> {
+                if (isStandbyWithheld(memberId, member.processId(), task, 
conflicts)) {
+                    withheld.computeIfAbsent(memberId, __ -> new 
TreeSet<>()).add(task);
+                }
+            });
+        });
+
+        return Collections.unmodifiableSortedMap(withheld);
+    }
+
+    /**
+     * Builds the {@link StandbyConflicts} lookups, ie, where each staged 
migration keeps its task running, and which
+     * placement pays for each borrowed copy.
+     */
+    private static StandbyConflicts indexStandbyConflicts(
+        final Map<String, TasksTuple> targetAssignment,
+        final CurrentAssignmentIndex currentAssignment,
+        final TaskDecisions decisions,
+        final WarmupPlan warmupPlan,
+        final Map<String, StreamsGroupMember> members,
+        final SortedMap<String, ConfiguredSubtopology> subtopologies
+    ) {
+        final Map<TaskId, String> activeStagedOn = new HashMap<>();
+        for (final StagedMigration migration : decisions.stagedMigrations()) {
+            activeStagedOn.put(migration.task(), 
members.get(migration.currentOwner()).processId());
+        }
+
+        final Map<TaskId, String> borrowPaidBy = new HashMap<>();
+        targetAssignment.forEach((memberId, tasks) ->
+            forEachStatefulTask(tasks.standbyTasks(), subtopologies, task -> {
+                if (warmupPlan.borrowedMigrations().contains(task)
+                    && !holdsCopyOf(currentAssignment, memberId, task)) {
+                    borrowPaidBy.merge(task, memberId, (left, right) -> 
left.compareTo(right) <= 0 ? left : right);

Review Comment:
   This checks only the destination member, but task uniqueness and standby 
handoff are process-scoped. If a target standby moves from `memberC1` to 
sibling `memberC2` while another process borrows the task, `memberC2` is 
incorrectly selected to pay for the borrow even though process C already has 
that copy and the reconciler will serialize the sibling move. Since `assemble` 
also drops `memberC1` according to the target assignment, process C loses its 
existing standby until the active migration completes. Resolve the destination 
member's process and treat a copy on any sibling of that process as already 
held (consistent with `CurrentAssignmentBuilder.isUnreleasedStandbyTask`).



##########
group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/AssignmentRefinerTest.java:
##########
@@ -1348,6 +1352,494 @@ public void 
shouldPlanNothingWhenTheWarmupBudgetIsZero() {
         assertEquals(Set.of(), plan.parkedMigrations());
     }
 
+    // 
---------------------------------------------------------------------------------------------------------------
+    // filterStandbys
+    // 
---------------------------------------------------------------------------------------------------------------
+
+    @Test
+    public void shouldWithholdAStandbyOnAProcessThatStillRunsTheTaskAsActive() 
{
+        // The swap shape: the assignor moves the active to memberB and leaves 
a standby behind on memberA. While the
+        // migration is staged the task keeps running on memberA, so the 
standby cannot be placed there as well.
+        final Map<String, StreamsGroupMember> members = Map.of(
+            "memberA", member("memberA", "processA", 
mkTasksTuple(TaskRole.ACTIVE, mkTasks(STATEFUL, 0))),
+            "memberB", member("memberB", "processB", TasksTuple.EMPTY)
+        );
+        final Map<String, TasksTuple> targetAssignment = Map.of(
+            "memberA", mkTasksTuple(TaskRole.STANDBY, mkTasks(STATEFUL, 0)),
+            "memberB", mkTasksTuple(TaskRole.ACTIVE, mkTasks(STATEFUL, 0))
+        );
+
+        assertEquals(Map.of("memberA", Set.of(STATEFUL_0)), filter(members, 
targetAssignment, Map.of(), 1));
+    }
+
+    @Test
+    public void 
shouldEmitTheStandbyOnTheMemberGrantingTheActiveAwayInTheSameStep() {
+        // The efficient half of the swap: memberA hands the active over and 
keeps a standby in its place, which the
+        // client does by relabelling the task it already has. Nothing is 
staged, so no rule holds the placement
+        // back, and the relabel happens now rather than a step later when 
that state is already gone.
+        final Map<String, StreamsGroupMember> members = Map.of(
+            "memberA", member("memberA", "processA", 
mkTasksTuple(TaskRole.ACTIVE, mkTasks(STATEFUL, 0))),
+            "memberB", member("memberB", "processB", 
mkTasksTuple(TaskRole.STANDBY, mkTasks(STATEFUL, 0)))
+        );
+        final Map<String, TasksTuple> targetAssignment = Map.of(
+            "memberA", mkTasksTuple(TaskRole.STANDBY, mkTasks(STATEFUL, 0)),
+            "memberB", mkTasksTuple(TaskRole.ACTIVE, mkTasks(STATEFUL, 0))
+        );
+        // memberB's standby is caught up, so the migration is granted rather 
than staged.
+        final Map<String, MemberTaskOffsets> taskOffsets = Map.of("memberB", 
offsets(100, 100));
+
+        assertEquals(Map.of(), filter(members, targetAssignment, taskOffsets, 
1));
+    }
+
+    @Test
+    public void shouldEmitAStandbyOnASiblingOfTheMemberGrantingTheActiveAway() 
{
+        // memberA2 would be a second copy on processA until memberA1's 
hand-over finishes, and it does have to wait
+        // for it -- but in the reconciler, which holds the placement back 
while the process still runs the task.
+        // Withholding it here as well would only add an epoch.
+        final Map<String, StreamsGroupMember> members = Map.of(
+            "memberA1", member("memberA1", "processA", 
mkTasksTuple(TaskRole.ACTIVE, mkTasks(STATEFUL, 0))),
+            "memberA2", member("memberA2", "processA", TasksTuple.EMPTY),
+            "memberB", member("memberB", "processB", 
mkTasksTuple(TaskRole.STANDBY, mkTasks(STATEFUL, 0)))
+        );
+        final Map<String, TasksTuple> targetAssignment = Map.of(
+            "memberA1", TasksTuple.EMPTY,
+            "memberA2", mkTasksTuple(TaskRole.STANDBY, mkTasks(STATEFUL, 0)),
+            "memberB", mkTasksTuple(TaskRole.ACTIVE, mkTasks(STATEFUL, 0))
+        );
+        final Map<String, MemberTaskOffsets> taskOffsets = Map.of("memberB", 
offsets(100, 100));
+
+        assertEquals(Map.of(), filter(members, targetAssignment, taskOffsets, 
1));
+    }
+
+    @Test
+    public void shouldEmitAStandbyBlockedOnlyByAPendingRevocation() {
+        // The filter reads the tasks members have been granted, never the 
ones they were told to give up: indexing
+        // revocations for this rule alone would duplicate what the reconciler 
already enforces, which refuses to
+        // grant a role for a task the process still physically holds. So this 
is emitted and the hand-over
+        // serializes itself, at the cost of an extra heartbeat or two before 
the group settles.
+        final Map<String, StreamsGroupMember> members = Map.of(
+            "memberA", member(
+                "memberA",
+                "processA",
+                TasksTuple.EMPTY,
+                mkTasksTuple(TaskRole.ACTIVE, mkTasks(STATEFUL, 0))
+            )
+        );
+        final Map<String, TasksTuple> targetAssignment = Map.of(
+            "memberA", mkTasksTuple(TaskRole.STANDBY, mkTasks(STATEFUL, 0))
+        );
+
+        assertEquals(Map.of(), filter(members, targetAssignment, Map.of(), 1));
+    }
+
+    @Test
+    public void shouldWithholdAStandbyOnTheProcessAMigrationIsStagedOn() {
+        // The placement rule 1 protects is the one the staged migration 
makes: the task runs on memberB for this
+        // step, so the target assignment's standby of it cannot land on 
memberB's process as well. Nothing holds it

Review Comment:
   The sentence reads “Nothing holds it task here”; change the final “it” to 
“the.”



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to