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 6fd96a3d391 KAFKA-20665: Bump group epoch for warm-up refinement steps 
(#22748)
6fd96a3d391 is described below

commit 6fd96a3d3916b69eb1c7e0b488fc8882c592af1a
Author: Matthias J. Sax <[email protected]>
AuthorDate: Tue Jul 28 16:17:39 2026 -0700

    KAFKA-20665: Bump group epoch for warm-up refinement steps (#22748)
    
    Replace the streams-heartbeat bumpGroupEpoch boolean with an
    AssignmentUpdate enum (NONE/RECOMPUTE/REFINE). RECOMPUTE keeps today's
    behavior (re-run the assignor for a new final target). REFINE only
    advances the assignment epoch of the unchanged final target via the
    target-assignment metadata record, without re-running the assignor, so
    members re-reconcile toward the next intermediate (warm-up) step.
    
    A REFINE bump is triggered by hasHotWarmupTask(): a caught-up warm-up
    (lag within acceptable.recovery.lag). Each refinement step needs its own
    assignment epoch because members only pick up a changed assignment when
    the epoch advances, and a new assignment epoch requires a group-epoch
    bump.
    
    Reviewers: Alieh Saeedi <[email protected]>, Sean Quah
     <[email protected]>, David Jacot <[email protected]>
---
 .../coordinator/group/GroupMetadataManager.java    | 97 +++++++++++++++++-----
 .../coordinator/group/streams/TasksTuple.java      | 26 ++++++
 .../group/GroupMetadataManagerTest.java            | 77 +++++++++++++++++
 .../coordinator/group/streams/TasksTupleTest.java  | 47 +++++++++++
 4 files changed, 226 insertions(+), 21 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 eaba792fdac..e801efc8722 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
@@ -2142,8 +2142,12 @@ public class GroupMetadataManager {
         StreamsGroupMember updatedMember = updatedMemberBuilder.build();
         
         // If the member is new or has changed, a 
StreamsGroupMemberMetadataValue record is written to the __consumer_offsets 
partition
-        // to persist the change, and bump the group epoch later.
-        boolean bumpGroupEpoch = hasStreamsMemberMetadataChanged(groupId, 
instanceId, member, updatedMember, records);
+        // to persist the change, and bump the group epoch later. We track 
whether (and why) the group epoch is bumped:
+        // RECOMPUTE re-runs the assignor to produce a new final target 
assignment; REFINE only advances the assignment epoch to move
+        // the in-memory refinement (warm-up promotion) forward without 
recomputing the final target.
+        AssignmentUpdate assignmentUpdate = 
hasStreamsMemberMetadataChanged(groupId, instanceId, member, updatedMember, 
records)
+            ? AssignmentUpdate.RECOMPUTE
+            : AssignmentUpdate.NONE;
 
         // 2. Initialize/Update the group topology.
         // If the topology is new or has changed, a StreamsGroupTopologyValue 
record is written to the __consumer_offsets partition to persist
@@ -2166,7 +2170,7 @@ public class GroupMetadataManager {
             if (metadataHash != group.metadataHash()) {
                 log.info("[GroupId {}][MemberId {}] Computed new metadata 
hash: {}.",
                     groupId, memberId, metadataHash);
-                bumpGroupEpoch = true;
+                assignmentUpdate = AssignmentUpdate.RECOMPUTE;
                 reconfigureTopology = true;
             }
 
@@ -2204,21 +2208,37 @@ public class GroupMetadataManager {
 
         // We validated a topology that was not validated before, so bump the 
group epoch as we may have to reassign tasks.
         if (validatedTopologyEpoch != group.validatedTopologyEpoch()) {
-            bumpGroupEpoch = true;
+            assignmentUpdate = AssignmentUpdate.RECOMPUTE;
         }
 
         // Check if assignment configurations have changed
         Map<String, String> currentAssignmentConfigs = 
streamsGroupAssignmentConfigs(groupId);
         Map<String, String> storedAssignmentConfigs = 
group.lastAssignmentConfigs();
-        if (!bumpGroupEpoch && 
!currentAssignmentConfigs.equals(storedAssignmentConfigs)) {
+        if (assignmentUpdate == AssignmentUpdate.NONE && 
!currentAssignmentConfigs.equals(storedAssignmentConfigs)) {
             log.info("[GroupId {}][MemberId {}] Assignment configurations 
changed to {}. Triggering rebalance.",
                 groupId, memberId, currentAssignmentConfigs);
-            bumpGroupEpoch = true;
+            assignmentUpdate = AssignmentUpdate.RECOMPUTE;
+        }
+
+        TasksTuple refinedAssignment = 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())) {
+                assignmentUpdate = AssignmentUpdate.REFINED;
+            }
         }
 
         // Actually bump the group epoch
         int groupEpoch = group.groupEpoch();
-        if (bumpGroupEpoch) {
+        if (assignmentUpdate != AssignmentUpdate.NONE) {
             groupEpoch += 1;
             records.add(newStreamsGroupMetadataRecord(
                 groupId,
@@ -2259,18 +2279,22 @@ public class GroupMetadataManager {
             metadataImage,
             records,
             Optional.of(returnedStatus),
-            currentAssignmentConfigs
+            currentAssignmentConfigs,
+            assignmentUpdate == AssignmentUpdate.REFINED
         );
 
-        // 4b. Refine the target assignment into the intermediate assignment 
(with warm-up tasks) the member should be
-        // reconciled toward. Runs on every heartbeat, before reconciliation. 
No-op for now.
-        TasksTuple refinedTarget = refine(
-            updatedMember,
-            updateTargetAssignmentResult.targetAssignment,
-            group.taskOffsets(),
-            streamsGroupNumWarmupReplicas(group.groupId()),
-            streamsGroupAcceptableRecoveryLag(group.groupId())
-        );
+        // 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,
+                updateTargetAssignmentResult.targetAssignment,
+                group.taskOffsets(),
+                streamsGroupNumWarmupReplicas(group.groupId()),
+                streamsGroupAcceptableRecoveryLag(group.groupId())
+            );
+        }
 
         // 5. Reconcile the member's assignment with the (refined) target 
assignment if the member is not
         // fully reconciled yet.
@@ -2281,7 +2305,7 @@ public class GroupMetadataManager {
             group::currentStandbyTaskProcessIds,
             group::currentWarmupTaskProcessIds,
             updateTargetAssignmentResult.targetAssignmentEpoch(),
-            refinedTarget,
+            refinedAssignment,
             ownedActiveTasks,
             ownedStandbyTasks,
             ownedWarmupTasks,
@@ -4336,6 +4360,22 @@ public class GroupMetadataManager {
         }
     }
 
+    /**
+     * How a streams-group heartbeat updates the group epoch and target 
assignment.
+     * <ul>
+     *   <li>{@code NONE} - nothing changed; the group epoch is not 
bumped.</li>
+     *   <li>{@code RECOMPUTE} - the group changed (membership, topology or 
configuration); bump the group epoch and re-run the
+     *       assignor to produce a new final target assignment.</li>
+     *   <li>{@code REFINE} - a warm-up task has caught up; bump the group 
epoch to advance the in-memory refinement one step
+     *       WITHOUT re-running the assignor (the final target assignment is 
unchanged).</li>
+     * </ul>
+     */
+    private enum AssignmentUpdate {
+        NONE,
+        RECOMPUTE,
+        REFINED
+    }
+
     /**
      * Updates the target assignment according to the updated member and 
metadata image.
      *
@@ -4345,6 +4385,8 @@ public class GroupMetadataManager {
      * @param metadataImage        The metadata image.
      * @param records              The list to accumulate any new records.
      * @param returnedStatus       A mutable collection of status to be 
returned in the response.
+     * @param refineOnly           If true, only advance the assignment epoch 
of the unchanged final target (warm-up refinement step)
+     *                             instead of re-running the assignor.
      * @return The target assignment epoch and the full per-member target 
assignment.
      */
     private UpdateTargetAssignmentResult<Map<String, TasksTuple>> 
maybeUpdateStreamsTargetAssignment(
@@ -4355,7 +4397,8 @@ public class GroupMetadataManager {
         CoordinatorMetadataImage metadataImage,
         List<CoordinatorRecord> records,
         Optional<List<Status>> returnedStatus,
-        Map<String, String> assignmentConfigs
+        Map<String, String> assignmentConfigs,
+        boolean refineOnly
     ) {
         boolean initialDelayActive = 
timer.isScheduled(streamsInitialRebalanceKey(group.groupId()));
         if (initialDelayActive) {
@@ -4387,6 +4430,17 @@ public class GroupMetadataManager {
             return new UpdateTargetAssignmentResult<>(group.assignmentEpoch(), 
updatedMembersAndTargetAssignment.targetAssignment());
         }
 
+        // The second condition rules out taking the shortcut while an 
assignment is still pending (deferred by the
+        // assignment interval, or offloaded): the caller only refines a 
STABLE group, where both epochs are equal, but
+        // if that ever changes, closing the epoch gap here would drop the 
pending assignor run instead of deferring to it.
+        if (refineOnly && group.assignmentEpoch() >= group.groupEpoch()) {
+            // A refinement step only advances the assignment epoch of the 
unchanged final target so that members
+            // re-reconcile toward the next in-memory intermediate assignment. 
The assignor is not run and the per-member target
+            // assignment records are left untouched; only the (small) 
target-assignment metadata record carrying the epoch is written.
+            
records.add(newStreamsGroupTargetAssignmentMetadataRecord(group.groupId(), 
groupEpoch, group.assignmentTimestamp()));
+            return new UpdateTargetAssignmentResult<>(groupEpoch, 
updatedMembersAndTargetAssignment.targetAssignment());
+        }
+
         boolean canComputeNextTargetAssignment = 
canComputeNextTargetAssignment(
             group.assignmentTimestamp(),
             streamsGroupAssignmentIntervalMs(group.groupId()),
@@ -4470,7 +4524,7 @@ public class GroupMetadataManager {
      *
      * @return The member's intermediate assignment tuple.
      */
-    private static TasksTuple refine(
+    private static TasksTuple maybeRefineAssignment(
         final StreamsGroupMember member,
         final Map<String, TasksTuple> targetAssignment,
         final Map<String, MemberTaskOffsets> taskOffsets,
@@ -4516,7 +4570,8 @@ public class GroupMetadataManager {
                 metadataImage,
                 records,
                 Optional.empty(),
-                group.lastAssignmentConfigs()
+                group.lastAssignmentConfigs(),
+                false
             );
 
             return new CoordinatorResult<>(records, null);
diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/TasksTuple.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/TasksTuple.java
index 5e2e6e04d70..c0f105a94d0 100644
--- 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/TasksTuple.java
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/TasksTuple.java
@@ -79,6 +79,32 @@ public record TasksTuple(Map<String, Set<Integer>> 
activeTasks,
         );
     }
 
+    /**
+     * Compares this tuple's task sets to those of a {@link 
TasksTupleWithEpochs}, ignoring the assignment epochs
+     * carried by the active tasks of {@code other}. Used to decide whether 
the intermediate assignment produced by
+     * the refiner differs from the assignment a member is currently 
reconciled to (an active task set is considered
+     * unchanged even if only its epochs would differ).
+     *
+     * @param other Another task tuple with epochs.
+     * @return true if the active, standby and warm-up task sets are equal 
(active-task epochs are not compared).
+     */
+    public boolean sameTasks(TasksTupleWithEpochs other) {
+        if (!warmupTasks.equals(other.warmupTasks()) || 
!standbyTasks.equals(other.standbyTasks())) {
+            return false;
+        }
+        Map<String, Map<Integer, Integer>> otherActiveTasks = 
other.activeTasksWithEpochs();
+        if (activeTasks.size() != otherActiveTasks.size()) {
+            return false;
+        }
+        for (Map.Entry<String, Set<Integer>> entry : activeTasks.entrySet()) {
+            Map<Integer, Integer> otherPartitions = 
otherActiveTasks.get(entry.getKey());
+            if (otherPartitions == null || 
!entry.getValue().equals(otherPartitions.keySet())) {
+                return false;
+            }
+        }
+        return true;
+    }
+
     /**
      * Creates a {{@link TasksTuple}} from a
      * {{@link 
org.apache.kafka.coordinator.group.generated.StreamsGroupTargetAssignmentMemberValue}}.
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 32c60e5384c..b5dfd129c16 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
@@ -123,6 +123,7 @@ import 
org.apache.kafka.coordinator.group.generated.ShareGroupTargetAssignmentMe
 import 
org.apache.kafka.coordinator.group.generated.StreamsGroupMemberMetadataValue.Endpoint;
 import org.apache.kafka.coordinator.group.generated.StreamsGroupMetadataKey;
 import org.apache.kafka.coordinator.group.generated.StreamsGroupMetadataValue;
+import 
org.apache.kafka.coordinator.group.generated.StreamsGroupTargetAssignmentMemberKey;
 import org.apache.kafka.coordinator.group.generated.StreamsGroupTopologyValue;
 import org.apache.kafka.coordinator.group.metrics.GroupCoordinatorMetricsShard;
 import org.apache.kafka.coordinator.group.modern.Assignment;
@@ -18994,6 +18995,82 @@ public class GroupMetadataManagerTest {
         assertEquals(MemberTaskOffsets.EMPTY, group.taskOffsets(memberId));
     }
 
+    @Test
+    public void 
testStreamsGroupHeartbeatRefinementStepBumpsEpochWithoutRunningAssignor() {
+        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)
+        ));
+
+        // maybeRefineAssignment() still returns the target unchanged, so the 
refinement predicate can only fire when a
+        // member's assigned tasks differ from its target. The setup below 
forces that: assigned {0,1,2} vs target
+        // {0,1,2,3}, with all epochs equal so the group still reports STABLE. 
Real reconciliation never leaves those two
+        // out of sync at the same epoch; a real refiner will make this case 
reachable properly. Metadata, topology and
+        // configs all match, so refinement is the only possible reason to 
bump the group epoch.
+        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, 3)))
+                .withTargetAssignmentEpoch(10)
+                .withTargetAssignmentTimestamp(12345L)
+                .withMetadataHash(groupMetadataHash)
+                .withValidatedTopologyEpoch(0)
+                .withLastAssignmentConfigs(Map.of("num.standby.replicas", "0"))
+            )
+            .build();
+
+        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> 
result = 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()));
+
+        // The refinement step bumped the group/assignment epoch to 11; the 
member reconciles toward it.
+        assertEquals(11, result.response().data().memberEpoch());
+        assertTrue(result.records().stream()
+            .filter(r -> r.key() instanceof StreamsGroupMetadataKey)
+            .map(r -> (StreamsGroupMetadataValue) r.value().message())
+            .anyMatch(v -> v.epoch() == 11));
+
+        // A refinement step only advances the assignment epoch of the 
unchanged final target. The target-assignment
+        // metadata record carries the bumped epoch and PRESERVES the 
assignment timestamp (12345L) rather than
+        // resetting it to the current time, so the assignment interval is not 
restarted.
+        assertTrue(result.records().contains(
+            
StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataRecord(groupId,
 11, 12345L)));
+
+        // The assignor was not run: the final target is unchanged, so no 
per-member target-assignment record is written.
+        assertTrue(result.records().stream()
+            .noneMatch(r -> r.key() instanceof 
StreamsGroupTargetAssignmentMemberKey));
+    }
+
     @Test
     public void testStreamsGroupHeartbeatStoresTaskOffsetsWithoutPersisting() {
         String groupId = "fooup";
diff --git 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/TasksTupleTest.java
 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/TasksTupleTest.java
index 94eee2484e3..4d4ed6a3542 100644
--- 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/TasksTupleTest.java
+++ 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/TasksTupleTest.java
@@ -166,6 +166,53 @@ public class TasksTupleTest {
         assertTrue(tuple1.containsAny(tuple5));
     }
 
+    @Test
+    public void testSameTasks() {
+        TasksTuple tuple = new TasksTuple(
+            Map.of(SUBTOPOLOGY_1, Set.of(1, 2, 3)),
+            Map.of(SUBTOPOLOGY_2, Set.of(4, 5, 6)),
+            Map.of(SUBTOPOLOGY_3, Set.of(7, 8, 9))
+        );
+
+        // Same task sets, but the active tasks carry (different) epochs -> 
still the same.
+        assertTrue(tuple.sameTasks(new TasksTupleWithEpochs(
+            Map.of(SUBTOPOLOGY_1, Map.of(1, 5, 2, 7, 3, 9)),
+            Map.of(SUBTOPOLOGY_2, Set.of(4, 5, 6)),
+            Map.of(SUBTOPOLOGY_3, Set.of(7, 8, 9))
+        )));
+
+        // Different active task set -> not the same.
+        assertFalse(tuple.sameTasks(new TasksTupleWithEpochs(
+            Map.of(SUBTOPOLOGY_1, Map.of(1, 5, 2, 7)),
+            Map.of(SUBTOPOLOGY_2, Set.of(4, 5, 6)),
+            Map.of(SUBTOPOLOGY_3, Set.of(7, 8, 9))
+        )));
+
+        // Extra active subtopology (differing key set) -> not the same.
+        assertFalse(tuple.sameTasks(new TasksTupleWithEpochs(
+            Map.of(SUBTOPOLOGY_1, Map.of(1, 5, 2, 7, 3, 9), SUBTOPOLOGY_2, 
Map.of(1, 1)),
+            Map.of(SUBTOPOLOGY_2, Set.of(4, 5, 6)),
+            Map.of(SUBTOPOLOGY_3, Set.of(7, 8, 9))
+        )));
+
+        // Different standby task set -> not the same.
+        assertFalse(tuple.sameTasks(new TasksTupleWithEpochs(
+            Map.of(SUBTOPOLOGY_1, Map.of(1, 5, 2, 7, 3, 9)),
+            Map.of(SUBTOPOLOGY_2, Set.of(4, 5)),
+            Map.of(SUBTOPOLOGY_3, Set.of(7, 8, 9))
+        )));
+
+        // Different warm-up task set -> not the same.
+        assertFalse(tuple.sameTasks(new TasksTupleWithEpochs(
+            Map.of(SUBTOPOLOGY_1, Map.of(1, 5, 2, 7, 3, 9)),
+            Map.of(SUBTOPOLOGY_2, Set.of(4, 5, 6)),
+            Map.of(SUBTOPOLOGY_3, Set.of(7, 8))
+        )));
+
+        // Two empty tuples are the same.
+        assertTrue(new TasksTuple(Map.of(), Map.of(), 
Map.of()).sameTasks(TasksTupleWithEpochs.EMPTY));
+    }
+
     @Test
     public void testIsEmpty() {
         TasksTuple emptyTuple = new TasksTuple(Map.of(), Map.of(), Map.of());

Reply via email to