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

lucasbru 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 4e18e9b1633 KAFKA-20623: Heartbeat extension for streams group 
topology description plugin (1/3) (#22551)
4e18e9b1633 is described below

commit 4e18e9b1633fe98cf4dd342fab23b59885327f64
Author: TengYao Chi <[email protected]>
AuthorDate: Tue Jun 16 08:14:43 2026 +0100

    KAFKA-20623: Heartbeat extension for streams group topology description 
plugin (1/3) (#22551)
    
    JIRA: KAFKA-20623  This PR is a part of KIP-1331
    
    Wires the plugin reference into `GroupCoordinatorService` and adds the
    heartbeat-path  gate that asks streams clients to push their topology
    description.
    
    ### Plugin reference on the service
    - `GroupCoordinatorService.Builder.build()` resolves the plugin
    internally via
      `config.streamsGroupTopologyDescriptionPlugin(Map.of())`
    - The service constructor accepts
    `Optional<StreamsGroupTopologyDescriptionPlugin>` and
      hands it to a new `TopologyDescriptionManager` that owns the plugin
    reference and the
      per-group push back-off.
    
    ### Heartbeat post-processing
    - `StreamsGroupHeartbeatResult` carries three epoch fields now —
      `currentTopologyEpoch`, `storedDescriptionTopologyEpoch`,
      `failedDescriptionTopologyEpoch` — so the service-layer gate can
    decide whether to set
      `TopologyDescriptionRequired=true` without re-reading the group on
    every heartbeat.
    - `GroupMetadataManager` builds the heartbeat result with these fields
    at the four
      existing `StreamsGroupHeartbeatResult` construction sites.
    - `TopologyDescriptionManager.maybeSetTopologyDescriptionRequired(...)`
    runs in the
      `.thenApply(...)` after the heartbeat write. The flag is set when the
    plugin is
      configured, the response has no error, the current epoch is resolved,
    that epoch is
      neither stored nor permanently failed at the plugin, the response does
    not carry a
      `STALE_TOPOLOGY` status, and the per-group back-off window is not in
    effect.
    
    ### Back-off
    - `StreamsGroupTopologyDescriptionBackoff` is a broker-level, per-group
    exponential
      back-off (30 s → 1 h, doubled on each arm at the same topology epoch,
    reset on
      topology-epoch advance).
    - The heartbeat path uses a single atomic `armIfNotActive` compute that
    serves two
      purposes: it prevents two concurrent heartbeats for the same group
    from both
      arming the back-off, and — when the previous window expired without a
    push
      reaching the coordinator (client opted out via
    `topology.description.push.enabled=false`,
      client never recorded the flag, or the push was lost in flight) — it
    continues
      the exponential chain across the re-arm instead of resetting to the
    initial
      delay. Without this, the heartbeat-only re-solicit loop would
    degenerate into a
      fixed 30 s cadence and the documented exponential throttle would never
    engage.
    - The back-off is non-timeline, non-replayed state — rebuilt from
    scratch on broker
      restart. Convergence after a restart is driven by the persisted
      `StoredDescriptionTopologyEpoch` / `FailedDescriptionTopologyEpoch`
    fields on each
      streams group.
    - The `clear(...)` and `armOrExtend(...)` sites consumed by the push and
    DeleteGroups
      paths land in the follow-up PRs along with their respective entry
    points on
      `TopologyDescriptionManager`.
    
    Reviewers: Lucas Brutschy <[email protected]>
---
 .../scala/unit/kafka/server/KafkaApisTest.scala    |  10 +-
 .../coordinator/group/GroupCoordinatorService.java |  39 ++-
 .../coordinator/group/GroupMetadataManager.java    |  32 ++-
 .../group/streams/StreamsGroupHeartbeatResult.java |  32 ++-
 .../StreamsGroupTopologyDescriptionBackoff.java    | 114 +++++++++
 .../StreamsGroupTopologyDescriptionManager.java    | 129 ++++++++++
 .../group/GroupCoordinatorServiceTest.java         |  53 +++-
 ...pCoordinatorServiceTopologyDescriptionTest.java | 285 +++++++++++++++++++++
 .../group/GroupCoordinatorShardTest.java           |   2 +-
 .../streams/StreamsGroupHeartbeatResultTest.java   |  31 ++-
 ...StreamsGroupTopologyDescriptionBackoffTest.java | 152 +++++++++++
 11 files changed, 839 insertions(+), 40 deletions(-)

diff --git a/core/src/test/scala/unit/kafka/server/KafkaApisTest.scala 
b/core/src/test/scala/unit/kafka/server/KafkaApisTest.scala
index e9542974410..701f2db0df0 100644
--- a/core/src/test/scala/unit/kafka/server/KafkaApisTest.scala
+++ b/core/src/test/scala/unit/kafka/server/KafkaApisTest.scala
@@ -11049,7 +11049,7 @@ class KafkaApisTest extends Logging {
     val streamsGroupHeartbeatResponse = new StreamsGroupHeartbeatResponseData()
       .setMemberId("member")
 
-    future.complete(new 
StreamsGroupHeartbeatResult(streamsGroupHeartbeatResponse, util.Map.of(), -1))
+    future.complete(new 
StreamsGroupHeartbeatResult(streamsGroupHeartbeatResponse, util.Map.of(), -1, 
-1, -1))
     val response = 
verifyNoThrottling[StreamsGroupHeartbeatResponse](requestChannelRequest)
     assertEquals(streamsGroupHeartbeatResponse, response.data)
   }
@@ -11119,7 +11119,7 @@ class KafkaApisTest extends Logging {
     val streamsGroupHeartbeatResponse = new StreamsGroupHeartbeatResponseData()
       .setMemberId("member")
 
-    future.complete(new 
StreamsGroupHeartbeatResult(streamsGroupHeartbeatResponse, util.Map.of(), -1))
+    future.complete(new 
StreamsGroupHeartbeatResult(streamsGroupHeartbeatResponse, util.Map.of(), -1, 
-1, -1))
     val response = 
verifyNoThrottling[StreamsGroupHeartbeatResponse](requestChannelRequest)
     assertEquals(streamsGroupHeartbeatResponse, response.data)
   }
@@ -11351,7 +11351,7 @@ class KafkaApisTest extends Logging {
     val streamsGroupHeartbeatResponse = new StreamsGroupHeartbeatResponseData()
       .setMemberId("member")
 
-    future.complete(new 
StreamsGroupHeartbeatResult(streamsGroupHeartbeatResponse, missingTopics, -1))
+    future.complete(new 
StreamsGroupHeartbeatResult(streamsGroupHeartbeatResponse, missingTopics, -1, 
-1, -1))
     val response = 
verifyNoThrottling[StreamsGroupHeartbeatResponse](requestChannelRequest)
     assertEquals(streamsGroupHeartbeatResponse, response.data)
     verify(autoTopicCreationManager).createStreamsInternalTopics(any(), any(), 
anyLong())
@@ -11400,7 +11400,7 @@ class KafkaApisTest extends Logging {
     val streamsGroupHeartbeatResponse = new StreamsGroupHeartbeatResponseData()
       .setMemberId("member")
 
-    future.complete(new 
StreamsGroupHeartbeatResult(streamsGroupHeartbeatResponse, missingTopics, -1))
+    future.complete(new 
StreamsGroupHeartbeatResult(streamsGroupHeartbeatResponse, missingTopics, -1, 
-1, -1))
     val response = 
verifyNoThrottling[StreamsGroupHeartbeatResponse](requestChannelRequest)
     assertEquals(Errors.NONE.code, response.data.errorCode())
     assertEquals(null, response.data.errorMessage())
@@ -11451,7 +11451,7 @@ class KafkaApisTest extends Logging {
           .setStatusDetail("Internal topics are missing: [test-topic]")
       ))
 
-    future.complete(new 
StreamsGroupHeartbeatResult(streamsGroupHeartbeatResponse, missingTopics, -1))
+    future.complete(new 
StreamsGroupHeartbeatResult(streamsGroupHeartbeatResponse, missingTopics, -1, 
-1, -1))
     val response = 
verifyNoThrottling[StreamsGroupHeartbeatResponse](requestChannelRequest)
     
     assertEquals(Errors.NONE.code, response.data.errorCode())
diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorService.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorService.java
index e879c7083fe..303a7d1f2e0 100644
--- 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorService.java
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorService.java
@@ -98,9 +98,11 @@ import 
org.apache.kafka.coordinator.common.runtime.MultiThreadedEventProcessor;
 import org.apache.kafka.coordinator.common.runtime.PartitionWriter;
 import org.apache.kafka.coordinator.group.GroupCoordinatorShard.DeletedTopic;
 import 
org.apache.kafka.coordinator.group.api.assignor.ConsumerGroupPartitionAssignor;
+import 
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescriptionPlugin;
 import org.apache.kafka.coordinator.group.metrics.GroupCoordinatorMetrics;
 import org.apache.kafka.coordinator.group.streams.StreamsGroupDescribeResult;
 import org.apache.kafka.coordinator.group.streams.StreamsGroupHeartbeatResult;
+import 
org.apache.kafka.coordinator.group.streams.StreamsGroupTopologyDescriptionManager;
 import org.apache.kafka.image.MetadataDelta;
 import org.apache.kafka.image.MetadataImage;
 import org.apache.kafka.image.TopicsDelta;
@@ -250,6 +252,9 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
             String logPrefix = String.format("GroupCoordinator id=%d", nodeId);
             LogContext logContext = new LogContext(String.format("[%s] ", 
logPrefix));
 
+            Optional<StreamsGroupTopologyDescriptionPlugin> 
streamsGroupTopologyDescriptionPlugin =
+                
Optional.ofNullable(config.streamsGroupTopologyDescriptionPlugin(Map.of()));
+
             CoordinatorShardBuilderSupplier<GroupCoordinatorShard, 
CoordinatorRecord> supplier = () ->
                 new GroupCoordinatorShard.Builder(config, groupConfigManager)
                     .withAuthorizerPlugin(authorizerPlugin);
@@ -297,7 +302,9 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
                 groupConfigManager,
                 persister,
                 timer,
-                partitionMetadataClient
+                partitionMetadataClient,
+                streamsGroupTopologyDescriptionPlugin,
+                time
             );
         }
     }
@@ -352,6 +359,14 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
      */
     private final PartitionMetadataClient partitionMetadataClient;
 
+    /**
+     * The broker-level component that owns the streams-group topology 
description plugin
+     * (KIP-1331): plugin reference, per-group push back-off, and the three 
entry points
+     * the service delegates into — heartbeat post-processing, the push RPC, 
and the
+     * pre-tombstone hook on DeleteGroups.
+     */
+    private final StreamsGroupTopologyDescriptionManager 
streamsGroupTopologyDescriptionManager;
+
     /**
      * The number of partitions of the __consumer_offsets topics. This is 
provided
      * when the component is started.
@@ -382,7 +397,9 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
         GroupConfigManager groupConfigManager,
         Persister persister,
         Timer timer,
-        PartitionMetadataClient partitionMetadataClient
+        PartitionMetadataClient partitionMetadataClient,
+        Optional<StreamsGroupTopologyDescriptionPlugin> 
streamsGroupTopologyDescriptionPlugin,
+        Time time
     ) {
         this.log = logContext.logger(GroupCoordinatorService.class);
         this.config = config;
@@ -397,6 +414,10 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
             .map(ConsumerGroupPartitionAssignor::name)
             .collect(Collectors.toSet());
         this.partitionMetadataClient = partitionMetadataClient;
+        this.streamsGroupTopologyDescriptionManager = new 
StreamsGroupTopologyDescriptionManager(
+            streamsGroupTopologyDescriptionPlugin,
+            time
+        );
     }
 
     /**
@@ -606,9 +627,7 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
         if (!isActive.get()) {
             return CompletableFuture.completedFuture(
                 new StreamsGroupHeartbeatResult(
-                    new 
StreamsGroupHeartbeatResponseData().setErrorCode(Errors.COORDINATOR_NOT_AVAILABLE.code()),
-                    Map.of(),
-                    -1
+                    new 
StreamsGroupHeartbeatResponseData().setErrorCode(Errors.COORDINATOR_NOT_AVAILABLE.code())
                 )
             );
         }
@@ -622,9 +641,7 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
                 new StreamsGroupHeartbeatResult(
                     new StreamsGroupHeartbeatResponseData()
                         .setErrorCode(apiError.error().code())
-                        .setErrorMessage(apiError.message()),
-                    Map.of(),
-                    -1
+                        .setErrorMessage(apiError.message())
                 )
             );
         }
@@ -633,6 +650,7 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
             "streams-group-heartbeat",
             topicPartitionFor(request.groupId()),
             coordinator -> coordinator.streamsGroupHeartbeat(context, request)
+        ).thenApply(result -> 
streamsGroupTopologyDescriptionManager.maybeSetTopologyDescriptionRequired(result,
 request.groupId(), context.requestVersion())
         ).exceptionally(exception -> handleOperationException(
             "streams-group-heartbeat",
             request,
@@ -641,9 +659,7 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
                 new StreamsGroupHeartbeatResult(
                     new StreamsGroupHeartbeatResponseData()
                         .setErrorCode(error.code())
-                        .setErrorMessage(message),
-                    Map.of(),
-                    -1
+                        .setErrorMessage(message)
                 ),
             log
         ));
@@ -2380,6 +2396,7 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
         log.info("Shutting down.");
         isActive.set(false);
         Utils.closeQuietly(runtime, "coordinator runtime");
+        Utils.closeQuietly(streamsGroupTopologyDescriptionManager, "streams 
group topology description manager");
         Utils.closeQuietly(groupCoordinatorMetrics, "group coordinator 
metrics");
         Utils.closeQuietly(groupConfigManager, "group config manager");
         log.info("Shutdown complete.");
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 1f09b2908d9..c5673613fe4 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
@@ -2325,7 +2325,13 @@ public class GroupMetadataManager {
 
         response.setStatus(returnedStatus);
 
-        return new CoordinatorResult<>(records, new 
StreamsGroupHeartbeatResult(response, internalTopicsToBeCreated, 
updatedTopology.topologyEpoch()));
+        return new CoordinatorResult<>(records, new 
StreamsGroupHeartbeatResult(
+            response,
+            internalTopicsToBeCreated,
+            updatedTopology.topologyEpoch(),
+            group.storedDescriptionTopologyEpoch(),
+            group.failedDescriptionTopologyEpoch()
+        ));
     }
 
     /**
@@ -4466,7 +4472,13 @@ public class GroupMetadataManager {
         if (instanceId == null) {
             StreamsGroupMember member = group.getMemberOrThrow(memberId);
             log.info("[GroupId {}][MemberId {}] Member {} left the streams 
group.", groupId, memberId, memberId);
-            return streamsGroupFenceMember(group, member, new 
StreamsGroupHeartbeatResult(response, Map.of(), group.currentTopologyEpoch()));
+            return streamsGroupFenceMember(group, member, new 
StreamsGroupHeartbeatResult(
+                response,
+                Map.of(),
+                group.currentTopologyEpoch(),
+                group.storedDescriptionTopologyEpoch(),
+                group.failedDescriptionTopologyEpoch()
+            ));
         } else {
             StreamsGroupMember member = group.staticMember(instanceId);
             throwIfStaticMemberIsUnknown(member, instanceId);
@@ -4478,7 +4490,13 @@ public class GroupMetadataManager {
             } else {
                 log.info("[GroupId {}][MemberId {}] Static member {} with 
instance id {} left the streams group.",
                     group.groupId(), memberId, memberId, instanceId);
-                return streamsGroupFenceMember(group, member, new 
StreamsGroupHeartbeatResult(response, Map.of(), group.currentTopologyEpoch()));
+                return streamsGroupFenceMember(group, member, new 
StreamsGroupHeartbeatResult(
+                    response,
+                    Map.of(),
+                    group.currentTopologyEpoch(),
+                    group.storedDescriptionTopologyEpoch(),
+                    group.failedDescriptionTopologyEpoch()
+                ));
             }
         }
     }
@@ -4547,7 +4565,13 @@ public class GroupMetadataManager {
 
         return new CoordinatorResult<>(
             List.of(record),
-            new StreamsGroupHeartbeatResult(response, Map.of(), 
group.currentTopologyEpoch())
+            new StreamsGroupHeartbeatResult(
+                response,
+                Map.of(),
+                group.currentTopologyEpoch(),
+                group.storedDescriptionTopologyEpoch(),
+                group.failedDescriptionTopologyEpoch()
+            )
         );
     }
 
diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupHeartbeatResult.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupHeartbeatResult.java
index b6066b41168..82b84d7b494 100644
--- 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupHeartbeatResult.java
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupHeartbeatResult.java
@@ -26,20 +26,40 @@ import java.util.Objects;
 /**
  * A simple record to hold the result of a StreamsGroupHeartbeat request.
  *
- * @param data                  The data to be returned to the client.
- * @param creatableTopics       The internal topics to be created.
- * @param currentTopologyEpoch  The topology epoch the group is operating at 
after this heartbeat, or -1 if the
- *                              group has no topology yet. The service layer 
uses this to decide whether to set
- *                              TopologyDescriptionRequired on the response 
(KIP-1331).
+ * <p>The three epoch fields let the service layer decide, without re-reading 
the group,
+ * whether to set {@code TopologyDescriptionRequired} on the response: a push 
is needed
+ * when the stored epoch lags the current epoch and the same epoch has not 
already been
+ * recorded as a permanent failure. All three are -1 for failure-fast paths 
that do not
+ * resolve a group.
+ *
+ * @param data                              The data to be returned to the 
client.
+ * @param creatableTopics                   The internal topics to be created.
+ * @param currentTopologyEpoch              The topology epoch the group is 
operating at after this heartbeat,
+ *                                          or -1 if the group has no topology 
yet.
+ * @param storedDescriptionTopologyEpoch    The most recent topology epoch 
successfully stored by the topology
+ *                                          description plugin, or -1 if none.
+ * @param failedDescriptionTopologyEpoch    The most recent topology epoch the 
plugin permanently rejected,
+ *                                          or -1 if none.
  */
 public record StreamsGroupHeartbeatResult(
     StreamsGroupHeartbeatResponseData data,
     Map<String, CreatableTopic> creatableTopics,
-    int currentTopologyEpoch
+    int currentTopologyEpoch,
+    int storedDescriptionTopologyEpoch,
+    int failedDescriptionTopologyEpoch
 ) {
 
     public StreamsGroupHeartbeatResult {
         Objects.requireNonNull(data);
         creatableTopics = 
Collections.unmodifiableMap(Objects.requireNonNull(creatableTopics));
     }
+
+    /**
+     * Convenience constructor for failure-fast paths that do not resolve a 
group: no
+     * internal topics to create, and all three epoch fields set to -1 so the 
service-layer
+     * gate sees nothing to do.
+     */
+    public StreamsGroupHeartbeatResult(StreamsGroupHeartbeatResponseData data) 
{
+        this(data, Map.of(), -1, -1, -1);
+    }
 }
diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionBackoff.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionBackoff.java
new file mode 100644
index 00000000000..afda7817908
--- /dev/null
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionBackoff.java
@@ -0,0 +1,114 @@
+/*
+ * 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.common.utils.Time;
+
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * In-memory per-group back-off that throttles broker re-solicitation of a 
topology
+ * description push. An entry is armed when the broker decides to set
+ * {@code TopologyDescriptionRequired=true} on a heartbeat or after a 
transient plugin
+ * failure; consecutive arms at the same topology epoch double the window from
+ * {@value #INITIAL_DELAY_MS} ms up to {@value #MAX_DELAY_MS} ms. Successful 
pushes,
+ * permanent plugin failures, and topology-epoch advances clear the entry.
+ */
+public class StreamsGroupTopologyDescriptionBackoff {
+
+    static final long INITIAL_DELAY_MS = 30_000L;
+    static final long MAX_DELAY_MS = 3_600_000L;
+
+    private final Time time;
+    private final ConcurrentHashMap<String, Entry> state = new 
ConcurrentHashMap<>();
+
+    record Entry(int topologyEpoch, long currentDelayMs, long nextAttemptMs) { 
}
+
+    public StreamsGroupTopologyDescriptionBackoff(Time time) {
+        this.time = time;
+    }
+
+    /**
+     * @return true if a back-off window is in effect for the given group at 
the given
+     *         topology epoch and the broker should suppress soliciting 
another push.
+     */
+    public boolean isActive(String groupId, int topologyEpoch) {
+        Entry entry = state.get(groupId);
+        return entry != null
+            && entry.topologyEpoch() == topologyEpoch
+            && time.milliseconds() < entry.nextAttemptMs();
+    }
+
+    /**
+     * Atomic check-and-arm. Returns true if no window was in effect and a new 
one was
+     * armed, false if a window was already active and nothing changed. Used 
on the
+     * heartbeat path to fold the "check + arm" pair into a single compute so 
two
+     * concurrent heartbeats for the same group cannot both arm the back-off, 
and to
+     * preserve the exponential chain when the previous window has expired 
without a
+     * push reaching the coordinator (e.g. the client never sent one, or the 
push was
+     * lost in flight before {@link #armOrExtend} could run).
+     */
+    public boolean armIfNotActive(String groupId, int topologyEpoch) {
+        final long now = time.milliseconds();
+        final boolean[] armed = new boolean[]{false};
+        state.compute(groupId, (key, existing) -> {
+            if (existing != null
+                && existing.topologyEpoch() == topologyEpoch
+                && now < existing.nextAttemptMs()) {
+                return existing;
+            }
+            armed[0] = true;
+            // Re-arm: continue the exponential chain at the same epoch (the 
previous
+            // window expired without a push completing); reset to 
INITIAL_DELAY_MS on
+            // an epoch advance, which implicitly drops the prior history.
+            if (existing != null && existing.topologyEpoch() == topologyEpoch) 
{
+                long nextDelay = Math.min(existing.currentDelayMs() * 2, 
MAX_DELAY_MS);
+                return new Entry(topologyEpoch, nextDelay, now + nextDelay);
+            }
+            return new Entry(topologyEpoch, INITIAL_DELAY_MS, now + 
INITIAL_DELAY_MS);
+        });
+        return armed[0];
+    }
+
+    /**
+     * Arm a new back-off window or extend the existing one. If the existing 
entry is for a
+     * different topology epoch the window is reset to {@link 
#INITIAL_DELAY_MS}.
+     */
+    public void armOrExtend(String groupId, int topologyEpoch) {
+        final long now = time.milliseconds();
+        state.compute(groupId, (key, existing) -> {
+            if (existing == null || existing.topologyEpoch() != topologyEpoch) 
{
+                return new Entry(topologyEpoch, INITIAL_DELAY_MS, now + 
INITIAL_DELAY_MS);
+            }
+            long nextDelay = Math.min(existing.currentDelayMs() * 2, 
MAX_DELAY_MS);
+            return new Entry(topologyEpoch, nextDelay, now + nextDelay);
+        });
+    }
+
+    /**
+     * Drop the back-off entry for a group. Called on a successful push, a 
permanent plugin
+     * failure, or when the group is removed.
+     */
+    public void clear(String groupId) {
+        state.remove(groupId);
+    }
+
+    // Visible for testing.
+    Entry entry(String groupId) {
+        return state.get(groupId);
+    }
+}
diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionManager.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionManager.java
new file mode 100644
index 00000000000..ca848518d10
--- /dev/null
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionManager.java
@@ -0,0 +1,129 @@
+/*
+ * 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.common.message.StreamsGroupHeartbeatResponseData;
+import org.apache.kafka.common.protocol.Errors;
+import org.apache.kafka.common.requests.StreamsGroupHeartbeatResponse.Status;
+import org.apache.kafka.common.utils.Time;
+import 
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescriptionPlugin;
+
+import java.util.Optional;
+
+/**
+ * Broker-level component that owns everything tied to the streams-group 
topology
+ * description plugin: the configured plugin reference, the per-group 
re-solicitation
+ * back-off, and the heartbeat-path gate that asks clients to push their 
topology.
+ *
+ * <p>This class is broker-level (one instance per {@code 
GroupCoordinatorService}); the
+ * back-off map is keyed by {@code groupId} and shared across all partitions 
hosted on the
+ * broker. State here is intentionally non-timeline and non-replayed: it is 
rebuilt from
+ * scratch on broker restart, and the persisted {@code 
StoredDescriptionTopologyEpoch} /
+ * {@code FailedDescriptionTopologyEpoch} fields on each streams group drive 
convergence
+ * after a restart.
+ */
+public class StreamsGroupTopologyDescriptionManager implements AutoCloseable {
+    private final Optional<StreamsGroupTopologyDescriptionPlugin> plugin;
+    private final StreamsGroupTopologyDescriptionBackoff backoff;
+
+    public StreamsGroupTopologyDescriptionManager(
+        Optional<StreamsGroupTopologyDescriptionPlugin> plugin,
+        Time time
+    ) {
+        this.plugin = plugin;
+        this.backoff = new StreamsGroupTopologyDescriptionBackoff(time);
+    }
+
+    /**
+     * Release plugin-side resources. The plugin is instantiated by the 
service via
+     * {@code config.getConfiguredInstance(...)}, so the service owns it and 
must close
+     * it on shutdown to avoid leaking threads, network clients, etc. across 
broker
+     * restart cycles.
+     */
+    @Override
+    public void close() throws Exception {
+        if (plugin.isPresent()) {
+            plugin.get().close();
+        }
+    }
+
+    /**
+     * @return true if a topology description plugin is configured on this 
broker.
+     */
+    public boolean isPluginConfigured() {
+        return plugin.isPresent();
+    }
+
+    /**
+     * Post-processes a successful streams group heartbeat result by deciding 
whether the
+     * broker should set {@code TopologyDescriptionRequired=true} on the 
response, and
+     * arming the per-group back-off when it does.
+     *
+     * <p>The flag is set when the request is at a version that carries the 
field
+     * ({@code TopologyDescriptionRequired} arrives at v1), the topology 
description plugin
+     * is configured, the group has resolved to a topology epoch, that epoch 
is neither
+     * stored nor permanently failed at the plugin, no back-off is in effect 
for this
+     * epoch, and the response does not carry a {@code STALE_TOPOLOGY} status 
(the member
+     * would just be told to catch up first). When the response already 
carries an error
+     * code we leave it alone.
+     *
+     * <p>The version gate is intentional: a v0 client cannot deserialize the 
flag, so
+     * arming the back-off for it would accumulate entries that grow 
exponentially while
+     * the flag itself gets dropped at serialization — wasting heap on a 
per-group basis
+     * for clients that will never push.
+     */
+    public StreamsGroupHeartbeatResult maybeSetTopologyDescriptionRequired(
+        StreamsGroupHeartbeatResult result,
+        String groupId,
+        int apiVersion
+    ) {
+        if (apiVersion < 1 || plugin.isEmpty()) {
+            return result;
+        }
+        StreamsGroupHeartbeatResponseData response = result.data();
+        if (response.errorCode() != Errors.NONE.code()) {
+            return result;
+        }
+        int currentEpoch = result.currentTopologyEpoch();
+        if (currentEpoch < 0
+            || result.storedDescriptionTopologyEpoch() == currentEpoch
+            || result.failedDescriptionTopologyEpoch() == currentEpoch
+            || responseHasStaleTopology(response)) {
+            return result;
+        }
+        // Atomic check-and-arm: only set the flag if the back-off window is 
not already
+        // in effect for this epoch, so two concurrent heartbeats for the same 
group cannot
+        // both arm the back-off and double the window beyond its intended 
length.
+        if (backoff.armIfNotActive(groupId, currentEpoch)) {
+            response.setTopologyDescriptionRequired(true);
+        }
+        return result;
+    }
+
+    // Visible for testing.
+    StreamsGroupTopologyDescriptionBackoff backoff() {
+        return backoff;
+    }
+
+    private static boolean 
responseHasStaleTopology(StreamsGroupHeartbeatResponseData response) {
+        if (response.status() == null) {
+            return false;
+        }
+        byte staleCode = Status.STALE_TOPOLOGY.code();
+        return response.status().stream().anyMatch(s -> s.statusCode() == 
staleCode);
+    }
+}
diff --git 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTest.java
 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTest.java
index 1ac1baf0553..33a71646068 100644
--- 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTest.java
+++ 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTest.java
@@ -92,6 +92,7 @@ import org.apache.kafka.common.utils.internals.LogContext;
 import org.apache.kafka.coordinator.common.runtime.CoordinatorRecord;
 import org.apache.kafka.coordinator.common.runtime.CoordinatorRuntime;
 import org.apache.kafka.coordinator.common.runtime.MetadataImageBuilder;
+import 
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescriptionPlugin;
 import org.apache.kafka.coordinator.group.metrics.GroupCoordinatorMetrics;
 import org.apache.kafka.coordinator.group.streams.StreamsGroupDescribeResult;
 import org.apache.kafka.coordinator.group.streams.StreamsGroupHeartbeatResult;
@@ -473,6 +474,8 @@ public class GroupCoordinatorServiceTest {
             new StreamsGroupHeartbeatResult(
                 new 
StreamsGroupHeartbeatResponseData().setErrorCode(Errors.COORDINATOR_NOT_AVAILABLE.code()),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             future.get()
@@ -505,6 +508,8 @@ public class GroupCoordinatorServiceTest {
             new StreamsGroupHeartbeatResult(
                 new StreamsGroupHeartbeatResponseData(),
                 Map.of(),
+                -1,
+                -1,
                 -1
             )
         ));
@@ -514,7 +519,7 @@ public class GroupCoordinatorServiceTest {
             request
         );
 
-        assertEquals(new StreamsGroupHeartbeatResult(new 
StreamsGroupHeartbeatResponseData(), Map.of(), -1), future.get(5, 
TimeUnit.SECONDS));
+        assertEquals(new StreamsGroupHeartbeatResult(new 
StreamsGroupHeartbeatResponseData(), Map.of(), -1, -1, -1), future.get(5, 
TimeUnit.SECONDS));
     }
 
     private static Stream<Arguments> 
testStreamsGroupHeartbeatWithExceptionSource() {
@@ -574,6 +579,8 @@ public class GroupCoordinatorServiceTest {
                     .setErrorCode(expectedErrorCode)
                     .setErrorMessage(expectedErrorMessage),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             future.get(5, TimeUnit.SECONDS)
@@ -596,6 +603,8 @@ public class GroupCoordinatorServiceTest {
                     .setErrorCode(Errors.INVALID_REQUEST.code())
                     .setErrorMessage("TaskOffsets are not supported yet."),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             service.streamsGroupHeartbeat(
@@ -611,6 +620,8 @@ public class GroupCoordinatorServiceTest {
                     .setErrorCode(Errors.INVALID_REQUEST.code())
                     .setErrorMessage("TaskEndOffsets are not supported yet."),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             service.streamsGroupHeartbeat(
@@ -626,6 +637,8 @@ public class GroupCoordinatorServiceTest {
                     .setErrorCode(Errors.INVALID_REQUEST.code())
                     .setErrorMessage("WarmupTasks are not supported yet."),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             service.streamsGroupHeartbeat(
@@ -641,6 +654,8 @@ public class GroupCoordinatorServiceTest {
                     .setErrorCode(Errors.INVALID_REQUEST.code())
                     .setErrorMessage("Regular expressions for source topics 
are not supported yet."),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             service.streamsGroupHeartbeat(
@@ -675,6 +690,8 @@ public class GroupCoordinatorServiceTest {
                     .setErrorCode(Errors.INVALID_REQUEST.code())
                     .setErrorMessage("MemberId can't be empty."),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             service.streamsGroupHeartbeat(
@@ -690,6 +707,8 @@ public class GroupCoordinatorServiceTest {
                     .setErrorCode(Errors.INVALID_REQUEST.code())
                     .setErrorMessage("MemberId can't be empty."),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             service.streamsGroupHeartbeat(
@@ -706,6 +725,8 @@ public class GroupCoordinatorServiceTest {
                     .setErrorCode(Errors.INVALID_REQUEST.code())
                     .setErrorMessage("GroupId can't be empty."),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             service.streamsGroupHeartbeat(
@@ -722,6 +743,8 @@ public class GroupCoordinatorServiceTest {
                     .setErrorCode(Errors.INVALID_REQUEST.code())
                     .setErrorMessage("GroupId can't be empty."),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             service.streamsGroupHeartbeat(
@@ -739,6 +762,8 @@ public class GroupCoordinatorServiceTest {
                     .setErrorCode(Errors.INVALID_REQUEST.code())
                     .setErrorMessage("RebalanceTimeoutMs must be provided in 
first request."),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             service.streamsGroupHeartbeat(
@@ -757,6 +782,8 @@ public class GroupCoordinatorServiceTest {
                     .setErrorCode(Errors.INVALID_REQUEST.code())
                     .setErrorMessage("ActiveTasks must be empty when 
(re-)joining."),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             service.streamsGroupHeartbeat(
@@ -776,6 +803,8 @@ public class GroupCoordinatorServiceTest {
                     .setErrorCode(Errors.INVALID_REQUEST.code())
                     .setErrorMessage("StandbyTasks must be empty when 
(re-)joining."),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             service.streamsGroupHeartbeat(
@@ -796,6 +825,8 @@ public class GroupCoordinatorServiceTest {
                     .setErrorCode(Errors.INVALID_REQUEST.code())
                     .setErrorMessage("WarmupTasks must be empty when 
(re-)joining."),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             service.streamsGroupHeartbeat(
@@ -817,6 +848,8 @@ public class GroupCoordinatorServiceTest {
                     .setErrorCode(Errors.INVALID_REQUEST.code())
                     .setErrorMessage("Topology must be non-null when 
(re-)joining."),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             service.streamsGroupHeartbeat(
@@ -839,6 +872,8 @@ public class GroupCoordinatorServiceTest {
                     .setErrorCode(Errors.INVALID_REQUEST.code())
                     .setErrorMessage("RackId can't be empty."),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             service.streamsGroupHeartbeat(
@@ -858,6 +893,8 @@ public class GroupCoordinatorServiceTest {
                     .setErrorCode(Errors.INVALID_REQUEST.code())
                     .setErrorMessage("InstanceId can't be null."),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             service.streamsGroupHeartbeat(
@@ -881,6 +918,8 @@ public class GroupCoordinatorServiceTest {
                     .setErrorCode(Errors.INVALID_REQUEST.code())
                     .setErrorMessage("MemberEpoch is -3, but must be greater 
than or equal to -2."),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             service.streamsGroupHeartbeat(
@@ -900,6 +939,8 @@ public class GroupCoordinatorServiceTest {
                     .setErrorCode(Errors.INVALID_REQUEST.code())
                     .setErrorMessage("Topology can only be provided when 
(re-)joining."),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             service.streamsGroupHeartbeat(
@@ -923,6 +964,8 @@ public class GroupCoordinatorServiceTest {
                     .setErrorCode(Errors.STREAMS_INVALID_TOPOLOGY.code())
                     .setErrorMessage("Changelog topic 
changelog_topic_with_fixed_partition must have an undefined partition count, 
but it is set to 3."),
                 Map.of(),
+                -1,
+                -1,
                 -1
             ),
             service.streamsGroupHeartbeat(
@@ -5922,6 +5965,7 @@ public class GroupCoordinatorServiceTest {
         private Persister persister = new NoOpStatePersister();
         private MetadataImage metadataImage = null;
         private PartitionMetadataClient partitionMetadataClient = null;
+        private Optional<StreamsGroupTopologyDescriptionPlugin> 
streamsGroupTopologyDescriptionPlugin = Optional.empty();
 
         GroupCoordinatorService build() {
             return build(false);
@@ -5934,6 +5978,7 @@ public class GroupCoordinatorServiceTest {
                     .build();
             }
 
+            MockTimer mockTimer = new MockTimer();
             var service = new GroupCoordinatorService(
                 logContext,
                 config,
@@ -5941,8 +5986,10 @@ public class GroupCoordinatorServiceTest {
                 metrics,
                 configManager,
                 persister,
-                new MockTimer(),
-                partitionMetadataClient
+                mockTimer,
+                partitionMetadataClient,
+                streamsGroupTopologyDescriptionPlugin,
+                mockTimer.time()
             );
 
             if (serviceStartup) {
diff --git 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTopologyDescriptionTest.java
 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTopologyDescriptionTest.java
new file mode 100644
index 00000000000..7ed02b37d92
--- /dev/null
+++ 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTopologyDescriptionTest.java
@@ -0,0 +1,285 @@
+/*
+ * 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;
+
+import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.Uuid;
+import org.apache.kafka.common.internals.Topic;
+import org.apache.kafka.common.message.StreamsGroupHeartbeatRequestData;
+import org.apache.kafka.common.message.StreamsGroupHeartbeatResponseData;
+import org.apache.kafka.common.protocol.ApiKeys;
+import org.apache.kafka.common.utils.MockTime;
+import org.apache.kafka.common.utils.internals.LogContext;
+import org.apache.kafka.coordinator.common.runtime.CoordinatorRecord;
+import org.apache.kafka.coordinator.common.runtime.CoordinatorRuntime;
+import 
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescriptionPlugin;
+import org.apache.kafka.coordinator.group.metrics.GroupCoordinatorMetrics;
+import org.apache.kafka.coordinator.group.streams.StreamsGroupHeartbeatResult;
+import org.apache.kafka.server.share.persister.NoOpStatePersister;
+import org.apache.kafka.server.util.timer.MockTimer;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+import static 
org.apache.kafka.common.requests.StreamsGroupHeartbeatResponse.Status;
+import static 
org.apache.kafka.coordinator.common.runtime.TestUtil.requestContext;
+import static 
org.apache.kafka.coordinator.group.GroupConfigManagerTest.createConfigManager;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for the heartbeat post-processing on {@link GroupCoordinatorService} 
that sets
+ * {@code TopologyDescriptionRequired} on the response when a topology 
description plugin
+ * is configured and the stored/failed epochs lag the current topology epoch.
+ */
+public class GroupCoordinatorServiceTopologyDescriptionTest {
+
+    private static final TopicPartition GROUP_TP = new 
TopicPartition(Topic.GROUP_METADATA_TOPIC_NAME, 0);
+
+    @SuppressWarnings("unchecked")
+    private static CoordinatorRuntime<GroupCoordinatorShard, 
CoordinatorRecord> mockRuntime() {
+        return (CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord>) 
mock(CoordinatorRuntime.class);
+    }
+
+    private static GroupCoordinatorService buildService(
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime,
+        Optional<StreamsGroupTopologyDescriptionPlugin> plugin,
+        boolean startup
+    ) {
+        return buildService(runtime, plugin, startup, new MockTimer());
+    }
+
+    private static GroupCoordinatorService buildService(
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime,
+        Optional<StreamsGroupTopologyDescriptionPlugin> plugin,
+        boolean startup,
+        MockTimer timer
+    ) {
+        MockTime time = timer.time();
+        GroupCoordinatorService service = new GroupCoordinatorService(
+            new LogContext(),
+            GroupCoordinatorConfigTest.createGroupCoordinatorConfig(4096, 
600000L, 24),
+            runtime,
+            new GroupCoordinatorMetrics(),
+            createConfigManager(),
+            new NoOpStatePersister(),
+            timer,
+            null,
+            plugin,
+            time
+        );
+        if (startup) {
+            service.startup(() -> 1);
+        }
+        return service;
+    }
+
+    @Test
+    public void testHeartbeatSetsTopologyDescriptionRequiredWhenStoredLags() 
throws Exception {
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(runtime.scheduleWriteOperation(
+            eq("streams-group-heartbeat"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(
+            new StreamsGroupHeartbeatResult(new 
StreamsGroupHeartbeatResponseData(), Map.of(), 5, -1, -1)));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        StreamsGroupHeartbeatResult result = service.streamsGroupHeartbeat(
+            requestContext(ApiKeys.STREAMS_GROUP_HEARTBEAT), 
validHeartbeatRequest()
+        ).get(5, TimeUnit.SECONDS);
+
+        assertTrue(result.data().topologyDescriptionRequired());
+    }
+
+    @Test
+    public void testHeartbeatArmSuppressReSolicitCycle() throws Exception {
+        // End-to-end exercise of the arm → suppress → re-solicit cycle 
through the
+        // service + TopologyDescriptionManager. Backoff primitive tests cover 
this in
+        // isolation; this asserts the contract holds when the heartbeat write
+        // result is fed into maybeSetTopologyDescriptionRequired.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        // Each call must yield a fresh response so mutations from the 
previous call do not
+        // bleed through. thenReturn would hand back the same instance to 
every invocation
+        // and the second heartbeat would observe topologyDescriptionRequired 
carried over
+        // from the first, masking the suppression we want to assert.
+        when(runtime.scheduleWriteOperation(
+            eq("streams-group-heartbeat"),
+            eq(GROUP_TP),
+            any()
+        )).thenAnswer(invocation -> CompletableFuture.completedFuture(
+            new StreamsGroupHeartbeatResult(new 
StreamsGroupHeartbeatResponseData(), Map.of(), 5, -1, -1)));
+
+        MockTimer timer = new MockTimer();
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true, timer);
+
+        // 1. First heartbeat — back-off idle. Manager arms it and sets the 
flag.
+        StreamsGroupHeartbeatResult firstResult = 
service.streamsGroupHeartbeat(
+            requestContext(ApiKeys.STREAMS_GROUP_HEARTBEAT), 
validHeartbeatRequest()
+        ).get(5, TimeUnit.SECONDS);
+        assertTrue(firstResult.data().topologyDescriptionRequired());
+
+        // 2. Second heartbeat — back-off window still active. Manager 
suppresses the flag.
+        StreamsGroupHeartbeatResult secondResult = 
service.streamsGroupHeartbeat(
+            requestContext(ApiKeys.STREAMS_GROUP_HEARTBEAT), 
validHeartbeatRequest()
+        ).get(5, TimeUnit.SECONDS);
+        assertFalse(secondResult.data().topologyDescriptionRequired());
+
+        // 3. Advance MockTime past the back-off window. INITIAL_DELAY_MS is 
30s — the
+        // value lives in StreamsGroupTopologyDescriptionBackoff as a 
package-private
+        // constant; sleeping a comfortable margin past it keeps this test 
independent
+        // of the exact delay while still asserting "past the initial window".
+        timer.time().sleep(60_000L);
+
+        // 4. Third heartbeat — window expired. Manager re-arms and sets the 
flag again.
+        StreamsGroupHeartbeatResult thirdResult = 
service.streamsGroupHeartbeat(
+            requestContext(ApiKeys.STREAMS_GROUP_HEARTBEAT), 
validHeartbeatRequest()
+        ).get(5, TimeUnit.SECONDS);
+        assertTrue(thirdResult.data().topologyDescriptionRequired());
+    }
+
+    @Test
+    public void testHeartbeatSkipsFlagWhenStoredMatchesCurrent() throws 
Exception {
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(runtime.scheduleWriteOperation(
+            eq("streams-group-heartbeat"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(
+            new StreamsGroupHeartbeatResult(new 
StreamsGroupHeartbeatResponseData(), Map.of(), 5, 5, -1)));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        StreamsGroupHeartbeatResult result = service.streamsGroupHeartbeat(
+            requestContext(ApiKeys.STREAMS_GROUP_HEARTBEAT), 
validHeartbeatRequest()
+        ).get(5, TimeUnit.SECONDS);
+
+        assertFalse(result.data().topologyDescriptionRequired());
+    }
+
+    @Test
+    public void testHeartbeatSkipsFlagWhenFailedAtCurrentEpoch() throws 
Exception {
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(runtime.scheduleWriteOperation(
+            eq("streams-group-heartbeat"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(
+            new StreamsGroupHeartbeatResult(new 
StreamsGroupHeartbeatResponseData(), Map.of(), 5, -1, 5)));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        StreamsGroupHeartbeatResult result = service.streamsGroupHeartbeat(
+            requestContext(ApiKeys.STREAMS_GROUP_HEARTBEAT), 
validHeartbeatRequest()
+        ).get(5, TimeUnit.SECONDS);
+
+        assertFalse(result.data().topologyDescriptionRequired());
+    }
+
+    @Test
+    public void testHeartbeatSkipsFlagWhenStaleTopologyStatusPresent() throws 
Exception {
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        StreamsGroupHeartbeatResponseData responseData = new 
StreamsGroupHeartbeatResponseData()
+            .setStatus(List.of(new StreamsGroupHeartbeatResponseData.Status()
+                .setStatusCode(Status.STALE_TOPOLOGY.code())
+                .setStatusDetail("behind")));
+        when(runtime.scheduleWriteOperation(
+            eq("streams-group-heartbeat"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(
+            new StreamsGroupHeartbeatResult(responseData, Map.of(), 5, -1, 
-1)));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        StreamsGroupHeartbeatResult result = service.streamsGroupHeartbeat(
+            requestContext(ApiKeys.STREAMS_GROUP_HEARTBEAT), 
validHeartbeatRequest()
+        ).get(5, TimeUnit.SECONDS);
+
+        assertFalse(result.data().topologyDescriptionRequired());
+    }
+
+    @Test
+    public void testHeartbeatNeverSetsFlagWithoutPlugin() throws Exception {
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        when(runtime.scheduleWriteOperation(
+            eq("streams-group-heartbeat"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(
+            new StreamsGroupHeartbeatResult(new 
StreamsGroupHeartbeatResponseData(), Map.of(), 5, -1, -1)));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.empty(), true);
+        StreamsGroupHeartbeatResult result = service.streamsGroupHeartbeat(
+            requestContext(ApiKeys.STREAMS_GROUP_HEARTBEAT), 
validHeartbeatRequest()
+        ).get(5, TimeUnit.SECONDS);
+
+        assertFalse(result.data().topologyDescriptionRequired());
+    }
+
+    @Test
+    public void testHeartbeatSkipsFlagOnV0Request() throws Exception {
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(runtime.scheduleWriteOperation(
+            eq("streams-group-heartbeat"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(
+            new StreamsGroupHeartbeatResult(new 
StreamsGroupHeartbeatResponseData(), Map.of(), 5, -1, -1)));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        StreamsGroupHeartbeatResult result = service.streamsGroupHeartbeat(
+            requestContext(ApiKeys.STREAMS_GROUP_HEARTBEAT, (short) 0), 
validHeartbeatRequest()
+        ).get(5, TimeUnit.SECONDS);
+
+        assertFalse(result.data().topologyDescriptionRequired());
+    }
+
+    @Test
+    public void testShutdownClosesPlugin() throws Exception {
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        GroupCoordinatorService service = buildService(mockRuntime(), 
Optional.of(plugin), true);
+        service.shutdown();
+        verify(plugin, times(1)).close();
+    }
+
+    private static StreamsGroupHeartbeatRequestData validHeartbeatRequest() {
+        return new StreamsGroupHeartbeatRequestData()
+            .setGroupId("foo")
+            .setMemberId(Uuid.randomUuid().toString())
+            .setMemberEpoch(0)
+            .setRebalanceTimeoutMs(1500)
+            .setActiveTasks(List.of())
+            .setStandbyTasks(List.of())
+            .setWarmupTasks(List.of())
+            .setTopology(new StreamsGroupHeartbeatRequestData.Topology());
+    }
+}
diff --git 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorShardTest.java
 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorShardTest.java
index 72e2d9d11f7..448145c54fa 100644
--- 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorShardTest.java
+++ 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorShardTest.java
@@ -195,7 +195,7 @@ public class GroupCoordinatorShardTest {
         StreamsGroupHeartbeatRequestData request = new 
StreamsGroupHeartbeatRequestData();
         CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> 
result = new CoordinatorResult<>(
             List.of(),
-            new StreamsGroupHeartbeatResult(new 
StreamsGroupHeartbeatResponseData(), Map.of(), -1)
+            new StreamsGroupHeartbeatResult(new 
StreamsGroupHeartbeatResponseData(), Map.of(), -1, -1, -1)
         );
 
         when(groupMetadataManager.streamsGroupHeartbeat(
diff --git 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupHeartbeatResultTest.java
 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupHeartbeatResultTest.java
index e08fcd36d2e..2fc3f23e007 100644
--- 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupHeartbeatResultTest.java
+++ 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupHeartbeatResultTest.java
@@ -25,35 +25,47 @@ import java.util.Map;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
 
 public class StreamsGroupHeartbeatResultTest {
 
     @Test
-    public void testThreeArgConstructorPreservesTopologyEpoch() {
+    public void testConstructorPreservesEpochs() {
         StreamsGroupHeartbeatResult result = new StreamsGroupHeartbeatResult(
-            new StreamsGroupHeartbeatResponseData(), Map.of(), 7);
+            new StreamsGroupHeartbeatResponseData(), Map.of(), 7, 5, 3);
         assertEquals(7, result.currentTopologyEpoch());
+        assertEquals(5, result.storedDescriptionTopologyEpoch());
+        assertEquals(3, result.failedDescriptionTopologyEpoch());
     }
 
     @Test
     public void testCurrentTopologyEpochIsPartOfEquality() {
-        // Records derive equals from all components; two results with 
different topology epochs are unequal.
         StreamsGroupHeartbeatResult a = new StreamsGroupHeartbeatResult(
-            new StreamsGroupHeartbeatResponseData(), Map.of(), 1);
+            new StreamsGroupHeartbeatResponseData(), Map.of(), 1, -1, -1);
         StreamsGroupHeartbeatResult b = new StreamsGroupHeartbeatResult(
-            new StreamsGroupHeartbeatResponseData(), Map.of(), 2);
+            new StreamsGroupHeartbeatResponseData(), Map.of(), 2, -1, -1);
         assertNotEquals(a, b);
 
         StreamsGroupHeartbeatResult c = new StreamsGroupHeartbeatResult(
-            new StreamsGroupHeartbeatResponseData(), Map.of(), 1);
+            new StreamsGroupHeartbeatResponseData(), Map.of(), 1, -1, -1);
         assertEquals(a, c);
     }
 
+    @Test
+    public void testStoredAndFailedEpochsArePartOfEquality() {
+        StreamsGroupHeartbeatResult a = new StreamsGroupHeartbeatResult(
+            new StreamsGroupHeartbeatResponseData(), Map.of(), 1, 1, -1);
+        StreamsGroupHeartbeatResult differentStored = new 
StreamsGroupHeartbeatResult(
+            new StreamsGroupHeartbeatResponseData(), Map.of(), 1, 0, -1);
+        StreamsGroupHeartbeatResult differentFailed = new 
StreamsGroupHeartbeatResult(
+            new StreamsGroupHeartbeatResponseData(), Map.of(), 1, 1, 0);
+        assertNotEquals(a, differentStored);
+        assertNotEquals(a, differentFailed);
+    }
+
     @Test
     public void testCreatableTopicsMapIsImmutable() {
         StreamsGroupHeartbeatResult result = new StreamsGroupHeartbeatResult(
-            new StreamsGroupHeartbeatResponseData(), Map.of(), -1);
+            new StreamsGroupHeartbeatResponseData(), Map.of(), -1, -1, -1);
         assertThrows(UnsupportedOperationException.class,
             () -> result.creatableTopics().put("t", null));
     }
@@ -61,7 +73,6 @@ public class StreamsGroupHeartbeatResultTest {
     @Test
     public void testNullDataIsRejected() {
         assertThrows(NullPointerException.class,
-            () -> new StreamsGroupHeartbeatResult(null, Map.of(), -1));
-        assertTrue(true);
+            () -> new StreamsGroupHeartbeatResult(null, Map.of(), -1, -1, -1));
     }
 }
diff --git 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionBackoffTest.java
 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionBackoffTest.java
new file mode 100644
index 00000000000..d6eae64a3e8
--- /dev/null
+++ 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionBackoffTest.java
@@ -0,0 +1,152 @@
+/*
+ * 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.common.utils.MockTime;
+
+import org.junit.jupiter.api.Test;
+
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class StreamsGroupTopologyDescriptionBackoffTest {
+
+    @Test
+    public void testFirstArmUsesInitialDelay() {
+        MockTime time = new MockTime();
+        StreamsGroupTopologyDescriptionBackoff backoff = new 
StreamsGroupTopologyDescriptionBackoff(time);
+        backoff.armOrExtend("g", 1);
+        assertTrue(backoff.isActive("g", 1));
+        time.sleep(StreamsGroupTopologyDescriptionBackoff.INITIAL_DELAY_MS - 
1);
+        assertTrue(backoff.isActive("g", 1));
+        time.sleep(1);
+        assertFalse(backoff.isActive("g", 1));
+    }
+
+    @Test
+    public void testConsecutiveArmsDoubleTheWindowUpToMax() {
+        MockTime time = new MockTime();
+        StreamsGroupTopologyDescriptionBackoff backoff = new 
StreamsGroupTopologyDescriptionBackoff(time);
+
+        long expected = 
StreamsGroupTopologyDescriptionBackoff.INITIAL_DELAY_MS;
+        backoff.armOrExtend("g", 1);
+        assertEquals(expected, backoff.entry("g").currentDelayMs());
+
+        // Each consecutive arm at the same epoch doubles, until we hit the 
cap.
+        for (int i = 0; i < 20; i++) {
+            backoff.armOrExtend("g", 1);
+            expected = Math.min(expected * 2, 
StreamsGroupTopologyDescriptionBackoff.MAX_DELAY_MS);
+            assertEquals(expected, backoff.entry("g").currentDelayMs(),
+                "iteration " + i);
+        }
+        assertEquals(StreamsGroupTopologyDescriptionBackoff.MAX_DELAY_MS,
+            backoff.entry("g").currentDelayMs());
+    }
+
+    @Test
+    public void testDifferentEpochResetsTheWindow() {
+        MockTime time = new MockTime();
+        StreamsGroupTopologyDescriptionBackoff backoff = new 
StreamsGroupTopologyDescriptionBackoff(time);
+        backoff.armOrExtend("g", 1);
+        backoff.armOrExtend("g", 1); // doubled
+        long doubled = backoff.entry("g").currentDelayMs();
+        assertTrue(doubled > 
StreamsGroupTopologyDescriptionBackoff.INITIAL_DELAY_MS);
+        backoff.armOrExtend("g", 2);
+        assertEquals(StreamsGroupTopologyDescriptionBackoff.INITIAL_DELAY_MS,
+            backoff.entry("g").currentDelayMs());
+        assertEquals(2, backoff.entry("g").topologyEpoch());
+    }
+
+    @Test
+    public void testIsActiveIsScopedToEpoch() {
+        MockTime time = new MockTime();
+        StreamsGroupTopologyDescriptionBackoff backoff = new 
StreamsGroupTopologyDescriptionBackoff(time);
+        backoff.armOrExtend("g", 1);
+        assertTrue(backoff.isActive("g", 1));
+        // A query at a different epoch never matches — the broker should 
re-solicit.
+        assertFalse(backoff.isActive("g", 2));
+    }
+
+    @Test
+    public void testClearRemovesEntry() {
+        MockTime time = new MockTime();
+        StreamsGroupTopologyDescriptionBackoff backoff = new 
StreamsGroupTopologyDescriptionBackoff(time);
+        backoff.armOrExtend("g", 1);
+        assertNotNull(backoff.entry("g"));
+        backoff.clear("g");
+        assertNull(backoff.entry("g"));
+        assertFalse(backoff.isActive("g", 1));
+    }
+
+    @Test
+    public void testArmIfNotActiveArmsWhenIdle() {
+        MockTime time = new MockTime();
+        StreamsGroupTopologyDescriptionBackoff backoff = new 
StreamsGroupTopologyDescriptionBackoff(time);
+        assertTrue(backoff.armIfNotActive("g", 1));
+        assertTrue(backoff.isActive("g", 1));
+        assertEquals(StreamsGroupTopologyDescriptionBackoff.INITIAL_DELAY_MS,
+            backoff.entry("g").currentDelayMs());
+    }
+
+    @Test
+    public void testArmIfNotActiveReturnsFalseWhenAlreadyActive() {
+        MockTime time = new MockTime();
+        StreamsGroupTopologyDescriptionBackoff backoff = new 
StreamsGroupTopologyDescriptionBackoff(time);
+        assertTrue(backoff.armIfNotActive("g", 1));
+        long armedAt = backoff.entry("g").nextAttemptMs();
+        // A second call inside the window does not arm and does not extend 
the window.
+        assertFalse(backoff.armIfNotActive("g", 1));
+        assertEquals(armedAt, backoff.entry("g").nextAttemptMs());
+    }
+
+    @Test
+    public void testArmIfNotActiveReArmsAfterEpochAdvance() {
+        MockTime time = new MockTime();
+        StreamsGroupTopologyDescriptionBackoff backoff = new 
StreamsGroupTopologyDescriptionBackoff(time);
+        assertTrue(backoff.armIfNotActive("g", 1));
+        // A query at the new epoch sees no active window and arms a fresh one.
+        assertTrue(backoff.armIfNotActive("g", 2));
+        assertEquals(2, backoff.entry("g").topologyEpoch());
+        assertEquals(StreamsGroupTopologyDescriptionBackoff.INITIAL_DELAY_MS,
+            backoff.entry("g").currentDelayMs());
+    }
+
+    @Test
+    public void testArmIfNotActiveDoublesAfterExpiredWindowAtSameEpoch() {
+        // Heartbeats keep re-soliciting the same epoch because the client 
never
+        // pushed (or the push was lost). The exponential chain must continue 
across
+        // those re-arms instead of resetting to INITIAL_DELAY_MS every cycle.
+        MockTime time = new MockTime();
+        StreamsGroupTopologyDescriptionBackoff backoff = new 
StreamsGroupTopologyDescriptionBackoff(time);
+
+        long expected = 
StreamsGroupTopologyDescriptionBackoff.INITIAL_DELAY_MS;
+        assertTrue(backoff.armIfNotActive("g", 1));
+        assertEquals(expected, backoff.entry("g").currentDelayMs());
+
+        for (int i = 0; i < 20; i++) {
+            time.sleep(backoff.entry("g").currentDelayMs());
+            assertTrue(backoff.armIfNotActive("g", 1));
+            expected = Math.min(expected * 2, 
StreamsGroupTopologyDescriptionBackoff.MAX_DELAY_MS);
+            assertEquals(expected, backoff.entry("g").currentDelayMs(), 
"iteration " + i);
+        }
+        assertEquals(StreamsGroupTopologyDescriptionBackoff.MAX_DELAY_MS,
+            backoff.entry("g").currentDelayMs());
+    }
+}

Reply via email to