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 1926075628d KAFKA-20665: Refine the assignment refiner scaffolding 
(#22985)
1926075628d is described below

commit 1926075628d72b2c0dc82c0e8ec9c79a54f869cd
Author: Matthias J. Sax <[email protected]>
AuthorDate: Wed Jul 29 21:46:19 2026 -0700

    KAFKA-20665: Refine the assignment refiner scaffolding (#22985)
    
    The refiner needs a group-wide view similar to the assignor. This PR
    changes the refiner setup and what parameters we pass into the it, to
    provide all information it needs.
    
    To avoid unnecessary recomputation of an "intermediate assignment" and
    to freeze the current reconsolation milestone, we add a cache for it.
    
    Reviewers: Sean Quah <[email protected]>
---
 .../coordinator/group/GroupMetadataManager.java    | 162 ++++++++++++++-------
 .../group/streams/AssignmentRefiner.java           |  99 +++++++++++++
 .../coordinator/group/streams/StreamsGroup.java    |  91 ++++++++++++
 .../group/GroupMetadataManagerTest.java            |  62 ++++++++
 ...sGroupStaticMemberGroupMetadataManagerTest.java |  55 +++++++
 .../group/streams/AssignmentRefinerTest.java       | 154 ++++++++++++++++++++
 .../group/streams/StreamsGroupTest.java            |  95 ++++++++++++
 7 files changed, 668 insertions(+), 50 deletions(-)

diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupMetadataManager.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupMetadataManager.java
index a437e4d10d4..367a3e9107b 100644
--- 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupMetadataManager.java
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupMetadataManager.java
@@ -154,7 +154,7 @@ import 
org.apache.kafka.coordinator.group.modern.share.ShareGroup.InitMapValue;
 import 
org.apache.kafka.coordinator.group.modern.share.ShareGroup.ShareGroupStatePartitionMetadataInfo;
 import 
org.apache.kafka.coordinator.group.modern.share.ShareGroupAssignmentBuilder;
 import org.apache.kafka.coordinator.group.modern.share.ShareGroupMember;
-import org.apache.kafka.coordinator.group.streams.MemberTaskOffsets;
+import org.apache.kafka.coordinator.group.streams.AssignmentRefiner;
 import 
org.apache.kafka.coordinator.group.streams.StreamsCoordinatorRecordHelpers;
 import org.apache.kafka.coordinator.group.streams.StreamsGroup;
 import org.apache.kafka.coordinator.group.streams.StreamsGroupDescribeResult;
@@ -2114,7 +2114,14 @@ public class GroupMetadataManager {
         } else {
             StreamsGroupMember maybeOldStaticMember = 
group.staticMember(instanceId);
             if (maybeOldStaticMember != null && 
!maybeOldStaticMember.memberId().equals(memberId)) {
-                replaceStaticOldMember = maybeOldStaticMember;    
+                replaceStaticOldMember = maybeOldStaticMember;
+                // Replacing a static member relabels its target assignment 
from the old to the new member ID without
+                // bumping the assignment epoch, so an intermediate assignment 
derived for this epoch no longer matches
+                // the members it was derived for. Re-key it rather than 
dropping it: the replacement copies the old
+                // member's state, so the decisions of this epoch still hold, 
and deriving a new one here would re-plan
+                // mid-epoch and could revise the slice of a member that 
already reconciled and is therefore not
+                // reconciled again within this epoch.
+                
group.relabelRefinedAssignment(maybeOldStaticMember.memberId(), memberId);
             }
             member = getOrMaybeCreateStaticStreamsGroupMember(
                 group,
@@ -2229,18 +2236,12 @@ public class GroupMetadataManager {
             assignmentUpdate = AssignmentUpdate.RECOMPUTE;
         }
 
-        TasksTuple refinedAssignment = null;
+        Map<String, TasksTuple> refinedGroupAssignment = null;
         if (assignmentUpdate == AssignmentUpdate.NONE && group.state() == 
StreamsGroup.StreamsGroupState.STABLE) {
             // We are not computing a new target assignment later, thus we try 
to refine the current target assignment
             // into an intermediate assignment (with warm-up tasks) the member 
should be reconciled towards.
-            refinedAssignment = maybeRefineAssignment(// no-op for now
-                updatedMember,
-                group.targetAssignment(),
-                group.taskOffsets(),
-                streamsGroupNumWarmupReplicas(groupId),
-                streamsGroupAcceptableRecoveryLag(groupId)
-            );
-            if (!refinedAssignment.sameTasks(updatedMember.assignedTasks())) {
+            refinedGroupAssignment = refineAssignment(group, 
group.targetAssignment(), updatedConfiguredTopology);
+            if (!refinedGroupAssignment.getOrDefault(memberId, 
TasksTuple.EMPTY).sameTasks(updatedMember.assignedTasks())) {
                 assignmentUpdate = AssignmentUpdate.REFINED;
             }
         }
@@ -2294,16 +2295,21 @@ public class GroupMetadataManager {
 
         // 4b. If we did not already refine above -- ie, we computed a new 
target assignment, or the group is not
         // yet reconciled (a rebalance is still in progress) -- refine the 
target assignment into an intermediate
-        // assignment (with warm-up tasks) the member should be reconciled 
towards.
-        if (refinedAssignment == null) {
-            refinedAssignment = maybeRefineAssignment(// no-op for now
-                updatedMember,
+        // assignment (with warm-up tasks) the member should be reconciled 
towards. Otherwise, the assignment we
+        // refined above is the intermediate assignment of this epoch, whether 
we bumped the epoch for it or not: it
+        // was derived from a settled group, so it also holds for the target 
assignment of the bumped epoch, which a
+        // refinement step leaves unchanged.
+        if (refinedGroupAssignment == null) {
+            refinedGroupAssignment = refinedAssignmentForEpoch(
+                group,
+                updateTargetAssignmentResult.targetAssignmentEpoch(),
                 updateTargetAssignmentResult.targetAssignment,
-                group.taskOffsets(),
-                streamsGroupNumWarmupReplicas(group.groupId()),
-                streamsGroupAcceptableRecoveryLag(group.groupId())
+                updatedConfiguredTopology
             );
+        } else {
+            maybeCacheRefinedAssignment(group, 
updateTargetAssignmentResult.targetAssignmentEpoch(), refinedGroupAssignment);
         }
+        TasksTuple refinedAssignment = 
refinedGroupAssignment.getOrDefault(memberId, TasksTuple.EMPTY);
 
         // 5. Reconcile the member's assignment with the (refined) target 
assignment if the member is not
         // fully reconciled yet.
@@ -4507,40 +4513,96 @@ public class GroupMetadataManager {
 
 
     /**
-     * Refines the task assignor's target assignment into the 
<em>intermediate</em> assignment that
-     * the reconciler ({@link 
org.apache.kafka.coordinator.group.streams.CurrentAssignmentBuilder}) converges 
members toward.
-     * <p>
-     * The intermediate assignment is the current assignment with warm-up 
tasks inserted (for later promotion to active,
-     * based on per-member changelog lag), so that a task is moved to a new 
owner only once that owner has caught up.
-     * It is held in memory only and is never persisted: the assignor's target 
assignment remains the persisted source of
-     * truth (and the intermediate is reconstructed from the persisted 
current-assignment records after a coordinator failover).
-     * <p>
-     * The refiner is invoked on every heartbeat, before reconciliation, so it 
can react to all the inputs that can change
-     * the intermediate assignment between reassignments — newly reported task 
offsets (a warm-up may have become hot),
-     * {@code num.warmup.replicas} / {@code acceptable.recovery.lag} config 
changes, and members acknowledging task
-     * revocation/restoration (advancing an in-flight migration).
-     *
-     * @param member
-     *        The member to produce the refined (intermediate) assignment for.
-     * @param targetAssignment
-     *        All members' target assignments (group context).
-     * @param taskOffsets
-     *        The latest per-member changelog offsets/end-offsets reported via 
heartbeats (group context).
-     * @param numWarmupReplicas
-     *        The configured maximum number of warm-up replicas.
-     * @param acceptableRecoveryLag
-     *        The lag at or below which a warm-up is considered caught up and 
can be promoted.
-     *
-     * @return The member's intermediate assignment tuple.
-     */
-    private static TasksTuple maybeRefineAssignment(
-        final StreamsGroupMember member,
+     * Derives the <em>intermediate</em> assignment that the reconciler
+     * ({@link 
org.apache.kafka.coordinator.group.streams.CurrentAssignmentBuilder}) converges 
the members toward, for a
+     * refinement step that is not minted yet. It is compared against the 
members' current assignments to decide whether
+     * a refinement step is due at all, and becomes the intermediate 
assignment of the epoch if it is (see
+     * {@link #refinedAssignmentForEpoch}).
+     *
+     * @param group              The streams group.
+     * @param targetAssignment   All members' target assignments.
+     * @param configuredTopology The configured topology.
+     *
+     * @return The intermediate assignment keyed by member ID.
+     */
+    private Map<String, TasksTuple> refineAssignment(
+        final StreamsGroup group,
+        final Map<String, TasksTuple> targetAssignment,
+        final ConfiguredTopology configuredTopology
+    ) {
+        final int numWarmupReplicas = 
streamsGroupNumWarmupReplicas(group.groupId());
+        if (numWarmupReplicas == 0) {
+            // Warm-up tasks are disabled, so there is nothing to refine and 
no state to keep for the group.
+            return targetAssignment;
+        }
+        final Map<String, TasksTuple> refinedAssignment = 
AssignmentRefiner.refine(
+            group.members(),
+            targetAssignment,
+            group.taskOffsets(),
+            configuredTopology,
+            numWarmupReplicas,
+            streamsGroupAcceptableRecoveryLag(group.groupId())
+        );
+        if (!AssignmentRefiner.preservesActiveTaskCount(targetAssignment, 
refinedAssignment)) {
+            // Reconciling towards an intermediate assignment that lost or 
duplicated an active task would leave input
+            // partitions unprocessed or processed twice, so fall back to the 
target assignment. That is the same
+            // assignment the group reconciles towards with warm-up tasks 
disabled, so no further handling is needed --
+            // beyond that tasks now move without warming up, which the 
operator should know about.
+            log.error("[GroupId {}] The refined assignment does not hand out 
as many active tasks as the target " +
+                    "assignment. Reconciling towards the target assignment 
instead, so tasks that have to move do so " +
+                    "without warming up first. Target assignment: {}, refined 
assignment: {}.",
+                group.groupId(), targetAssignment, refinedAssignment);
+            return targetAssignment;
+        }
+        return refinedAssignment;
+    }
+
+    /**
+     * Returns the intermediate assignment of the given assignment epoch, 
deriving it once and caching it for the
+     * lifetime of the epoch. Every refinement step is an epoch of its own, so 
the decisions of a step are taken when
+     * its epoch is minted and do not change while the members reconcile 
toward it.
+     *
+     * @param group              The streams group.
+     * @param assignmentEpoch    The assignment epoch the members are being 
reconciled to.
+     * @param targetAssignment   All members' target assignments.
+     * @param configuredTopology The configured topology.
+     *
+     * @return The intermediate assignment keyed by member ID.
+     */
+    private Map<String, TasksTuple> refinedAssignmentForEpoch(
+        final StreamsGroup group,
+        final int assignmentEpoch,
         final Map<String, TasksTuple> targetAssignment,
-        final Map<String, MemberTaskOffsets> taskOffsets,
-        final int numWarmupReplicas,
-        final long acceptableRecoveryLag
+        final ConfiguredTopology configuredTopology
     ) {
-        return targetAssignment.getOrDefault(member.memberId(), 
TasksTuple.EMPTY);
+        final Map<String, TasksTuple> cached = 
group.refinedAssignment(assignmentEpoch);
+        if (cached != null) {
+            return cached;
+        }
+        final Map<String, TasksTuple> refinedAssignment = 
refineAssignment(group, targetAssignment, configuredTopology);
+        maybeCacheRefinedAssignment(group, assignmentEpoch, refinedAssignment);
+        return refinedAssignment;
+    }
+
+    /**
+     * Freezes the given intermediate assignment as the one of the given 
assignment epoch, so that the decisions of a
+     * refinement step do not change while the members reconcile toward it.
+     * <p>
+     * Nothing is kept for a group that has warm-up tasks disabled: the 
intermediate assignment is then just the target
+     * assignment, which the caller has anyway, so there is nothing to freeze.
+     *
+     * @param group              The streams group.
+     * @param assignmentEpoch    The assignment epoch the intermediate 
assignment was derived for.
+     * @param refinedAssignment  The intermediate assignment keyed by member 
ID.
+     */
+    private void maybeCacheRefinedAssignment(
+        final StreamsGroup group,
+        final int assignmentEpoch,
+        final Map<String, TasksTuple> refinedAssignment
+    ) {
+        if (streamsGroupNumWarmupReplicas(group.groupId()) > 0) {
+            group.setRefinedAssignment(assignmentEpoch, refinedAssignment);
+        }
     }
 
     /**
diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/AssignmentRefiner.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/AssignmentRefiner.java
new file mode 100644
index 00000000000..1322f891016
--- /dev/null
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/AssignmentRefiner.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.coordinator.group.streams;
+
+import org.apache.kafka.coordinator.group.streams.topics.ConfiguredTopology;
+
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Refines the task assignor's target assignment into the 
<em>intermediate</em> assignment that the reconciler
+ * ({@link CurrentAssignmentBuilder}) converges the members toward.
+ * <p>
+ * The assignor decides <em>where</em> a task belongs; the refiner decides 
<em>how</em> the group gets there. When a
+ * stateful task has to move to a member that does not hold its state yet, 
handing it over right away would stall
+ * processing while the new owner restores the state from the changelog. 
Instead, the refiner leaves the task with its
+ * current owner and hands the new owner a warm-up task, so it can restore the 
state in the background. Once the warm-up
+ * task has caught up -- its lag is within {@code acceptable.recovery.lag} -- 
a later refinement step moves the task
+ * over. {@code num.warmup.replicas} bounds how many such warm-up tasks the 
group runs at a time.
+ * <p>
+ * Properties of the intermediate assignment that callers rely on:
+ * <ul>
+ *     <li>It is <b>derived for the whole group at once</b>, because both the 
warm-up budget and the rule that a
+ *     process must not hold a task twice are group-wide. Callers take the 
individual member's slice out of the
+ *     result.</li>
+ *     <li>It is <b>held in memory only</b> and never persisted. The 
assignor's target assignment remains the persisted
+ *     source of truth, and the intermediate assignment is derived again from 
the persisted current assignments after a
+ *     coordinator failover.</li>
+ *     <li>It is <b>frozen for the duration of an assignment epoch</b>: every 
refinement step is an epoch of its own, so
+ *     the decisions of a step are taken once, when the epoch is minted, and 
do not change while the members reconcile
+ *     towards it. See {@link StreamsGroup#refinedAssignment(int)}.</li>
+ * </ul>
+ */
+public class AssignmentRefiner {
+
+    private AssignmentRefiner() {
+    }
+
+    /**
+     * Derives the intermediate assignment for all members of the group.
+     *
+     * @param members               All members of the group, with their 
current assignments, the tasks they are still
+     *                              to revoke, and their process IDs.
+     * @param targetAssignment      All members' target assignments, as 
computed by the task assignor.
+     * @param taskOffsets           The latest changelog offsets/end-offsets 
reported by the members via heartbeats,
+     *                              from which the lag of a warm-up task is 
derived. Not populated for a member that
+     *                              has not reported any offsets yet, for 
example right after a coordinator failover.
+     * @param configuredTopology    The configured topology, which tells 
whether a subtopology is stateful. Only
+     *                              stateful tasks are warmed up; a stateless 
task has no state to restore.
+     * @param numWarmupReplicas     The maximum number of warm-up tasks the 
group may run at a time. Zero disables
+     *                              warm-up tasks altogether, in which case 
the intermediate assignment is the target
+     *                              assignment.
+     * @param acceptableRecoveryLag The lag at or below which a warm-up task 
is considered caught up.
+     *
+     * @return The intermediate assignment, keyed by member ID.
+     */
+    public static Map<String, TasksTuple> refine(
+        Map<String, StreamsGroupMember> members,
+        Map<String, TasksTuple> targetAssignment,
+        Map<String, MemberTaskOffsets> taskOffsets,
+        ConfiguredTopology configuredTopology,
+        int numWarmupReplicas,
+        long acceptableRecoveryLag
+    ) {
+        // No warm-up tasks are inserted yet, so the intermediate assignment 
is the target assignment.
+        return targetAssignment;
+    }
+
+    public static boolean preservesActiveTaskCount(
+        Map<String, TasksTuple> targetAssignment,
+        Map<String, TasksTuple> refinedAssignment
+    ) {
+        return countActiveTasks(targetAssignment) == 
countActiveTasks(refinedAssignment);
+    }
+
+    private static int countActiveTasks(Map<String, TasksTuple> assignment) {
+        int count = 0;
+        for (TasksTuple tasks : assignment.values()) {
+            for (Set<Integer> partitionIds : tasks.activeTasks().values()) {
+                count += partitionIds.size();
+            }
+        }
+        return count;
+    }
+}
diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroup.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroup.java
index 3a481459f1c..213e49a2eb1 100644
--- 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroup.java
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroup.java
@@ -225,6 +225,35 @@ public class StreamsGroup implements Group {
      */
     private final Map<String, MemberTaskOffsets> taskOffsets = new HashMap<>();
 
+    /**
+     * The intermediate assignment the members are reconciled toward, as 
derived by the {@link AssignmentRefiner} from
+     * the target assignment, together with the assignment epoch it was 
derived for. Like {@link #taskOffsets}, this is
+     * held in memory only and never persisted; it is derived again from the 
persisted assignments after a coordinator
+     * failover.
+     * <p>
+     * Every refinement step is an assignment epoch of its own, so the 
intermediate assignment is derived once per
+     * epoch, when that epoch is minted, and stays fixed while the members 
reconcile toward it. It is therefore cached
+     * together with the epoch it belongs to, and a cached value from another 
epoch is never used.
+     * <p>
+     * Unlike {@link #taskOffsets} it is kept in a timeline container, because 
it is derived from state that a failed
+     * write rolls back. Rolling back restores an earlier assignment epoch, 
which the next epoch to be minted then
+     * reuses -- with a target assignment that may differ from the one this 
was derived from. Left outside the rollback,
+     * the cache would be hit for that epoch and reconcile the members toward 
an intermediate assignment of an
+     * assignment that no longer exists. The epoch and the assignment share 
one container so that a rollback can never
+     * restore one without the other.
+     */
+    private final TimelineObject<RefinedAssignment> refinedAssignment;
+
+    /**
+     * An intermediate assignment together with the assignment epoch it was 
derived for.
+     *
+     * @param assignmentEpoch The assignment epoch, or {@code -1} if nothing 
was derived yet.
+     * @param assignment      The intermediate assignment keyed by member ID.
+     */
+    private record RefinedAssignment(int assignmentEpoch, Map<String, 
TasksTuple> assignment) {
+        private static final RefinedAssignment NONE = new 
RefinedAssignment(-1, Map.of());
+    }
+
     /**
      * The Streams topology.
      */
@@ -299,6 +328,7 @@ public class StreamsGroup implements Group {
         this.currentActiveTaskToProcessId = new 
TimelineHashMap<>(snapshotRegistry, 0);
         this.currentStandbyTaskToProcessIds = new 
TimelineHashMap<>(snapshotRegistry, 0);
         this.currentWarmupTaskToProcessIds = new 
TimelineHashMap<>(snapshotRegistry, 0);
+        this.refinedAssignment = new TimelineObject<>(snapshotRegistry, 
RefinedAssignment.NONE);
         this.topology = new TimelineObject<>(snapshotRegistry, 
Optional.empty());
         this.configuredTopology = new TimelineObject<>(snapshotRegistry, 
Optional.empty());
         this.lastAssignmentConfigs = new TimelineHashMap<>(snapshotRegistry, 
0);
@@ -586,6 +616,67 @@ public class StreamsGroup implements Group {
         return Collections.unmodifiableMap(taskOffsets);
     }
 
+    /**
+     * Returns the intermediate assignment that was derived for the given 
assignment epoch, if any. A value derived for
+     * an earlier epoch is not returned: the intermediate assignment of an 
epoch is fixed, but a new epoch means a new
+     * refinement step, which has to be derived anew.
+     *
+     * @param assignmentEpoch The assignment epoch to return the intermediate 
assignment for.
+     *
+     * @return The intermediate assignment keyed by member ID, or {@code null} 
if it was not derived for that epoch.
+     */
+    public Map<String, TasksTuple> refinedAssignment(int assignmentEpoch) {
+        final RefinedAssignment cached = refinedAssignment.get();
+        return cached.assignmentEpoch() == assignmentEpoch ? 
cached.assignment() : null;
+    }
+
+    /**
+     * Caches the intermediate assignment derived for the given assignment 
epoch. This is transient state; it must be
+     * set from the heartbeat path only, never while replaying records.
+     * <p>
+     * The given map is copied, so that the cached assignment is genuinely 
fixed for its epoch rather than tracking
+     * whatever the caller hands over: a refiner that returns the target 
assignment as-is would otherwise leave the
+     * cache holding a view of {@link #targetAssignment()}, which changes as 
records are replayed. Only the map itself
+     * is copied -- a {@link TasksTuple} already owns its task sets, so there 
is nothing else that could change
+     * underneath. The copy is free once the refiner returns a map it built 
itself, because {@link Map#copyOf} hands
+     * back an already-immutable map unchanged.
+     *
+     * @param assignmentEpoch    The assignment epoch the intermediate 
assignment was derived for.
+     * @param refinedAssignment  The intermediate assignment keyed by member 
ID.
+     */
+    public void setRefinedAssignment(int assignmentEpoch, Map<String, 
TasksTuple> refinedAssignment) {
+        this.refinedAssignment.set(new RefinedAssignment(
+            assignmentEpoch,
+            Map.copyOf(Objects.requireNonNull(refinedAssignment))
+        ));
+    }
+
+    /**
+     * Re-keys the cached intermediate assignment from an old to a new member 
ID.
+     * <p>
+     * Replacing a static member relabels its target assignment the same way, 
without advancing the assignment epoch.
+     * The cached intermediate assignment is therefore still the one the other 
members are reconciling towards; only its
+     * key for the replaced member went stale. Re-keying it keeps the 
decisions of the epoch fixed, whereas dropping it
+     * would derive a new one mid-epoch, which could revise the slice of a 
member that already reconciled and is
+     * therefore not reconciled again within this epoch.
+     *
+     * @param oldMemberId The member ID the intermediate assignment was 
derived for.
+     * @param newMemberId The member ID replacing it.
+     */
+    public void relabelRefinedAssignment(String oldMemberId, String 
newMemberId) {
+        final RefinedAssignment cached = refinedAssignment.get();
+        final TasksTuple refinedTasks = cached.assignment().get(oldMemberId);
+        if (refinedTasks == null) {
+            // Either nothing was derived for the replaced member, or the 
cached assignment belongs to another epoch --
+            // in which case refinedAssignment(int) does not hand it out 
anyway.
+            return;
+        }
+        final Map<String, TasksTuple> relabeled = new 
HashMap<>(cached.assignment());
+        relabeled.remove(oldMemberId);
+        relabeled.put(newMemberId, refinedTasks);
+        this.refinedAssignment.set(new 
RefinedAssignment(cached.assignmentEpoch(), Map.copyOf(relabeled)));
+    }
+
     /**
      * Remove the static member mapping if the removed member is static.
      *
diff --git 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTest.java
 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTest.java
index 7636eb7a202..c1bbff94398 100644
--- 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTest.java
+++ 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTest.java
@@ -19178,6 +19178,68 @@ public class GroupMetadataManagerTest {
             .noneMatch(r -> r.key() instanceof 
StreamsGroupTargetAssignmentMemberKey));
     }
 
+    @Test
+    public void 
testStreamsGroupKeepsNoRefinedAssignmentWhenWarmupsAreDisabled() {
+        String groupId = "fooup";
+        String memberId = Uuid.randomUuid().toString();
+        String subtopology1 = "subtopology1";
+        String fooTopicName = "foo";
+        Uuid fooTopicId = Uuid.randomUuid();
+        Topology topology = new Topology().setSubtopologies(List.of(
+            new 
Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
+        ));
+
+        CoordinatorMetadataImage metadataImage = new MetadataImageBuilder()
+            .addTopic(fooTopicId, fooTopicName, 4)
+            .buildCoordinatorMetadataImage();
+        long groupMetadataHash = computeGroupHash(Map.of(
+            fooTopicName, computeTopicHash(fooTopicName, metadataImage)
+        ));
+
+        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
+        GroupMetadataManagerTestContext context = new 
GroupMetadataManagerTestContext.Builder()
+            .withStreamsGroupTaskAssignors(List.of(assignor))
+            .withMetadataImage(metadataImage)
+            .withStreamsGroup(new StreamsGroupBuilder(groupId, 10)
+                .withMember(streamsGroupMemberBuilderWithDefaults(memberId)
+                    .setMemberEpoch(10)
+                    .setPreviousMemberEpoch(10)
+                    
.setAssignedTasks(mkTasksTupleWithCommonEpoch(TaskRole.ACTIVE, 10,
+                        TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2)))
+                    .build())
+                .withTopology(StreamsTopology.fromHeartbeatRequest(topology))
+                .withTargetAssignment(memberId, 
TaskAssignmentTestUtil.mkTasksTuple(TaskRole.ACTIVE,
+                    TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2)))
+                .withTargetAssignmentEpoch(10)
+                .withMetadataHash(groupMetadataHash)
+                .withValidatedTopologyEpoch(0)
+                .withLastAssignmentConfigs(Map.of("num.standby.replicas", "0"))
+            )
+            .build();
+
+        Properties groupConfig = new Properties();
+        
groupConfig.setProperty(GroupConfig.STREAMS_NUM_WARMUP_REPLICAS_CONFIG, "0");
+        context.updateGroupConfig(groupId, groupConfig);
+
+        context.streamsGroupHeartbeat(
+            new StreamsGroupHeartbeatRequestData()
+                .setGroupId(groupId)
+                .setMemberId(memberId)
+                .setMemberEpoch(10)
+                .setProcessId("process-id")
+                .setRebalanceTimeoutMs(1500)
+                .setActiveTasks(List.of(new 
StreamsGroupHeartbeatRequestData.TaskIds()
+                    .setSubtopologyId(subtopology1)
+                    .setPartitions(List.of(0, 1, 2))))
+                .setStandbyTasks(List.of())
+                .setWarmupTasks(List.of()));
+
+        // With warm-up tasks disabled the intermediate assignment is just the 
target assignment, which the caller has
+        // anyway, so nothing is frozen for the group -- the group keeps no 
per-epoch state it would never read.
+        StreamsGroup group = 
context.groupMetadataManager.streamsGroup(groupId);
+        assertNull(group.refinedAssignment(group.assignmentEpoch()));
+    }
+
     @Test
     public void testStreamsGroupHeartbeatStoresTaskOffsetsWithoutPersisting() {
         String groupId = "fooup";
diff --git 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/StreamsGroupStaticMemberGroupMetadataManagerTest.java
 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/StreamsGroupStaticMemberGroupMetadataManagerTest.java
index 48fe6bb834a..68f1c763d23 100644
--- 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/StreamsGroupStaticMemberGroupMetadataManagerTest.java
+++ 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/StreamsGroupStaticMemberGroupMetadataManagerTest.java
@@ -76,6 +76,7 @@ import static 
org.apache.kafka.coordinator.group.StreamsGroupTestUtil.streamsGro
 import static 
org.apache.kafka.coordinator.group.StreamsGroupTestUtil.streamsTopicFixture;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -1524,6 +1525,60 @@ class StreamsGroupStaticMemberGroupMetadataManagerTest {
         ));
     }
 
+    @Test
+    public void testStaticMemberReplacementRelabelsCachedRefinedAssignment() {
+        int groupEpoch = DEFAULT_GROUP_EPOCH;
+
+        String groupId = "fooup";
+        String oldMemberId = Uuid.randomUuid().toString();
+        String rejoinMemberId = Uuid.randomUuid().toString();
+        String instanceId = Uuid.randomUuid().toString();
+
+        StreamsTopicFixture topic = streamsTopicFixture("subtopology1", "foo", 
4);
+        TasksTuple oldTargetAssignment = topic.targetAssignment(0, 1, 2, 3);
+        TasksTupleWithEpochs assignedTasks = topic.assignedTasks(groupEpoch, 
0, 1, 2, 3);
+
+        StreamsGroupMember oldMember = 
streamsGroupMemberBuilderWithDefaults(oldMemberId, instanceId)
+            .setMemberEpoch(LEAVE_GROUP_STATIC_MEMBER_EPOCH)
+            .setPreviousMemberEpoch(groupEpoch)
+            .setAssignedTasks(resetAssignedTasksEpochsToZero(assignedTasks))
+            .build();
+
+        GroupMetadataManagerTestContext context = new 
GroupMetadataManagerTestContext.Builder()
+            .withStreamsGroupTaskAssignors(List.of(new 
MockTaskAssignor("sticky")))
+            .withMetadataImage(topic.metadataImage())
+            .withStreamsGroup(new StreamsGroupBuilder(groupId, groupEpoch)
+                .withMember(oldMember)
+                .withTargetAssignment(oldMemberId, oldTargetAssignment)
+                .withTargetAssignmentEpoch(groupEpoch)
+                
.withTopology(StreamsTopology.fromHeartbeatRequest(topic.topology()))
+                .withValidatedTopologyEpoch(0)
+                .withMetadataHash(topic.metadataHash())
+                .withLastAssignmentConfigs(getDefaultAssignmentConfigs()))
+            
.withConfig(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG,
 GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_DEFAULT)
+            .build();
+
+        // Prime the frozen intermediate assignment for the current assignment 
epoch, keyed by the OLD member ID, as a
+        // previous heartbeat of the departed member would have left it.
+        StreamsGroup streamsGroup = 
context.groupMetadataManager.streamsGroup(groupId);
+        streamsGroup.setRefinedAssignment(groupEpoch, Map.of(oldMemberId, 
oldTargetAssignment));
+
+        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> 
result = context.streamsGroupHeartbeat(
+            staticJoinHeartbeat(groupId, rejoinMemberId, instanceId, 
DEFAULT_PROCESS_ID)
+        );
+
+        // A static replacement relabels the target assignment oldMemberId -> 
rejoinMemberId WITHOUT advancing the
+        // assignment epoch, so the epoch alone cannot tell the cached entry 
apart from a still-valid one. Unless the
+        // cached entry is re-keyed too, the replacing member is sliced out of 
a map that only knows the old ID and gets
+        // no tasks at all.
+        assertEquals(groupEpoch, result.response().data().memberEpoch());
+        assertEquals(
+            oldTargetAssignment.activeTasks(),
+            
streamsGroup.refinedAssignment(groupEpoch).get(rejoinMemberId).activeTasks()
+        );
+        
assertFalse(streamsGroup.refinedAssignment(groupEpoch).containsKey(oldMemberId));
+    }
+
     @Test
     public void 
testStaticMemberRejoinsWithSameMemberIdAndDifferentInstanceId() {
         int groupEpoch = DEFAULT_GROUP_EPOCH;
diff --git 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/AssignmentRefinerTest.java
 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/AssignmentRefinerTest.java
new file mode 100644
index 00000000000..eeb105d1fee
--- /dev/null
+++ 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/AssignmentRefinerTest.java
@@ -0,0 +1,154 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.coordinator.group.streams;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class AssignmentRefinerTest {
+
+    private static TasksTuple active(final Map<String, Set<Integer>> 
activeTasks) {
+        return new TasksTuple(activeTasks, Map.of(), Map.of());
+    }
+
+    @Test
+    public void shouldPreserveActiveTaskCountOfUnchangedAssignment() {
+        final Map<String, TasksTuple> targetAssignment = Map.of(
+            "memberA", active(Map.of("0", Set.of(0, 1))),
+            "memberB", active(Map.of("0", Set.of(2)))
+        );
+
+        
assertTrue(AssignmentRefiner.preservesActiveTaskCount(targetAssignment, 
targetAssignment));
+    }
+
+    @Test
+    public void 
shouldPreserveActiveTaskCountWhenATaskIsHeldBackWithItsCurrentOwner() {
+        // What a refinement step does to stage a migration: the target 
assignment moves 0_2 to memberB, the refined
+        // assignment leaves it with memberA while memberB warms it up. The 
active tasks themselves are unchanged.
+        final Map<String, TasksTuple> targetAssignment = Map.of(
+            "memberA", active(Map.of("0", Set.of(0, 1))),
+            "memberB", active(Map.of("0", Set.of(2)))
+        );
+        final Map<String, TasksTuple> refinedAssignment = Map.of(
+            "memberA", active(Map.of("0", Set.of(0, 1, 2))),
+            "memberB", new TasksTuple(Map.of(), Map.of(), Map.of("0", 
Set.of(2)))
+        );
+
+        
assertTrue(AssignmentRefiner.preservesActiveTaskCount(targetAssignment, 
refinedAssignment));
+    }
+
+    @Test
+    public void shouldPreserveActiveTaskCountWhenStandbysAreDeferred() {
+        // A refinement step may defer a standby to a later step, which is not 
a defect this check is about.
+        final Map<String, TasksTuple> targetAssignment = Map.of(
+            "memberA", new TasksTuple(Map.of("0", Set.of(0)), Map.of("0", 
Set.of(1)), Map.of()),
+            "memberB", new TasksTuple(Map.of("0", Set.of(1)), Map.of("0", 
Set.of(0)), Map.of())
+        );
+        final Map<String, TasksTuple> refinedAssignment = Map.of(
+            "memberA", active(Map.of("0", Set.of(0))),
+            "memberB", active(Map.of("0", Set.of(1)))
+        );
+
+        
assertTrue(AssignmentRefiner.preservesActiveTaskCount(targetAssignment, 
refinedAssignment));
+    }
+
+    @Test
+    public void shouldNotPreserveActiveTaskCountWhenATaskWasDropped() {
+        final Map<String, TasksTuple> targetAssignment = Map.of(
+            "memberA", active(Map.of("0", Set.of(0, 1))),
+            "memberB", active(Map.of("0", Set.of(2)))
+        );
+        final Map<String, TasksTuple> refinedAssignment = Map.of(
+            "memberA", active(Map.of("0", Set.of(0, 1))),
+            "memberB", active(Map.of())
+        );
+
+        
assertFalse(AssignmentRefiner.preservesActiveTaskCount(targetAssignment, 
refinedAssignment));
+    }
+
+    @Test
+    public void 
shouldNotPreserveActiveTaskCountWhenASubtopologyWasDroppedEntirely() {
+        final Map<String, TasksTuple> targetAssignment = Map.of(
+            "memberA", active(Map.of("0", Set.of(0), "1", Set.of(0)))
+        );
+        final Map<String, TasksTuple> refinedAssignment = Map.of(
+            "memberA", active(Map.of("0", Set.of(0)))
+        );
+
+        
assertFalse(AssignmentRefiner.preservesActiveTaskCount(targetAssignment, 
refinedAssignment));
+    }
+
+    @Test
+    public void 
shouldNotPreserveActiveTaskCountWhenATaskWasHandedToTwoMembers() {
+        final Map<String, TasksTuple> targetAssignment = Map.of(
+            "memberA", active(Map.of("0", Set.of(0))),
+            "memberB", active(Map.of("0", Set.of(1)))
+        );
+        final Map<String, TasksTuple> refinedAssignment = Map.of(
+            "memberA", active(Map.of("0", Set.of(0, 1))),
+            "memberB", active(Map.of("0", Set.of(1)))
+        );
+
+        
assertFalse(AssignmentRefiner.preservesActiveTaskCount(targetAssignment, 
refinedAssignment));
+    }
+
+    @Test
+    public void shouldNotPreserveActiveTaskCountWhenATaskWasInvented() {
+        final Map<String, TasksTuple> targetAssignment = Map.of(
+            "memberA", active(Map.of("0", Set.of(0)))
+        );
+        final Map<String, TasksTuple> refinedAssignment = Map.of(
+            "memberA", active(Map.of("0", Set.of(0, 1)))
+        );
+
+        
assertFalse(AssignmentRefiner.preservesActiveTaskCount(targetAssignment, 
refinedAssignment));
+    }
+
+    @Test
+    public void shouldNotDetectADropAndADuplicateCancellingEachOtherOut() {
+        // The accepted blind spot of counting: 0_0 was dropped and 0_1 handed 
to both members, so the count still
+        // matches. It takes two coordinated mistakes in one derivation, and 
the exhaustive invariant is covered by the
+        // refiner's own tests. If this check is ever strengthened, this test 
is what should fail.
+        final Map<String, TasksTuple> targetAssignment = Map.of(
+            "memberA", active(Map.of("0", Set.of(0))),
+            "memberB", active(Map.of("0", Set.of(1)))
+        );
+        final Map<String, TasksTuple> refinedAssignment = Map.of(
+            "memberA", active(Map.of("0", Set.of(1))),
+            "memberB", active(Map.of("0", Set.of(1)))
+        );
+
+        
assertTrue(AssignmentRefiner.preservesActiveTaskCount(targetAssignment, 
refinedAssignment));
+    }
+
+    @Test
+    public void 
shouldPreserveActiveTaskCountWhenTheTargetAssignmentItselfDuplicatesATask() {
+        // A target assignment that places an active task twice is the 
assignor's defect, not the refinement's, so a
+        // refinement that keeps it as-is is not blamed for it.
+        final Map<String, TasksTuple> targetAssignment = Map.of(
+            "memberA", active(Map.of("0", Set.of(0))),
+            "memberB", active(Map.of("0", Set.of(0)))
+        );
+
+        
assertTrue(AssignmentRefiner.preservesActiveTaskCount(targetAssignment, 
targetAssignment));
+    }
+}
diff --git 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTest.java
 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTest.java
index a4bc5bdad8f..44bc85689c1 100644
--- 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTest.java
+++ 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTest.java
@@ -136,6 +136,101 @@ public class StreamsGroupTest {
         assertEquals(newerOffsets, streamsGroup.taskOffsets("member-id"));
     }
 
+    @Test
+    public void testCacheAndRetrieveRefinedAssignment() {
+        StreamsGroup streamsGroup = createStreamsGroup("foo");
+
+        assertNull(streamsGroup.refinedAssignment(0));
+
+        Map<String, TasksTuple> refinedAssignment = Map.of(
+            "member-id", new TasksTuple(Map.of("sub-1", Set.of(0)), Map.of(), 
Map.of())
+        );
+        streamsGroup.setRefinedAssignment(5, refinedAssignment);
+
+        assertEquals(refinedAssignment, streamsGroup.refinedAssignment(5));
+        // An intermediate assignment derived for one assignment epoch must 
not be used for another one: a new epoch is
+        // a new refinement step.
+        assertNull(streamsGroup.refinedAssignment(4));
+        assertNull(streamsGroup.refinedAssignment(6));
+    }
+
+    @Test
+    public void testCachedRefinedAssignmentDoesNotTrackTheCallersMap() {
+        StreamsGroup streamsGroup = createStreamsGroup("foo");
+
+        TasksTuple memberTasks = new TasksTuple(Map.of("sub-1", Set.of(0)), 
Map.of(), Map.of());
+        Map<String, TasksTuple> derived = new HashMap<>();
+        derived.put("member-id", memberTasks);
+
+        streamsGroup.setRefinedAssignment(5, derived);
+
+        // The intermediate assignment of an epoch is fixed once derived. A 
refiner that returns the target assignment
+        // as-is hands over a view of it, which changes as records are 
replayed, so the cache must copy rather than alias.
+        derived.put("late-member-id", new TasksTuple(Map.of("sub-1", 
Set.of(1)), Map.of(), Map.of()));
+        derived.remove("member-id");
+
+        assertEquals(Map.of("member-id", memberTasks), 
streamsGroup.refinedAssignment(5));
+    }
+
+    @Test
+    public void testRelabelRefinedAssignment() {
+        StreamsGroup streamsGroup = createStreamsGroup("foo");
+
+        TasksTuple oldMemberTasks = new TasksTuple(Map.of("sub-1", Set.of(0)), 
Map.of(), Map.of());
+        TasksTuple otherMemberTasks = new TasksTuple(Map.of("sub-1", 
Set.of(1)), Map.of(), Map.of());
+        streamsGroup.setRefinedAssignment(5, Map.of(
+            "old-member-id", oldMemberTasks,
+            "other-member-id", otherMemberTasks
+        ));
+
+        streamsGroup.relabelRefinedAssignment("old-member-id", 
"new-member-id");
+
+        // The replaced member's slice moves to its new ID and every other 
member's slice is left exactly as derived:
+        // the epoch's decisions must not change while the group reconciles 
towards it.
+        assertEquals(
+            Map.of("new-member-id", oldMemberTasks, "other-member-id", 
otherMemberTasks),
+            streamsGroup.refinedAssignment(5)
+        );
+        // Re-keying does not make the entry apply to a different epoch.
+        assertNull(streamsGroup.refinedAssignment(6));
+    }
+
+    @Test
+    public void testRelabelRefinedAssignmentOfUnknownMemberIsANoOp() {
+        StreamsGroup streamsGroup = createStreamsGroup("foo");
+
+        Map<String, TasksTuple> refinedAssignment = Map.of(
+            "member-id", new TasksTuple(Map.of("sub-1", Set.of(0)), Map.of(), 
Map.of())
+        );
+        streamsGroup.setRefinedAssignment(5, refinedAssignment);
+
+        // Nothing was derived for that member, so there is nothing to re-key 
-- and in particular the cached
+        // assignment of the epoch must not be disturbed.
+        streamsGroup.relabelRefinedAssignment("not-in-the-cache", 
"new-member-id");
+
+        assertEquals(refinedAssignment, streamsGroup.refinedAssignment(5));
+    }
+
+    @Test
+    public void testRefinedAssignmentCacheHoldsOnlyTheLatestEpoch() {
+        StreamsGroup streamsGroup = createStreamsGroup("foo");
+
+        Map<String, TasksTuple> refinedAssignmentOfEpoch5 = Map.of(
+            "member-id", new TasksTuple(Map.of("sub-1", Set.of(0)), Map.of(), 
Map.of())
+        );
+        Map<String, TasksTuple> refinedAssignmentOfEpoch6 = Map.of(
+            "member-id", new TasksTuple(Map.of("sub-1", Set.of(0, 1)), 
Map.of(), Map.of())
+        );
+
+        streamsGroup.setRefinedAssignment(5, refinedAssignmentOfEpoch5);
+        streamsGroup.setRefinedAssignment(6, refinedAssignmentOfEpoch6);
+
+        // The cache is a single slot, so deriving for a new epoch evicts the 
previous one rather than accumulating an
+        // entry per epoch. Only the epoch the members are currently 
reconciling towards is ever asked for.
+        assertEquals(refinedAssignmentOfEpoch6, 
streamsGroup.refinedAssignment(6));
+        assertNull(streamsGroup.refinedAssignment(5));
+    }
+
     @Test
     public void testRemoveMemberClearsTaskOffsets() {
         StreamsGroup streamsGroup = createStreamsGroup("foo");

Reply via email to