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 111a4685df8 KAFKA-20623: Update RPC for streams group topology 
description plugin (2/3) (#22552)
111a4685df8 is described below

commit 111a4685df81f5566f797d1a294bbc9ea0a9af01
Author: TengYao Chi <[email protected]>
AuthorDate: Tue Jun 16 19:29:03 2026 +0100

    KAFKA-20623: Update RPC for streams group topology description plugin (2/3) 
(#22552)
    
    JIRA: KAFKA-20623
    This is a part of KIP-1331
    **This PR shouldn't be merge before #22551**
    
    Adds the StreamsGroupTopologyDescriptionUpdate RPC handler stacked on
    the  heartbeat extension (1/3). The push pipeline runs through
    `TopologyDescriptionManager.handleSetTopology`: `validate (group,
    member)`, convert  wire payload, call `plugin.setTopology`, persist
    `StoredDescriptionTopologyEpoch` on  success or
    `FailedDescriptionTopologyEpoch` on permanent failure, and centralise
    back-off state mutations in a single `whenComplete`.
    
    ### Push pipeline on the manager
    `TopologyDescriptionManager` gains the push entry points:
    
    - `preCheckTopologyDescriptionUpdate` — synchronous structural
    validation that rejects
      the request with `UNSUPPORTED_VERSION` when no plugin is configured,
    `INVALID_REQUEST`
      for empty `MemberId` / `GroupId` or null `TopologyDescription`.
    - `handleSetTopology` — runs `validateStreamsGroupMember` first (so a
    fenced caller gets
      `UNKNOWN_MEMBER_ID` rather than an `INVALID_REQUEST` from a payload
    conversion
      failure), then converts the wire payload to the broker-side POJO, then
    calls the
      plugin. Plugin success writes a metadata record advancing
      `StoredDescriptionTopologyEpoch`; a
    `StreamsTopologyDescriptionPermanentFailureException`
      (or a synchronous throw) writes `FailedDescriptionTopologyEpoch`; any
    other exception
      is treated as transient and writes no record.
    
    ### Back-off mutation point
    All back-off mutations on the push path are folded into a single
    `whenComplete`,
    driven by a `BackoffAction` holder populated by each terminal branch.
    The default is
    `ARM`; only success and permanent-failure branches set `CLEAR`. A
    post-plugin write
    failure therefore re-arms the back-off and the next heartbeat sees the
    drift and
    re-solicits an idempotent re-push, matching the KIP-1331 invariant.
    
    ### Wire ↔ POJO converter
    `StreamsGroupTopologyDescriptionConverter` translates
    `StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription`
    into the
    broker-side `StreamsGroupTopologyDescription` POJO. Subtopology node
    ordering and
    string-collection iteration order are preserved via `LinkedHashSet`, so
    a downstream
    pretty-printer (e.g. the future `kafka-streams-groups.sh --topology`
    command) can
    reproduce the source ordering.
    
    Wire-level node types (`SOURCE=1`, `PROCESSOR=2`, `SINK=3`) map to the
    sealed `Node`  hierarchy. `GlobalStore` shape is validated; a malformed
    payload throws  `InvalidRequestException`. The POJO itself drops
    `Set.copyOf` in favour of  `Collections.unmodifiableSet` to preserve the
    converter's insertion order — production  callers only ever construct
    nodes from a fresh `LinkedHashSet` so this is not a  defensive-copy
    regression.
    
    ### Coordinator interface and service entry
    - `GroupCoordinator.streamsGroupTopologyDescriptionUpdate(context,
    request)` interface
      method added.
    - `GroupCoordinatorService.streamsGroupTopologyDescriptionUpdate(...)`
    short-circuits
      on a non-active coordinator with `COORDINATOR_NOT_AVAILABLE`, then
    delegates to
      `topologyDescriptionManager.preCheckTopologyDescriptionUpdate(...)`
    and
      `handleSetTopology(...)`. Unhandled exceptions are translated by
      `handleOperationException` into the wire error response.
    
    ### Coordinator shard + GMM
    - `GroupMetadataManager.streamsGroupSetTopologyDescriptionEpoch(groupId,
    pushedEpoch, permanentFailure)`
      — writes a `StreamsGroupMetadata` record advancing
    `StoredDescriptionTopologyEpoch`
      (on success) or `FailedDescriptionTopologyEpoch` (on permanent
    failure).
    - `GroupCoordinatorShard.streamsGroupSetTopologyDescriptionEpoch(...)`
    exposes the
      method to the runtime write-operation scheduler.
      `topologyDescriptionManager.preCheckTopologyDescriptionUpdate(...)`
    and
      `handleSetTopology(...)`. Unhandled exceptions are translated by
      `handleOperationException` into the wire error response.
    
    ### Coordinator shard + GMM
    - `GroupMetadataManager.streamsGroupSetTopologyDescriptionEpoch(groupId,
    pushedEpoch, permanentFailure)`
      — writes a `StreamsGroupMetadata` record advancing
    `StoredDescriptionTopologyEpoch`
      (on success) or `FailedDescriptionTopologyEpoch` (on permanent
    failure).
    - `GroupCoordinatorShard.streamsGroupSetTopologyDescriptionEpoch(...)`
    exposes the
      method to the runtime write-operation scheduler.
    
    Reviewers: Lucas Brutschy <[email protected]>
---
 .../streams/StreamsGroupTopologyDescription.java   |  11 +-
 .../kafka/coordinator/group/GroupCoordinator.java  |  23 ++
 .../coordinator/group/GroupCoordinatorService.java | 191 +++++++++-
 .../coordinator/group/GroupCoordinatorShard.java   |  36 +-
 .../coordinator/group/GroupMetadataManager.java    | 100 +++--
 .../group/streams/StreamsGroupHeartbeatResult.java |  24 +-
 .../StreamsGroupTopologyDescriptionBackoff.java    | 108 ++++--
 .../StreamsGroupTopologyDescriptionConverter.java  | 108 ++++++
 .../StreamsGroupTopologyDescriptionManager.java    | 115 +++++-
 ...pCoordinatorServiceTopologyDescriptionTest.java | 411 ++++++++++++++++++++-
 .../group/GroupCoordinatorShardTest.java           |  19 +-
 .../group/GroupMetadataManagerTest.java            |  58 ++-
 .../StreamsGroupTopologyDescriptionTest.java       |   9 +-
 ...StreamsGroupTopologyDescriptionBackoffTest.java | 146 ++++++--
 ...reamsGroupTopologyDescriptionConverterTest.java | 197 ++++++++++
 15 files changed, 1398 insertions(+), 158 deletions(-)

diff --git 
a/group-coordinator/group-coordinator-api/src/main/java/org/apache/kafka/coordinator/group/api/streams/StreamsGroupTopologyDescription.java
 
b/group-coordinator/group-coordinator-api/src/main/java/org/apache/kafka/coordinator/group/api/streams/StreamsGroupTopologyDescription.java
index bfff3aca4f6..a5f242ed5a5 100644
--- 
a/group-coordinator/group-coordinator-api/src/main/java/org/apache/kafka/coordinator/group/api/streams/StreamsGroupTopologyDescription.java
+++ 
b/group-coordinator/group-coordinator-api/src/main/java/org/apache/kafka/coordinator/group/api/streams/StreamsGroupTopologyDescription.java
@@ -19,6 +19,7 @@ package org.apache.kafka.coordinator.group.api.streams;
 import org.apache.kafka.common.annotation.InterfaceStability;
 
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
@@ -58,16 +59,16 @@ public record StreamsGroupTopologyDescription(
     public record Source(String name, Set<String> topics, Set<String> 
successors) implements Node {
         public Source {
             Objects.requireNonNull(name, "name");
-            topics = Set.copyOf(Objects.requireNonNull(topics, "topics"));
-            successors = Set.copyOf(Objects.requireNonNull(successors, 
"successors"));
+            topics = 
Collections.unmodifiableSet(Objects.requireNonNull(topics, "topics"));
+            successors = 
Collections.unmodifiableSet(Objects.requireNonNull(successors, "successors"));
         }
     }
 
     public record Processor(String name, Set<String> stores, Set<String> 
successors) implements Node {
         public Processor {
             Objects.requireNonNull(name, "name");
-            stores = Set.copyOf(Objects.requireNonNull(stores, "stores"));
-            successors = Set.copyOf(Objects.requireNonNull(successors, 
"successors"));
+            stores = 
Collections.unmodifiableSet(Objects.requireNonNull(stores, "stores"));
+            successors = 
Collections.unmodifiableSet(Objects.requireNonNull(successors, "successors"));
         }
     }
 
@@ -75,7 +76,7 @@ public record StreamsGroupTopologyDescription(
         public Sink {
             Objects.requireNonNull(name, "name");
             Objects.requireNonNull(topic, "topic");
-            successors = Set.copyOf(Objects.requireNonNull(successors, 
"successors"));
+            successors = 
Collections.unmodifiableSet(Objects.requireNonNull(successors, "successors"));
         }
     }
 
diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinator.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinator.java
index c6f67ade887..717664a5939 100644
--- 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinator.java
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinator.java
@@ -47,6 +47,8 @@ import 
org.apache.kafka.common.message.ShareGroupHeartbeatRequestData;
 import org.apache.kafka.common.message.ShareGroupHeartbeatResponseData;
 import org.apache.kafka.common.message.StreamsGroupDescribeResponseData;
 import org.apache.kafka.common.message.StreamsGroupHeartbeatRequestData;
+import 
org.apache.kafka.common.message.StreamsGroupTopologyDescriptionUpdateRequestData;
+import 
org.apache.kafka.common.message.StreamsGroupTopologyDescriptionUpdateResponseData;
 import org.apache.kafka.common.message.SyncGroupRequestData;
 import org.apache.kafka.common.message.SyncGroupResponseData;
 import org.apache.kafka.common.message.TxnOffsetCommitRequestData;
@@ -99,6 +101,27 @@ public interface GroupCoordinator {
         StreamsGroupHeartbeatRequestData request
     );
 
+    /**
+     * Persist the full topology description pushed by a Streams group member.
+     *
+     * <p>The broker forwards the description to the configured
+     * {@code StreamsGroupTopologyDescriptionPlugin}. On success the broker 
writes a
+     * metadata record advancing {@code StoredDescriptionTopologyEpoch}. On a 
permanent
+     * plugin failure it writes {@code FailedDescriptionTopologyEpoch} to stop 
re-soliciting
+     * at the same epoch. On a transient failure no record is written and the 
broker arms
+     * an in-memory back-off.
+     *
+     * @param context   The request context.
+     * @param request   The StreamsGroupTopologyDescriptionUpdateRequest data.
+     *
+     * @return A future yielding the response. The error code is set to 
indicate any error
+     *         encountered during the execution.
+     */
+    CompletableFuture<StreamsGroupTopologyDescriptionUpdateResponseData> 
streamsGroupTopologyDescriptionUpdate(
+        AuthorizableRequestContext context,
+        StreamsGroupTopologyDescriptionUpdateRequestData request
+    );
+
     /**
      * Heartbeat to a Share Group.
      *
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 303a7d1f2e0..1f78c0b401f 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
@@ -20,10 +20,12 @@ import org.apache.kafka.common.TopicPartition;
 import org.apache.kafka.common.Uuid;
 import org.apache.kafka.common.compress.Compression;
 import org.apache.kafka.common.config.TopicConfig;
+import org.apache.kafka.common.errors.GroupIdNotFoundException;
 import org.apache.kafka.common.errors.InvalidRequestException;
 import org.apache.kafka.common.errors.NotCoordinatorException;
 import org.apache.kafka.common.errors.StreamsInvalidTopologyException;
 import org.apache.kafka.common.errors.UnsupportedAssignorException;
+import org.apache.kafka.common.errors.UnsupportedVersionException;
 import org.apache.kafka.common.internals.Plugin;
 import org.apache.kafka.common.internals.Topic;
 import org.apache.kafka.common.message.AlterShareGroupOffsetsRequestData;
@@ -59,6 +61,8 @@ import 
org.apache.kafka.common.message.ShareGroupHeartbeatResponseData;
 import org.apache.kafka.common.message.StreamsGroupDescribeResponseData;
 import org.apache.kafka.common.message.StreamsGroupHeartbeatRequestData;
 import org.apache.kafka.common.message.StreamsGroupHeartbeatResponseData;
+import 
org.apache.kafka.common.message.StreamsGroupTopologyDescriptionUpdateRequestData;
+import 
org.apache.kafka.common.message.StreamsGroupTopologyDescriptionUpdateResponseData;
 import org.apache.kafka.common.message.SyncGroupRequestData;
 import org.apache.kafka.common.message.SyncGroupResponseData;
 import org.apache.kafka.common.message.TxnOffsetCommitRequestData;
@@ -102,6 +106,7 @@ import 
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescri
 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.StreamsGroupTopologyDescriptionConverter;
 import 
org.apache.kafka.coordinator.group.streams.StreamsGroupTopologyDescriptionManager;
 import org.apache.kafka.image.MetadataDelta;
 import org.apache.kafka.image.MetadataImage;
@@ -626,7 +631,7 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
     ) {
         if (!isActive.get()) {
             return CompletableFuture.completedFuture(
-                new StreamsGroupHeartbeatResult(
+                StreamsGroupHeartbeatResult.withoutEpochContext(
                     new 
StreamsGroupHeartbeatResponseData().setErrorCode(Errors.COORDINATOR_NOT_AVAILABLE.code())
                 )
             );
@@ -638,7 +643,7 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
         } catch (Throwable ex) {
             ApiError apiError = ApiError.fromThrowable(ex);
             return CompletableFuture.completedFuture(
-                new StreamsGroupHeartbeatResult(
+                StreamsGroupHeartbeatResult.withoutEpochContext(
                     new StreamsGroupHeartbeatResponseData()
                         .setErrorCode(apiError.error().code())
                         .setErrorMessage(apiError.message())
@@ -646,17 +651,36 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
             );
         }
 
-        return runtime.scheduleWriteOperation(
+        CompletableFuture<StreamsGroupHeartbeatResult> heartbeat = 
runtime.scheduleWriteOperation(
             "streams-group-heartbeat",
             topicPartitionFor(request.groupId()),
-            coordinator -> coordinator.streamsGroupHeartbeat(context, request)
-        ).thenApply(result -> 
streamsGroupTopologyDescriptionManager.maybeSetTopologyDescriptionRequired(result,
 request.groupId(), context.requestVersion())
-        ).exceptionally(exception -> handleOperationException(
+            coordinator -> coordinator.streamsGroupHeartbeat(context, 
request));
+
+        if (streamsGroupTopologyDescriptionManager.isPluginConfigured()) {
+            heartbeat = heartbeat.thenApply(result -> {
+                try {
+                    return 
streamsGroupTopologyDescriptionManager.maybeSetTopologyDescriptionRequired(
+                        result, request.groupId(), context.requestVersion());
+                } catch (Throwable t) {
+                    // The heartbeat has already committed durably; if 
decoration fails (e.g.
+                    // because of an unexpected response shape) we log and 
return the
+                    // committed result as-is rather than translating into an 
error via the
+                    // exceptionally below — that would mask a successful 
broker-side state
+                    // change behind a client-visible failure.
+                    log.warn("Failed to apply topology-description 
post-processing on the "
+                        + "streams group heartbeat response for group {}; 
returning the response unmodified.",
+                        request.groupId(), t);
+                    return result;
+                }
+            });
+        }
+
+        return heartbeat.exceptionally(exception -> handleOperationException(
             "streams-group-heartbeat",
             request,
             exception,
             (error, message) ->
-                new StreamsGroupHeartbeatResult(
+                StreamsGroupHeartbeatResult.withoutEpochContext(
                     new StreamsGroupHeartbeatResponseData()
                         .setErrorCode(error.code())
                         .setErrorMessage(message)
@@ -665,6 +689,159 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
         ));
     }
 
+    /**
+     * See {@link 
GroupCoordinator#streamsGroupTopologyDescriptionUpdate(AuthorizableRequestContext,
 StreamsGroupTopologyDescriptionUpdateRequestData)}.
+     *
+     * <p>The push pipeline lives on {@link TopologyDescriptionManager}; the 
service is
+     * responsible only for short-circuiting on a non-active coordinator and 
translating
+     * unhandled exceptions into the wire error response.
+     */
+    @Override
+    public 
CompletableFuture<StreamsGroupTopologyDescriptionUpdateResponseData> 
streamsGroupTopologyDescriptionUpdate(
+        AuthorizableRequestContext context,
+        StreamsGroupTopologyDescriptionUpdateRequestData request
+    ) {
+        if (!isActive.get()) {
+            return CompletableFuture.completedFuture(
+                new StreamsGroupTopologyDescriptionUpdateResponseData()
+                    .setErrorCode(Errors.COORDINATOR_NOT_AVAILABLE.code())
+            );
+        }
+
+        try {
+            throwIfStreamsGroupTopologyDescriptionUpdateInvalid(request);
+        } catch (Throwable ex) {
+            ApiError apiError = ApiError.fromThrowable(ex);
+            return CompletableFuture.completedFuture(new 
StreamsGroupTopologyDescriptionUpdateResponseData()
+                .setErrorCode(apiError.error().code())
+                .setErrorMessage(apiError.message())
+            );
+        }
+
+        final String groupId = request.groupId();
+        final String memberId = request.memberId();
+        final int pushedEpoch = request.topologyEpoch();
+        final TopicPartition tp = topicPartitionFor(groupId);
+
+        // Each terminal branch produces a SetTopologyOutcome carrying the 
response and the
+        // back-off disposition. Pre-plugin failures (validate / convert / 
runtime error)
+        // skip the post-plugin stages and are wrapped with BackoffAction.NOOP 
by
+        // exceptionally so a fenced or unauthorized caller cannot grief the 
back-off and
+        // suppress legitimate solicitation. Post-plugin failures arm the 
back-off; a
+        // post-plugin write failing with GroupIdNotFoundException — the group 
was deleted
+        // between the plugin call and the write — drops the orphaned entry 
since no live
+        // group remains to throttle.
+        return runtime.scheduleReadOperation(
+                "streams-group-topology-description-validate",
+                tp,
+                (coordinator, lastCommittedOffset) -> {
+                    coordinator.validateStreamsGroupTopologyDescriptionUpdate(
+                        groupId, memberId, pushedEpoch, lastCommittedOffset);
+                    return null;
+                })
+            .thenApply(__ -> 
StreamsGroupTopologyDescriptionConverter.fromRequest(request.topologyDescription()))
+            .thenCompose(description -> 
streamsGroupTopologyDescriptionManager.invokeSetTopology(
+                groupId, pushedEpoch, description))
+            .thenCompose(pluginOutcome -> 
postPluginSetTopologyAction(pluginOutcome, groupId, pushedEpoch, tp))
+            .exceptionally(t -> new SetTopologyOutcome(null, 
BackoffAction.NOOP, t))
+            .thenApply(outcome -> {
+                applySetTopologyBackoff(outcome, groupId, pushedEpoch);
+                return outcome;
+            })
+            .thenCompose(outcome -> outcome.failure() == null
+                ? CompletableFuture.completedFuture(outcome.response())
+                : CompletableFuture.failedFuture(outcome.failure()))
+            .exceptionally(exception -> handleOperationException(
+                "streams-group-topology-description-update",
+                request,
+                exception,
+                (error, message) -> new 
StreamsGroupTopologyDescriptionUpdateResponseData()
+                    .setErrorCode(error.code())
+                    .setErrorMessage(message),
+                log
+            ));
+    }
+
+    private CompletableFuture<SetTopologyOutcome> postPluginSetTopologyAction(
+        StreamsGroupTopologyDescriptionManager.PluginOutcome pluginOutcome,
+        String groupId,
+        int pushedEpoch,
+        TopicPartition tp
+    ) {
+        return switch (pluginOutcome.kind()) {
+            case SUCCESS -> runtime.scheduleWriteOperation(
+                "streams-group-set-stored-topology-epoch",
+                tp,
+                coordinator -> 
coordinator.streamsGroupSetTopologyDescriptionEpoch(groupId, pushedEpoch, false)
+            ).handle((unused, throwable) -> outcomeForPostPluginWrite(
+                throwable, new 
StreamsGroupTopologyDescriptionUpdateResponseData()));
+            case PERMANENT -> runtime.scheduleWriteOperation(
+                "streams-group-set-failed-topology-epoch",
+                tp,
+                coordinator -> 
coordinator.streamsGroupSetTopologyDescriptionEpoch(groupId, pushedEpoch, true)
+            ).handle((unused, throwable) -> outcomeForPostPluginWrite(
+                throwable,
+                
topologyDescriptionUpdateError(Errors.STREAMS_TOPOLOGY_DESCRIPTION_UPDATE_FAILED,
 pluginOutcome.message())));
+            case TRANSIENT -> CompletableFuture.completedFuture(new 
SetTopologyOutcome(
+                
topologyDescriptionUpdateError(Errors.STREAMS_TOPOLOGY_DESCRIPTION_UPDATE_FAILED,
 pluginOutcome.message()),
+                BackoffAction.ARM, null));
+        };
+    }
+
+    private static SetTopologyOutcome outcomeForPostPluginWrite(
+        Throwable throwable,
+        StreamsGroupTopologyDescriptionUpdateResponseData responseOnSuccess
+    ) {
+        if (throwable == null) {
+            return new SetTopologyOutcome(responseOnSuccess, 
BackoffAction.CLEAR, null);
+        }
+        // The group was deleted between the plugin call and the bookkeeping 
write — the
+        // push already took effect at the plugin and no live group remains to 
throttle,
+        // so drop the orphaned back-off entry instead of arming one nobody 
will clear.
+        if (Errors.maybeUnwrapException(throwable) instanceof 
GroupIdNotFoundException) {
+            return new SetTopologyOutcome(null, BackoffAction.CLEAR_GROUP, 
throwable);
+        }
+        return new SetTopologyOutcome(null, BackoffAction.ARM, throwable);
+    }
+
+    private void applySetTopologyBackoff(SetTopologyOutcome outcome, String 
groupId, int pushedEpoch) {
+        switch (outcome.backoffAction()) {
+            case NOOP -> { }
+            case CLEAR -> 
streamsGroupTopologyDescriptionManager.clearBackoff(groupId, pushedEpoch);
+            case CLEAR_GROUP -> 
streamsGroupTopologyDescriptionManager.clearBackoffGroup(groupId);
+            case ARM -> 
streamsGroupTopologyDescriptionManager.armBackoff(groupId, pushedEpoch);
+        }
+    }
+
+    private static StreamsGroupTopologyDescriptionUpdateResponseData 
topologyDescriptionUpdateError(
+        Errors error,
+        String message
+    ) {
+        return new StreamsGroupTopologyDescriptionUpdateResponseData()
+            .setErrorCode(error.code())
+            .setErrorMessage(message);
+    }
+
+    private record SetTopologyOutcome(
+        StreamsGroupTopologyDescriptionUpdateResponseData response,
+        BackoffAction backoffAction,
+        Throwable failure
+    ) { }
+
+    private enum BackoffAction { NOOP, ARM, CLEAR, CLEAR_GROUP }
+
+    private void throwIfStreamsGroupTopologyDescriptionUpdateInvalid(
+        StreamsGroupTopologyDescriptionUpdateRequestData request
+    ) throws InvalidRequestException, UnsupportedVersionException {
+        if (!streamsGroupTopologyDescriptionManager.isPluginConfigured()) {
+            throw new UnsupportedVersionException(
+                "The broker has no streams group topology description plugin 
configured.");
+        }
+        throwIfEmptyString(request.memberId(), "MemberId can't be empty.");
+        throwIfEmptyString(request.groupId(), "GroupId can't be empty.");
+        throwIfNull(request.topologyDescription(), "TopologyDescription can't 
be null.");
+    }
+
     /**
      * Validates the ShareGroupHeartbeat request.
      *
diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorShard.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorShard.java
index b65260f1123..ed25f77c158 100644
--- 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorShard.java
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorShard.java
@@ -21,6 +21,7 @@ import org.apache.kafka.common.Uuid;
 import org.apache.kafka.common.errors.ApiException;
 import org.apache.kafka.common.errors.GroupIdNotFoundException;
 import org.apache.kafka.common.errors.GroupNotEmptyException;
+import org.apache.kafka.common.errors.InvalidRequestException;
 import org.apache.kafka.common.errors.UnknownMemberIdException;
 import org.apache.kafka.common.errors.UnsupportedVersionException;
 import org.apache.kafka.common.internals.Plugin;
@@ -913,23 +914,48 @@ public class GroupCoordinatorShard implements 
CoordinatorShard<CoordinatorRecord
     }
 
     /**
-     * Validates that a streams group exists and that the given member is a 
current member of it.
-     * The lookup runs at {@code committedOffset} so an uncommitted 
fence/leave does not
-     * cause a still-live member to appear unknown (or vice versa). Must be 
scheduled on the
+     * Validates that a streams group exists, that the given member is a 
current member of
+     * it, and that {@code pushedEpoch} matches the group's current topology 
epoch. The
+     * lookup runs at {@code committedOffset} so an uncommitted fence/leave 
does not cause
+     * a still-live member to appear unknown (or vice versa). Must be 
scheduled on the
      * coordinator runtime like any other read.
      *
      * @param groupId          The group ID.
      * @param memberId         The member ID.
+     * @param pushedEpoch      The topology epoch carried by the push request.
      * @param committedOffset  A committed offset corresponding to the desired 
snapshot.
      * @throws GroupIdNotFoundException if the group does not exist.
      * @throws UnknownMemberIdException if the member is not in the group.
+     * @throws InvalidRequestException  if {@code pushedEpoch} does not match 
the group's
+     *                                  current topology epoch.
      */
-    public void validateStreamsGroupMember(
+    public void validateStreamsGroupTopologyDescriptionUpdate(
         String groupId,
         String memberId,
+        int pushedEpoch,
         long committedOffset
     ) {
-        groupMetadataManager.validateStreamsGroupMember(groupId, memberId, 
committedOffset);
+        groupMetadataManager.validateStreamsGroupTopologyDescriptionUpdate(
+            groupId, memberId, pushedEpoch, committedOffset);
+    }
+
+    /**
+     * Persist the outcome of a topology description plugin call. Writes a 
metadata record
+     * advancing either {@code StoredDescriptionTopologyEpoch} (on plugin 
success) or
+     * {@code FailedDescriptionTopologyEpoch} (on permanent plugin failure).
+     *
+     * @param groupId           The streams group id.
+     * @param pushedEpoch       The topology epoch on the push that just 
completed.
+     * @param permanentFailure  True if the plugin signalled a permanent 
failure; false on success.
+     * @return A coordinator result carrying the metadata record.
+     * @throws GroupIdNotFoundException if the streams group no longer exists.
+     */
+    public CoordinatorResult<Void, CoordinatorRecord> 
streamsGroupSetTopologyDescriptionEpoch(
+        String groupId,
+        int pushedEpoch,
+        boolean permanentFailure
+    ) {
+        return 
groupMetadataManager.streamsGroupSetTopologyDescriptionEpoch(groupId, 
pushedEpoch, permanentFailure);
     }
 
     /**
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 c5673613fe4..0c42f200529 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
@@ -4469,16 +4469,15 @@ public class GroupMetadataManager {
             .setMemberEpoch(memberEpoch)
             .setStatus(List.of());
 
+        // Leave/fence paths use 
StreamsGroupHeartbeatResult.withoutEpochContext: the departing
+        // member will never push a topology description, so the service-layer 
post-processing
+        // sees epochs of -1 and short-circuits. Without this the manager 
would arm a back-off
+        // window for a member that is on its way out, delaying push 
solicitation for the rest
+        // of the group.
         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(),
-                group.storedDescriptionTopologyEpoch(),
-                group.failedDescriptionTopologyEpoch()
-            ));
+            return streamsGroupFenceMember(group, member, 
StreamsGroupHeartbeatResult.withoutEpochContext(response));
         } else {
             StreamsGroupMember member = group.staticMember(instanceId);
             throwIfStaticMemberIsUnknown(member, instanceId);
@@ -4490,13 +4489,7 @@ 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(),
-                    group.storedDescriptionTopologyEpoch(),
-                    group.failedDescriptionTopologyEpoch()
-                ));
+                return streamsGroupFenceMember(group, member, 
StreamsGroupHeartbeatResult.withoutEpochContext(response));
             }
         }
     }
@@ -4563,15 +4556,12 @@ public class GroupMetadataManager {
             .setMemberEpoch(LEAVE_GROUP_STATIC_MEMBER_EPOCH)
             .setStatus(List.of());
 
+        // Static-leave is a departing path: a member that will not push at 
this epoch,
+        // so we use the withoutEpochContext factory to skip the heartbeat 
post-processing
+        // and avoid arming the back-off on its behalf.
         return new CoordinatorResult<>(
             List.of(record),
-            new StreamsGroupHeartbeatResult(
-                response,
-                Map.of(),
-                group.currentTopologyEpoch(),
-                group.storedDescriptionTopologyEpoch(),
-                group.failedDescriptionTopologyEpoch()
-            )
+            StreamsGroupHeartbeatResult.withoutEpochContext(response)
         );
     }
 
@@ -8362,26 +8352,78 @@ public class GroupMetadataManager {
     }
 
     /**
-     * Validates that a streams group exists and that the given member is a 
current member of it.
-     * Used by the StreamsGroupTopologyDescriptionUpdate RPC handler to 
enforce the
-     * GROUP_ID_NOT_FOUND / UNKNOWN_MEMBER_ID contract before consulting the 
topology description plugin.
-     * The lookup runs at {@code committedOffset} so an uncommitted 
fence/leave does not cause a
-     * still-live member to appear unknown (or vice versa).
+     * Validates that a streams group exists, that the given member is a 
current member of it,
+     * and that {@code pushedEpoch} matches the group's current topology 
epoch. Used by the
+     * StreamsGroupTopologyDescriptionUpdate RPC handler to enforce the
+     * GROUP_ID_NOT_FOUND / UNKNOWN_MEMBER_ID / INVALID_REQUEST contract 
before consulting
+     * the topology description plugin.
+     *
+     * <p>The epoch check rejects stale or out-of-order pushes so they cannot 
regress
+     * {@code storedDescriptionTopologyEpoch} or be persisted at an epoch the 
group never
+     * advertised. The lookup runs at {@code committedOffset} so an uncommitted
+     * fence/leave does not cause a still-live member to appear unknown (or 
vice versa).
      *
      * @param groupId         The group ID.
      * @param memberId        The member ID.
+     * @param pushedEpoch     The topology epoch carried by the push request.
      * @param committedOffset A committed offset corresponding to the desired 
snapshot.
      * @return The matching {@link StreamsGroupMember}.
      * @throws GroupIdNotFoundException if no streams group with this id 
exists.
      * @throws UnknownMemberIdException if the member is not currently in the 
group.
+     * @throws InvalidRequestException  if {@code pushedEpoch} does not match 
the group's
+     *                                  current topology epoch.
      */
-    public StreamsGroupMember validateStreamsGroupMember(
+    public StreamsGroupMember validateStreamsGroupTopologyDescriptionUpdate(
         String groupId,
         String memberId,
+        int pushedEpoch,
         long committedOffset
-    ) throws GroupIdNotFoundException, UnknownMemberIdException {
+    ) throws GroupIdNotFoundException, UnknownMemberIdException, 
InvalidRequestException {
         StreamsGroup group = streamsGroup(groupId, committedOffset);
-        return group.getMemberOrThrow(memberId, committedOffset);
+        StreamsGroupMember member = group.getMemberOrThrow(memberId, 
committedOffset);
+        int currentEpoch = group.currentTopologyEpoch();
+        if (pushedEpoch != currentEpoch) {
+            throw new InvalidRequestException(
+                "Topology epoch " + pushedEpoch + " does not match the group's 
current topology epoch "
+                    + currentEpoch + ".");
+        }
+        return member;
+    }
+
+    /**
+     * Persist the outcome of a topology description plugin call for a streams 
group.
+     *
+     * <p>On a successful plugin {@code setTopology} the {@code 
StoredDescriptionTopologyEpoch}
+     * field is advanced to the pushed epoch; on a permanent failure the
+     * {@code FailedDescriptionTopologyEpoch} field is advanced instead so 
subsequent
+     * heartbeats at the same epoch do not re-solicit a push.
+     *
+     * @param groupId           The streams group id.
+     * @param pushedEpoch       The topology epoch on the push that just 
completed.
+     * @param permanentFailure  True if the plugin signalled a permanent 
failure; false on success.
+     * @return A coordinator result carrying the metadata record that updates 
the field.
+     * @throws GroupIdNotFoundException if the streams group no longer exists.
+     */
+    public CoordinatorResult<Void, CoordinatorRecord> 
streamsGroupSetTopologyDescriptionEpoch(
+        String groupId,
+        int pushedEpoch,
+        boolean permanentFailure
+    ) throws GroupIdNotFoundException {
+        StreamsGroup group = streamsGroup(groupId);
+
+        int newStored = permanentFailure ? 
group.storedDescriptionTopologyEpoch() : pushedEpoch;
+        int newFailed = permanentFailure ? pushedEpoch : 
group.failedDescriptionTopologyEpoch();
+
+        CoordinatorRecord record = newStreamsGroupMetadataRecord(
+            groupId,
+            group.groupEpoch(),
+            group.metadataHash(),
+            group.validatedTopologyEpoch(),
+            group.lastAssignmentConfigs(),
+            newStored,
+            newFailed
+        );
+        return new CoordinatorResult<>(List.of(record), null);
     }
 
     /**
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 82b84d7b494..28fc1e844f2 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
@@ -55,11 +55,25 @@ public record StreamsGroupHeartbeatResult(
     }
 
     /**
-     * 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.
+     * Build a heartbeat result that bypasses the service-layer topology 
post-processing.
+     * Used at two distinct call sites:
+     *
+     * <ul>
+     *   <li>Failure-fast paths in {@code 
GroupCoordinatorService#streamsGroupHeartbeat}
+     *       — broker not active, request validation rejected, runtime error 
translated
+     *       by {@code handleOperationException}. The group is not resolved, 
so there is
+     *       no epoch context to track.</li>
+     *   <li>Departing-member paths in {@code GroupMetadataManager} (leave or 
fence).
+     *       The group exists but the member will not push a topology 
description, so
+     *       attaching the live epoch would arm a back-off window on its 
behalf and
+     *       delay solicitation for the rest of the group.</li>
+     * </ul>
+     *
+     * <p>All three epoch fields are set to -1, causing
+     * {@code maybeSetTopologyDescriptionRequired} to short-circuit before 
arming the
+     * back-off or setting the {@code TopologyDescriptionRequired} flag.
      */
-    public StreamsGroupHeartbeatResult(StreamsGroupHeartbeatResponseData data) 
{
-        this(data, Map.of(), -1, -1, -1);
+    public static StreamsGroupHeartbeatResult 
withoutEpochContext(StreamsGroupHeartbeatResponseData data) {
+        return new StreamsGroupHeartbeatResult(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
index afda7817908..2d4035aea95 100644
--- 
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
@@ -17,6 +17,7 @@
 package org.apache.kafka.coordinator.group.streams;
 
 import org.apache.kafka.common.utils.Time;
+import org.apache.kafka.common.utils.internals.ExponentialBackoff;
 
 import java.util.concurrent.ConcurrentHashMap;
 
@@ -24,22 +25,31 @@ 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.
+ * failure; consecutive arms at the same topology epoch advance the attempt 
count and
+ * delegate to {@link ExponentialBackoff} for the next delay . 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;
+    static final int MULTIPLIER = 2;
+    static final double JITTER = 0.2;
 
     private final Time time;
+    private final ExponentialBackoff exponentialBackoff;
     private final ConcurrentHashMap<String, Entry> state = new 
ConcurrentHashMap<>();
 
-    record Entry(int topologyEpoch, long currentDelayMs, long nextAttemptMs) { 
}
+    record Entry(int topologyEpoch, int attempts, long nextAttemptMs) { }
 
     public StreamsGroupTopologyDescriptionBackoff(Time time) {
+        this(time, new ExponentialBackoff(INITIAL_DELAY_MS, MULTIPLIER, 
MAX_DELAY_MS, JITTER));
+    }
+
+    // Visible for testing
+    StreamsGroupTopologyDescriptionBackoff(Time time, ExponentialBackoff 
exponentialBackoff) {
         this.time = time;
+        this.exponentialBackoff = exponentialBackoff;
     }
 
     /**
@@ -55,55 +65,73 @@ public class StreamsGroupTopologyDescriptionBackoff {
 
     /**
      * 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).
+     * installed, false if a window was already active and nothing changed. 
Used on the
+     * heartbeat path 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) -> {
+        while (true) {
+            final long now = time.milliseconds();
+            Entry existing = state.get(groupId);
             if (existing != null
                 && existing.topologyEpoch() == topologyEpoch
                 && now < existing.nextAttemptMs()) {
-                return existing;
+                return false;
             }
-            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);
+            Entry next = computeNextEntry(existing, topologyEpoch, now);
+            boolean installed = existing == null
+                ? state.putIfAbsent(groupId, next) == null
+                : state.replace(groupId, existing, next);
+            if (installed) {
+                return true;
             }
-            return new Entry(topologyEpoch, INITIAL_DELAY_MS, now + 
INITIAL_DELAY_MS);
-        });
-        return armed[0];
+            // Lost a race with a concurrent mutation; retry with the fresh 
state.
+        }
     }
 
     /**
-     * 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}.
+     * Arm a new back-off window or extend the existing one at the caller's 
topology epoch.
+     *
+     * <p>Epoch-aware: if the stored entry is for a newer topology epoch than 
{@code
+     * topologyEpoch} (a concurrent heartbeat already armed for the advanced 
epoch while a
+     * late post-plugin callback finishes at the old epoch), the call is a 
no-op so we do
+     * not overwrite the newer window. At the same epoch the attempt count 
advances and
+     * the next delay is drawn from {@link ExponentialBackoff}; at a 
stale-or-absent entry
+     * we start a fresh chain at {@code attempts=0}.
      */
     public void armOrExtend(String groupId, int topologyEpoch) {
         final long now = time.milliseconds();
+        state.compute(groupId, (key, existing) -> {
+            if (existing != null && existing.topologyEpoch() > topologyEpoch) {
+                return existing;
+            }
+            return computeNextEntry(existing, topologyEpoch, now);
+        });
+    }
+
+    /**
+     * Drop the back-off entry for a group at the given topology epoch. 
Epoch-aware: only
+     * clears if the stored entry matches {@code topologyEpoch} (a late 
post-plugin
+     * callback at the old epoch must not wipe a window a concurrent heartbeat 
armed at
+     * the advanced epoch). Called on a successful push or a permanent plugin 
failure.
+     */
+    public void clear(String groupId, int topologyEpoch) {
         state.compute(groupId, (key, existing) -> {
             if (existing == null || existing.topologyEpoch() != topologyEpoch) 
{
-                return new Entry(topologyEpoch, INITIAL_DELAY_MS, now + 
INITIAL_DELAY_MS);
+                return existing;
             }
-            long nextDelay = Math.min(existing.currentDelayMs() * 2, 
MAX_DELAY_MS);
-            return new Entry(topologyEpoch, nextDelay, now + nextDelay);
+            return null;
         });
     }
 
     /**
-     * Drop the back-off entry for a group. Called on a successful push, a 
permanent plugin
-     * failure, or when the group is removed.
+     * Drop the back-off entry for a group unconditionally. Used by paths that 
remove the
+     * group entirely (explicit {@code DeleteGroups}, periodic cleanup of 
naturally-expired
+     * groups), where any in-flight back-off state is irrelevant because the 
group is gone.
      */
-    public void clear(String groupId) {
+    public void clearGroup(String groupId) {
         state.remove(groupId);
     }
 
@@ -111,4 +139,20 @@ public class StreamsGroupTopologyDescriptionBackoff {
     Entry entry(String groupId) {
         return state.get(groupId);
     }
+
+    /**
+     * Build the next entry for an arm: same-epoch arms advance the attempt 
count to
+     * continue the exponential chain; a different (or absent) epoch starts 
fresh at
+     * {@code attempts=0}. The actual delay is drawn from {@link 
ExponentialBackoff} so
+     * the multiplier / max / jitter are configured in one place rather than 
hand-rolled
+     * twice.
+     */
+    private Entry computeNextEntry(Entry existing, int topologyEpoch, long 
now) {
+        int nextAttempts =
+            (existing != null && existing.topologyEpoch() == topologyEpoch)
+                ? existing.attempts() + 1
+                : 0;
+        long delay = exponentialBackoff.backoff(nextAttempts);
+        return new Entry(topologyEpoch, nextAttempts, now + delay);
+    }
 }
diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionConverter.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionConverter.java
new file mode 100644
index 00000000000..94de07f38b1
--- /dev/null
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionConverter.java
@@ -0,0 +1,108 @@
+/*
+ * 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.errors.InvalidRequestException;
+import 
org.apache.kafka.common.message.StreamsGroupTopologyDescriptionUpdateRequestData;
+import 
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescription;
+import 
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescription.GlobalStore;
+import 
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescription.Node;
+import 
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescription.Processor;
+import 
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescription.Sink;
+import 
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescription.Source;
+import 
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescription.Subtopology;
+
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Translates between the wire form of a streams topology description (as 
carried on
+ * {@code StreamsGroupTopologyDescriptionUpdateRequest}) and the broker-side
+ * {@link StreamsGroupTopologyDescription} POJO that is handed to the plugin.
+ *
+ * <p>String collections are wrapped in {@link LinkedHashSet} so that the wire 
ordering
+ * is preserved through to the POJO and any downstream pretty-printing.
+ */
+public final class StreamsGroupTopologyDescriptionConverter {
+
+    static final byte NODE_TYPE_SOURCE = 1;
+    static final byte NODE_TYPE_PROCESSOR = 2;
+    static final byte NODE_TYPE_SINK = 3;
+
+    private StreamsGroupTopologyDescriptionConverter() {
+    }
+
+    public static StreamsGroupTopologyDescription fromRequest(
+        StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription 
wire
+    ) {
+        List<Subtopology> subtopologies = wire.subtopologies().stream()
+            .map(StreamsGroupTopologyDescriptionConverter::convertSubtopology)
+            .toList();
+        List<GlobalStore> globalStores = wire.globalStores().stream()
+            .map(StreamsGroupTopologyDescriptionConverter::convertGlobalStore)
+            .toList();
+        return new StreamsGroupTopologyDescription(subtopologies, 
globalStores);
+    }
+
+    private static Subtopology convertSubtopology(
+        
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionSubtopology 
wire
+    ) {
+        List<Node> nodes = wire.nodes().stream()
+            .map(StreamsGroupTopologyDescriptionConverter::convertNode)
+            .toList();
+        return new Subtopology(wire.subtopologyId(), nodes);
+    }
+
+    private static GlobalStore convertGlobalStore(
+        
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionGlobalStore 
wire
+    ) {
+        Node source = convertNode(wire.source());
+        Node processor = convertNode(wire.processor());
+        if (!(source instanceof Source) || !(processor instanceof Processor)) {
+            throw new InvalidRequestException(
+                "Global store must be composed of a source and a processor 
node."
+            );
+        }
+        return new GlobalStore((Source) source, (Processor) processor);
+    }
+
+    private static Node convertNode(
+        
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode wire
+    ) {
+        return switch (wire.nodeType()) {
+            case NODE_TYPE_SOURCE -> new Source(
+                wire.name(),
+                new LinkedHashSet<>(wire.sourceTopics()),
+                new LinkedHashSet<>(wire.successors())
+            );
+            case NODE_TYPE_PROCESSOR -> new Processor(
+                wire.name(),
+                new LinkedHashSet<>(wire.stores()),
+                new LinkedHashSet<>(wire.successors())
+            );
+            case NODE_TYPE_SINK -> new Sink(
+                wire.name(),
+                Optional.ofNullable(wire.sinkTopic()),
+                new LinkedHashSet<>(wire.successors())
+            );
+            default -> throw new InvalidRequestException(
+                "Unknown topology node type: " + wire.nodeType()
+            );
+        };
+    }
+}
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
index ca848518d10..bf6f62dc804 100644
--- 
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
@@ -20,21 +20,28 @@ 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.StreamsGroupTopologyDescription;
 import 
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescriptionPlugin;
+import 
org.apache.kafka.coordinator.group.api.streams.StreamsTopologyDescriptionPermanentFailureException;
 
+import java.util.Objects;
 import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
 
 /**
- * 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.
+ * Broker-level component that owns the streams-group topology description 
plugin
+ * reference and the per-group re-solicitation back-off. The chain that drives 
a push
+ * RPC (validate → convert → plugin → metadata write → back-off mutation) 
lives on
+ * {@code GroupCoordinatorService}; this class exposes the building blocks 
(plugin
+ * invocation, back-off mutations) and the heartbeat-path gate, but does not 
assemble
+ * the chain itself.
  *
  * <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.
+ * {@code FailedDescriptionTopologyEpoch} fields on each streams group drive
+ * convergence after a restart.
  */
 public class StreamsGroupTopologyDescriptionManager implements AutoCloseable {
     private final Optional<StreamsGroupTopologyDescriptionPlugin> plugin;
@@ -114,6 +121,83 @@ public class StreamsGroupTopologyDescriptionManager 
implements AutoCloseable {
         return result;
     }
 
+    /**
+     * Call {@code plugin.setTopology} and fold the result into a {@link 
PluginOutcome}.
+     * The returned future never completes exceptionally — the outcome carries 
the
+     * failure category so the caller can dispatch on it without try/catch on 
the
+     * future. A synchronous throw from the plugin (which violates the SPI 
contract) is
+     * mapped to a permanent failure with a generic message rather than 
forwarding the
+     * raw exception text, and a {@code null} returned future is treated the 
same way.
+     */
+    public CompletableFuture<PluginOutcome> invokeSetTopology(
+        String groupId,
+        int topologyEpoch,
+        StreamsGroupTopologyDescription description
+    ) {
+        if (plugin.isEmpty()) {
+            return CompletableFuture.completedFuture(
+                PluginOutcome.permanent("Topology description plugin 
failed."));
+        }
+        final CompletableFuture<Void> pluginFuture;
+        try {
+            pluginFuture = Objects.requireNonNull(
+                plugin.get().setTopology(groupId, topologyEpoch, description));
+        } catch (Exception e) {
+            // A synchronous throw violates the SPI contract — implementations 
must signal
+            // failures by completing the future exceptionally. Treat it as a 
permanent
+            // failure with a stable, generic client-visible message so we 
don't forward
+            // an unbounded or null exception message that could leak plugin 
internals.
+            return CompletableFuture.completedFuture(
+                PluginOutcome.permanent("Topology description plugin 
failed."));
+        }
+        return pluginFuture.handle((unused, throwable) -> {
+            if (throwable == null) {
+                return PluginOutcome.success();
+            }
+            // CompletionException / ExecutionException can legally carry a 
null cause; if a
+            // plugin completes its future with one of those (rare but legal),
+            // maybeUnwrapException returns null. Treat that as a transient 
failure with a
+            // generic message rather than NPE-ing inside this handle and 
losing the
+            // transient/permanent classification downstream.
+            Throwable cause = Errors.maybeUnwrapException(throwable);
+            if (cause == null) {
+                return PluginOutcome.transientFailure("Plugin failure (no 
cause).");
+            }
+            if (cause instanceof 
StreamsTopologyDescriptionPermanentFailureException) {
+                return PluginOutcome.permanent(cause.getMessage());
+            }
+            return PluginOutcome.transientFailure(cause.getMessage());
+        });
+    }
+
+    /**
+     * Arm or extend the back-off window for a group at the given topology 
epoch.
+     * Delegates to {@link StreamsGroupTopologyDescriptionBackoff#armOrExtend}.
+     */
+    public void armBackoff(String groupId, int topologyEpoch) {
+        backoff.armOrExtend(groupId, topologyEpoch);
+    }
+
+    /**
+     * Drop the back-off entry for a group at the given topology epoch. 
Epoch-scoped so a
+     * late post-plugin callback at an old epoch cannot wipe a window a 
concurrent
+     * heartbeat armed at the advanced epoch. Delegates to
+     * {@link StreamsGroupTopologyDescriptionBackoff#clear}.
+     */
+    public void clearBackoff(String groupId, int topologyEpoch) {
+        backoff.clear(groupId, topologyEpoch);
+    }
+
+    /**
+     * Drop the back-off entry for a group unconditionally. Used by paths that 
remove the
+     * group entirely (explicit DeleteGroups, periodic cleanup of 
naturally-expired
+     * groups, post-plugin write failing with GroupIdNotFoundException). 
Delegates to
+     * {@link StreamsGroupTopologyDescriptionBackoff#clearGroup}.
+     */
+    public void clearBackoffGroup(String groupId) {
+        backoff.clearGroup(groupId);
+    }
+
     // Visible for testing.
     StreamsGroupTopologyDescriptionBackoff backoff() {
         return backoff;
@@ -126,4 +210,25 @@ public class StreamsGroupTopologyDescriptionManager 
implements AutoCloseable {
         byte staleCode = Status.STALE_TOPOLOGY.code();
         return response.status().stream().anyMatch(s -> s.statusCode() == 
staleCode);
     }
+
+    /**
+     * Outcome of a {@code plugin.setTopology} call, folded into a value so 
the caller can
+     * dispatch on {@link Kind} without try/catch on the underlying future.
+     */
+    public record PluginOutcome(Kind kind, String message) {
+
+        public enum Kind { SUCCESS, PERMANENT, TRANSIENT }
+
+        public static PluginOutcome success() {
+            return new PluginOutcome(Kind.SUCCESS, null);
+        }
+
+        public static PluginOutcome permanent(String message) {
+            return new PluginOutcome(Kind.PERMANENT, message);
+        }
+
+        public static PluginOutcome transientFailure(String message) {
+            return new PluginOutcome(Kind.TRANSIENT, message);
+        }
+    }
 }
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
index 7ed02b37d92..da4c4ae6591 100644
--- 
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
@@ -18,15 +18,21 @@ package org.apache.kafka.coordinator.group;
 
 import org.apache.kafka.common.TopicPartition;
 import org.apache.kafka.common.Uuid;
+import org.apache.kafka.common.errors.GroupIdNotFoundException;
+import org.apache.kafka.common.errors.UnknownMemberIdException;
 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.message.StreamsGroupTopologyDescriptionUpdateRequestData;
+import 
org.apache.kafka.common.message.StreamsGroupTopologyDescriptionUpdateResponseData;
 import org.apache.kafka.common.protocol.ApiKeys;
+import org.apache.kafka.common.protocol.Errors;
 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.api.streams.StreamsTopologyDescriptionPermanentFailureException;
 import org.apache.kafka.coordinator.group.metrics.GroupCoordinatorMetrics;
 import org.apache.kafka.coordinator.group.streams.StreamsGroupHeartbeatResult;
 import org.apache.kafka.server.share.persister.NoOpStatePersister;
@@ -34,28 +40,35 @@ import org.apache.kafka.server.util.timer.MockTimer;
 
 import org.junit.jupiter.api.Test;
 
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
 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.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 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.
+ * Tests for the topology-description plugin paths added to {@link 
GroupCoordinatorService}:
+ * the new {@code streamsGroupTopologyDescriptionUpdate} RPC, the heartbeat 
post-processing
+ * that sets {@code TopologyDescriptionRequired}, and the back-off interaction.
  */
 public class GroupCoordinatorServiceTopologyDescriptionTest {
 
@@ -99,6 +112,324 @@ public class 
GroupCoordinatorServiceTopologyDescriptionTest {
         return service;
     }
 
+    @Test
+    public void testUpdateRejectedWhenCoordinatorNotActive() throws Exception {
+        GroupCoordinatorService service = buildService(mockRuntime(), 
Optional.empty(), false);
+
+        StreamsGroupTopologyDescriptionUpdateResponseData response = 
service.streamsGroupTopologyDescriptionUpdate(
+            requestContext(ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE),
+            validUpdateRequest()
+        ).get(5, TimeUnit.SECONDS);
+
+        assertEquals(Errors.COORDINATOR_NOT_AVAILABLE.code(), 
response.errorCode());
+    }
+
+    @Test
+    public void testUpdateReturnsUnsupportedVersionWhenNoPlugin() throws 
Exception {
+        GroupCoordinatorService service = buildService(mockRuntime(), 
Optional.empty(), true);
+
+        StreamsGroupTopologyDescriptionUpdateResponseData response = 
service.streamsGroupTopologyDescriptionUpdate(
+            requestContext(ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE),
+            validUpdateRequest()
+        ).get(5, TimeUnit.SECONDS);
+
+        assertEquals(Errors.UNSUPPORTED_VERSION.code(), response.errorCode());
+        assertNotNull(response.errorMessage());
+    }
+
+    @Test
+    public void testUpdateRejectsEmptyMemberId() throws Exception {
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        GroupCoordinatorService service = buildService(mockRuntime(), 
Optional.of(plugin), true);
+
+        StreamsGroupTopologyDescriptionUpdateResponseData response = 
service.streamsGroupTopologyDescriptionUpdate(
+            requestContext(ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE),
+            validUpdateRequest().setMemberId("")
+        ).get(5, TimeUnit.SECONDS);
+
+        assertEquals(Errors.INVALID_REQUEST.code(), response.errorCode());
+        assertEquals("MemberId can't be empty.", response.errorMessage());
+    }
+
+    @Test
+    public void testUpdateRejectsEmptyGroupId() throws Exception {
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        GroupCoordinatorService service = buildService(mockRuntime(), 
Optional.of(plugin), true);
+
+        StreamsGroupTopologyDescriptionUpdateResponseData response = 
service.streamsGroupTopologyDescriptionUpdate(
+            requestContext(ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE),
+            validUpdateRequest().setGroupId("")
+        ).get(5, TimeUnit.SECONDS);
+
+        assertEquals(Errors.INVALID_REQUEST.code(), response.errorCode());
+    }
+
+    @Test
+    public void testUpdateSuccessPersistsStoredEpoch() throws Exception {
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(plugin.setTopology(anyString(), anyInt(), any()))
+            .thenReturn(CompletableFuture.completedFuture(null));
+        when(runtime.scheduleReadOperation(
+            eq("streams-group-topology-description-validate"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(null));
+        when(runtime.scheduleWriteOperation(
+            eq("streams-group-set-stored-topology-epoch"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(null));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+
+        StreamsGroupTopologyDescriptionUpdateResponseData response = 
service.streamsGroupTopologyDescriptionUpdate(
+            requestContext(ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE),
+            validUpdateRequest()
+        ).get(5, TimeUnit.SECONDS);
+
+        assertEquals(Errors.NONE.code(), response.errorCode());
+        verify(plugin, times(1)).setTopology(eq("foo"), eq(3), any());
+        verify(runtime, times(1)).scheduleWriteOperation(
+            eq("streams-group-set-stored-topology-epoch"), eq(GROUP_TP), 
any());
+
+        // Back-off must be cleared on success: a subsequent heartbeat at the 
same epoch
+        // (stored still lags in this mock-only world because the 
metadata-record write
+        // is captured but not replayed) should set the flag again.
+        assertTrue(heartbeatTopologyDescriptionRequired(runtime, service, 3, 
-1, -1));
+    }
+
+    @Test
+    public void testUpdatePermanentFailurePersistsFailedEpoch() throws 
Exception {
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(plugin.setTopology(anyString(), anyInt(), any()))
+            .thenReturn(CompletableFuture.failedFuture(
+                new StreamsTopologyDescriptionPermanentFailureException("too 
large")));
+        when(runtime.scheduleReadOperation(
+            eq("streams-group-topology-description-validate"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(null));
+        when(runtime.scheduleWriteOperation(
+            eq("streams-group-set-failed-topology-epoch"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(null));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+
+        StreamsGroupTopologyDescriptionUpdateResponseData response = 
service.streamsGroupTopologyDescriptionUpdate(
+            requestContext(ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE),
+            validUpdateRequest()
+        ).get(5, TimeUnit.SECONDS);
+
+        assertEquals(Errors.STREAMS_TOPOLOGY_DESCRIPTION_UPDATE_FAILED.code(), 
response.errorCode());
+        assertEquals("too large", response.errorMessage());
+        verify(runtime, times(1)).scheduleWriteOperation(
+            eq("streams-group-set-failed-topology-epoch"), eq(GROUP_TP), 
any());
+    }
+
+    @Test
+    public void testUpdateTransientFailureWritesNoRecord() throws Exception {
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(plugin.setTopology(anyString(), anyInt(), any()))
+            .thenReturn(CompletableFuture.failedFuture(new 
RuntimeException("backend offline")));
+        when(runtime.scheduleReadOperation(
+            eq("streams-group-topology-description-validate"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(null));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+
+        StreamsGroupTopologyDescriptionUpdateResponseData response = 
service.streamsGroupTopologyDescriptionUpdate(
+            requestContext(ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE),
+            validUpdateRequest()
+        ).get(5, TimeUnit.SECONDS);
+
+        assertEquals(Errors.STREAMS_TOPOLOGY_DESCRIPTION_UPDATE_FAILED.code(), 
response.errorCode());
+        assertEquals("backend offline", response.errorMessage());
+        verify(runtime, never()).scheduleWriteOperation(
+            eq("streams-group-set-stored-topology-epoch"), any(), any());
+        verify(runtime, never()).scheduleWriteOperation(
+            eq("streams-group-set-failed-topology-epoch"), any(), any());
+
+        // Back-off must be armed on transient failure: a subsequent heartbeat 
at the same
+        // epoch is suppressed rather than re-soliciting immediately.
+        assertFalse(heartbeatTopologyDescriptionRequired(runtime, service, 3, 
-1, -1));
+    }
+
+    @Test
+    public void testUpdatePluginFutureWithNullCauseIsTreatedAsTransient() 
throws Exception {
+        // CompletionException / ExecutionException can legally carry a null 
cause. If a
+        // plugin completes its future with one of those (rare but legal), the 
handle()
+        // callback must not NPE on cause.getMessage() — that would lose the
+        // transient/permanent classification and surface 
UNKNOWN_SERVER_ERROR. The null
+        // cause is treated as a transient failure with a generic message.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(plugin.setTopology(anyString(), anyInt(), any()))
+            .thenReturn(CompletableFuture.failedFuture(new 
CompletionException(null)));
+        when(runtime.scheduleReadOperation(
+            eq("streams-group-topology-description-validate"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(null));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+
+        StreamsGroupTopologyDescriptionUpdateResponseData response = 
service.streamsGroupTopologyDescriptionUpdate(
+            requestContext(ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE),
+            validUpdateRequest()
+        ).get(5, TimeUnit.SECONDS);
+
+        // Treated as transient: STREAMS_TOPOLOGY_DESCRIPTION_UPDATE_FAILED 
rather than
+        // UNKNOWN_SERVER_ERROR; no metadata record written; back-off armed.
+        assertEquals(Errors.STREAMS_TOPOLOGY_DESCRIPTION_UPDATE_FAILED.code(), 
response.errorCode());
+        assertNotNull(response.errorMessage());
+        verify(runtime, never()).scheduleWriteOperation(
+            eq("streams-group-set-stored-topology-epoch"), any(), any());
+        verify(runtime, never()).scheduleWriteOperation(
+            eq("streams-group-set-failed-topology-epoch"), any(), any());
+        assertFalse(heartbeatTopologyDescriptionRequired(runtime, service, 3, 
-1, -1));
+    }
+
+    @Test
+    public void 
testUpdateBackoffArmedWhenStoredEpochWriteFailsAfterPluginSuccess() throws 
Exception {
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(plugin.setTopology(anyString(), anyInt(), any()))
+            .thenReturn(CompletableFuture.completedFuture(null));
+        when(runtime.scheduleReadOperation(
+            eq("streams-group-topology-description-validate"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(null));
+        when(runtime.scheduleWriteOperation(
+            eq("streams-group-set-stored-topology-epoch"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.failedFuture(new 
RuntimeException("write failed")));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+
+        StreamsGroupTopologyDescriptionUpdateResponseData response = 
service.streamsGroupTopologyDescriptionUpdate(
+            requestContext(ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE),
+            validUpdateRequest()
+        ).get(5, TimeUnit.SECONDS);
+
+        // The exceptionally branch translates the write failure into an error 
response.
+        assertEquals(Errors.UNKNOWN_SERVER_ERROR.code(), response.errorCode());
+        verify(plugin, times(1)).setTopology(eq("foo"), eq(3), any());
+
+        // Plugin succeeded, write failed. BackoffAction defaulted to ARM and 
the thenApply
+        // that would have set CLEAR never ran, so whenComplete armed the 
back-off.
+        assertFalse(heartbeatTopologyDescriptionRequired(runtime, service, 3, 
-1, -1));
+    }
+
+    @Test
+    public void testUpdatePreValidationFailureDoesNotArmBackoff() throws 
Exception {
+        // A fenced/stale member (or, once routing lands, an unauthorized 
caller) whose
+        // validateStreamsGroupMember check fails must not arm the per-group 
back-off:
+        // legitimate members of the same group must still get 
TopologyDescriptionRequired
+        // on their next heartbeat at the same epoch.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(runtime.scheduleReadOperation(
+            eq("streams-group-topology-description-validate"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.failedFuture(
+            new UnknownMemberIdException("Member fenced from the group.")));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+
+        StreamsGroupTopologyDescriptionUpdateResponseData response = 
service.streamsGroupTopologyDescriptionUpdate(
+            requestContext(ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE),
+            validUpdateRequest()
+        ).get(5, TimeUnit.SECONDS);
+
+        assertEquals(Errors.UNKNOWN_MEMBER_ID.code(), response.errorCode());
+        verify(plugin, never()).setTopology(anyString(), anyInt(), any());
+
+        // Pre-plugin failure must leave the back-off untouched. A legitimate 
heartbeat at
+        // the same epoch must still get the flag — otherwise a 
fenced/unauthorized caller
+        // could grief the entire group's re-solicitation until the back-off 
window expires.
+        assertTrue(heartbeatTopologyDescriptionRequired(runtime, service, 3, 
-1, -1));
+    }
+
+    @Test
+    public void 
testUpdateGroupDisappearsBetweenPluginSuccessAndWriteDropsBackoffEntry() throws 
Exception {
+        // KIP-1331 race: the plugin succeeds, then the group is deleted, then 
the bookkeeping
+        // write for StoredDescriptionTopologyEpoch fails with 
GroupIdNotFoundException. The
+        // push has already taken effect at the plugin and the group is gone, 
so the back-off
+        // must be dropped rather than re-armed for a now-orphan group.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(plugin.setTopology(anyString(), anyInt(), any()))
+            .thenReturn(CompletableFuture.completedFuture(null));
+        when(runtime.scheduleReadOperation(
+            eq("streams-group-topology-description-validate"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(null));
+        when(runtime.scheduleWriteOperation(
+            eq("streams-group-set-stored-topology-epoch"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.failedFuture(
+            new GroupIdNotFoundException("Group deleted between plugin success 
and bookkeeping write.")));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+
+        StreamsGroupTopologyDescriptionUpdateResponseData response = 
service.streamsGroupTopologyDescriptionUpdate(
+            requestContext(ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE),
+            validUpdateRequest()
+        ).get(5, TimeUnit.SECONDS);
+
+        assertEquals(Errors.GROUP_ID_NOT_FOUND.code(), response.errorCode());
+        verify(plugin, times(1)).setTopology(eq("foo"), eq(3), any());
+
+        // Back-off must be dropped, not armed: nobody will ever clear it for 
a deleted group.
+        // A subsequent heartbeat at the same epoch (if the group somehow 
comes back) must
+        // still get the flag — i.e. the orphan back-off entry is gone.
+        assertTrue(heartbeatTopologyDescriptionRequired(runtime, service, 3, 
-1, -1));
+    }
+
+    @Test
+    public void testUpdatePluginReturnsNullFutureIsTreatedAsPermanentFailure() 
throws Exception {
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(plugin.setTopology(anyString(), anyInt(), 
any())).thenReturn(null);
+        when(runtime.scheduleReadOperation(
+            eq("streams-group-topology-description-validate"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(null));
+        when(runtime.scheduleWriteOperation(
+            eq("streams-group-set-failed-topology-epoch"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(null));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+
+        StreamsGroupTopologyDescriptionUpdateResponseData response = 
service.streamsGroupTopologyDescriptionUpdate(
+            requestContext(ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE),
+            validUpdateRequest()
+        ).get(5, TimeUnit.SECONDS);
+
+        assertEquals(Errors.STREAMS_TOPOLOGY_DESCRIPTION_UPDATE_FAILED.code(), 
response.errorCode());
+        assertEquals("Topology description plugin failed.", 
response.errorMessage());
+        // Treated as permanent failure: FailedDescriptionTopologyEpoch is 
written.
+        verify(runtime, times(1)).scheduleWriteOperation(
+            eq("streams-group-set-failed-topology-epoch"), eq(GROUP_TP), 
any());
+        verify(runtime, never()).scheduleWriteOperation(
+            eq("streams-group-set-stored-topology-epoch"), any(), any());
+    }
+
     @Test
     public void testHeartbeatSetsTopologyDescriptionRequiredWhenStoredLags() 
throws Exception {
         CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
@@ -226,6 +557,38 @@ public class 
GroupCoordinatorServiceTopologyDescriptionTest {
         assertFalse(result.data().topologyDescriptionRequired());
     }
 
+    @Test
+    public void testHeartbeatDecorationFailurePreservesCommittedResponse() 
throws Exception {
+        // If maybeSetTopologyDescriptionRequired throws while decorating an
+        // already-committed successful heartbeat (for example, because the 
response carries
+        // an unexpected shape such as a null Status element), the service 
must NOT translate
+        // that into an error response — the broker-side state change has 
already happened,
+        // so we return the committed result as-is and let the next heartbeat 
retry.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+
+        // Construct a heartbeat result where responseHasStaleTopology will 
NPE on the null
+        // status element. errorCode is NONE so we exercise the success path.
+        StreamsGroupHeartbeatResponseData response = new 
StreamsGroupHeartbeatResponseData()
+            .setStatus(Collections.singletonList(null));
+        when(runtime.scheduleWriteOperation(
+            eq("streams-group-heartbeat"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(
+            new StreamsGroupHeartbeatResult(response, 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);
+
+        // No error translation: the response carries NONE and the original 
status, the flag
+        // is left unset because decoration could not complete.
+        assertEquals(Errors.NONE.code(), result.data().errorCode());
+        assertFalse(result.data().topologyDescriptionRequired());
+    }
+
     @Test
     public void testHeartbeatNeverSetsFlagWithoutPlugin() throws Exception {
         CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
@@ -271,6 +634,17 @@ public class 
GroupCoordinatorServiceTopologyDescriptionTest {
         verify(plugin, times(1)).close();
     }
 
+    private static StreamsGroupTopologyDescriptionUpdateRequestData 
validUpdateRequest() {
+        return new StreamsGroupTopologyDescriptionUpdateRequestData()
+            .setGroupId("foo")
+            .setMemberId(Uuid.randomUuid().toString())
+            .setTopologyEpoch(3)
+            .setTopologyDescription(
+                new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription()
+                    .setSubtopologies(List.of())
+                    .setGlobalStores(List.of()));
+    }
+
     private static StreamsGroupHeartbeatRequestData validHeartbeatRequest() {
         return new StreamsGroupHeartbeatRequestData()
             .setGroupId("foo")
@@ -282,4 +656,35 @@ public class 
GroupCoordinatorServiceTopologyDescriptionTest {
             .setWarmupTasks(List.of())
             .setTopology(new StreamsGroupHeartbeatRequestData.Topology());
     }
+
+    /**
+     * Drive a heartbeat through the service after an update test and report 
whether the
+     * topology-description-required flag was set on the response. Used to 
observe the
+     * back-off state behaviourally: the flag is set iff the back-off window 
is not in
+     * effect for the given epoch, so the assertion stands in for "back-off 
cleared" vs
+     * "back-off armed".
+     */
+    private static boolean heartbeatTopologyDescriptionRequired(
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime,
+        GroupCoordinatorService service,
+        int currentEpoch,
+        int storedEpoch,
+        int failedEpoch
+    ) throws Exception {
+        when(runtime.scheduleWriteOperation(
+            eq("streams-group-heartbeat"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(new 
StreamsGroupHeartbeatResult(
+            new StreamsGroupHeartbeatResponseData(),
+            Map.of(),
+            currentEpoch,
+            storedEpoch,
+            failedEpoch
+        )));
+        StreamsGroupHeartbeatResult result = service.streamsGroupHeartbeat(
+            requestContext(ApiKeys.STREAMS_GROUP_HEARTBEAT), 
validHeartbeatRequest()
+        ).get(5, TimeUnit.SECONDS);
+        return result.data().topologyDescriptionRequired();
+    }
 }
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 448145c54fa..5f375d67c2e 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
@@ -19,6 +19,7 @@ package org.apache.kafka.coordinator.group;
 import org.apache.kafka.common.Uuid;
 import org.apache.kafka.common.errors.GroupIdNotFoundException;
 import org.apache.kafka.common.errors.GroupNotEmptyException;
+import org.apache.kafka.common.errors.InvalidRequestException;
 import org.apache.kafka.common.errors.UnknownMemberIdException;
 import org.apache.kafka.common.message.ConsumerGroupHeartbeatRequestData;
 import org.apache.kafka.common.message.ConsumerGroupHeartbeatResponseData;
@@ -244,21 +245,27 @@ public class GroupCoordinatorShardTest {
         );
 
         // Happy path: manager returns a member, shard returns void.
-        when(groupMetadataManager.validateStreamsGroupMember("foo", "m1", 
100L))
+        
when(groupMetadataManager.validateStreamsGroupTopologyDescriptionUpdate("foo", 
"m1", 3, 100L))
             .thenReturn(mock(StreamsGroupMember.class));
-        coordinator.validateStreamsGroupMember("foo", "m1", 100L);
+        coordinator.validateStreamsGroupTopologyDescriptionUpdate("foo", "m1", 
3, 100L);
 
         // GROUP_ID_NOT_FOUND propagates.
-        when(groupMetadataManager.validateStreamsGroupMember("missing", "m1", 
100L))
+        
when(groupMetadataManager.validateStreamsGroupTopologyDescriptionUpdate("missing",
 "m1", 3, 100L))
             .thenThrow(new GroupIdNotFoundException("nope"));
         assertThrows(GroupIdNotFoundException.class,
-            () -> coordinator.validateStreamsGroupMember("missing", "m1", 
100L));
+            () -> 
coordinator.validateStreamsGroupTopologyDescriptionUpdate("missing", "m1", 3, 
100L));
 
         // UNKNOWN_MEMBER_ID propagates.
-        when(groupMetadataManager.validateStreamsGroupMember("foo", 
"stranger", 100L))
+        
when(groupMetadataManager.validateStreamsGroupTopologyDescriptionUpdate("foo", 
"stranger", 3, 100L))
             .thenThrow(new UnknownMemberIdException("not a member"));
         assertThrows(UnknownMemberIdException.class,
-            () -> coordinator.validateStreamsGroupMember("foo", "stranger", 
100L));
+            () -> 
coordinator.validateStreamsGroupTopologyDescriptionUpdate("foo", "stranger", 3, 
100L));
+
+        // Stale topology epoch propagates as INVALID_REQUEST.
+        
when(groupMetadataManager.validateStreamsGroupTopologyDescriptionUpdate("foo", 
"m1", 2, 100L))
+            .thenThrow(new InvalidRequestException("stale epoch"));
+        assertThrows(InvalidRequestException.class,
+            () -> 
coordinator.validateStreamsGroupTopologyDescriptionUpdate("foo", "m1", 2, 
100L));
     }
 
     @Test
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 f6e4033d9aa..7c033026b08 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
@@ -10820,19 +10820,19 @@ public class GroupMetadataManagerTest {
     }
 
     @Test
-    public void testValidateStreamsGroupMemberThrowsWhenGroupAbsent() {
-        // KIP-1331: validateStreamsGroupMember surfaces GROUP_ID_NOT_FOUND 
for the upcoming
-        // StreamsGroupTopologyDescriptionUpdate handler.
+    public void 
testValidateStreamsGroupTopologyDescriptionUpdateThrowsWhenGroupAbsent() {
+        // KIP-1331: validateStreamsGroupTopologyDescriptionUpdate surfaces 
GROUP_ID_NOT_FOUND for
+        // the StreamsGroupTopologyDescriptionUpdate handler.
         GroupMetadataManagerTestContext context = new 
GroupMetadataManagerTestContext.Builder().build();
         assertThrows(GroupIdNotFoundException.class,
-            () -> context.groupMetadataManager.validateStreamsGroupMember(
-                "nonexistent", "m1", context.lastCommittedOffset));
+            () -> 
context.groupMetadataManager.validateStreamsGroupTopologyDescriptionUpdate(
+                "nonexistent", "m1", 0, context.lastCommittedOffset));
     }
 
     @Test
-    public void testValidateStreamsGroupMemberThrowsWhenMemberAbsent() {
-        // KIP-1331: validateStreamsGroupMember surfaces UNKNOWN_MEMBER_ID for 
the upcoming
-        // StreamsGroupTopologyDescriptionUpdate handler.
+    public void 
testValidateStreamsGroupTopologyDescriptionUpdateThrowsWhenMemberAbsent() {
+        // KIP-1331: validateStreamsGroupTopologyDescriptionUpdate surfaces 
UNKNOWN_MEMBER_ID for
+        // the StreamsGroupTopologyDescriptionUpdate handler.
         String groupId = "streams-group";
         GroupMetadataManagerTestContext context = new 
GroupMetadataManagerTestContext.Builder().build();
         // Replaying a metadata record materializes a streams group with no 
members; commit so the
@@ -10842,14 +10842,38 @@ public class GroupMetadataManagerTest {
         context.commit();
 
         assertThrows(UnknownMemberIdException.class,
-            () -> context.groupMetadataManager.validateStreamsGroupMember(
-                groupId, "stranger", context.lastCommittedOffset));
+            () -> 
context.groupMetadataManager.validateStreamsGroupTopologyDescriptionUpdate(
+                groupId, "stranger", -1, context.lastCommittedOffset));
     }
 
     @Test
-    public void testValidateStreamsGroupMemberDoesNotSeeUncommittedFence() {
-        // KIP-1331: validateStreamsGroupMember reads at committedOffset, so 
an uncommitted fence
-        // (member tombstone) must not make a still-committed member appear 
unknown.
+    public void 
testValidateStreamsGroupTopologyDescriptionUpdateRejectsStaleEpoch() {
+        // KIP-1331: validateStreamsGroupTopologyDescriptionUpdate must reject 
a pushedEpoch that
+        // does not match the group's current topology epoch with 
INVALID_REQUEST. Without this
+        // check, a stale push could regress storedDescriptionTopologyEpoch 
and the heartbeat
+        // gate would never converge.
+        String groupId = "streams-group";
+        String memberId = "m1";
+        GroupMetadataManagerTestContext context = new 
GroupMetadataManagerTestContext.Builder().build();
+
+        StreamsGroupMember member = 
streamsGroupMemberBuilderWithDefaults(memberId).build();
+        
context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+            groupId, 1, 0L, -1, Map.of(), -1, -1));
+        
context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(groupId,
 member));
+        
context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentRecord(groupId,
 member));
+        context.commit();
+
+        // No topology has been pushed via heartbeat, so 
currentTopologyEpoch() returns -1.
+        // A push claiming epoch 4 must be rejected.
+        assertThrows(InvalidRequestException.class,
+            () -> 
context.groupMetadataManager.validateStreamsGroupTopologyDescriptionUpdate(
+                groupId, memberId, 4, context.lastCommittedOffset));
+    }
+
+    @Test
+    public void 
testValidateStreamsGroupTopologyDescriptionUpdateDoesNotSeeUncommittedFence() {
+        // KIP-1331: validateStreamsGroupTopologyDescriptionUpdate reads at 
committedOffset, so an
+        // uncommitted fence (member tombstone) must not make a 
still-committed member appear unknown.
         String groupId = "streams-group";
         String memberId = "m1";
         GroupMetadataManagerTestContext context = new 
GroupMetadataManagerTestContext.Builder().build();
@@ -10870,13 +10894,15 @@ public class GroupMetadataManagerTest {
         
context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberTombstoneRecord(groupId,
 memberId));
 
         // Validating at the still-committed offset must succeed; the 
uncommitted tombstone is invisible.
-        StreamsGroupMember resolved = 
context.groupMetadataManager.validateStreamsGroupMember(
-            groupId, memberId, committedWithMember);
+        // currentTopologyEpoch() is -1 since no topology has been pushed via 
heartbeat.
+        StreamsGroupMember resolved = 
context.groupMetadataManager.validateStreamsGroupTopologyDescriptionUpdate(
+            groupId, memberId, -1, committedWithMember);
         assertEquals(memberId, resolved.memberId());
 
         // Latest in-memory state, by contrast, sees the tombstone — verify by 
querying with Long.MAX_VALUE.
         assertThrows(UnknownMemberIdException.class,
-            () -> 
context.groupMetadataManager.validateStreamsGroupMember(groupId, memberId, 
Long.MAX_VALUE));
+            () -> 
context.groupMetadataManager.validateStreamsGroupTopologyDescriptionUpdate(
+                groupId, memberId, -1, Long.MAX_VALUE));
     }
 
     @Test
diff --git 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/api/streams/StreamsGroupTopologyDescriptionTest.java
 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/api/streams/StreamsGroupTopologyDescriptionTest.java
index 1838ff96af6..dc842bccc34 100644
--- 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/api/streams/StreamsGroupTopologyDescriptionTest.java
+++ 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/api/streams/StreamsGroupTopologyDescriptionTest.java
@@ -81,14 +81,11 @@ public class StreamsGroupTopologyDescriptionTest {
     }
 
     @Test
-    public void testCollectionsAreDefensivelyCopied() {
-        Set<String> mutableTopics = new HashSet<>(Set.of("in"));
+    public void testCollectionAccessorsAreUnmodifiable() {
         StreamsGroupTopologyDescription.Source src = new 
StreamsGroupTopologyDescription.Source(
-            "src", mutableTopics, Set.of("proc"));
-        mutableTopics.add("rogue");
-
-        assertEquals(Set.of("in"), src.topics());
+            "src", new HashSet<>(Set.of("in")), Set.of("proc"));
         assertThrows(UnsupportedOperationException.class, () -> 
src.topics().add("nope"));
+        assertThrows(UnsupportedOperationException.class, () -> 
src.successors().add("nope"));
     }
 
     @Test
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
index d6eae64a3e8..3a32d7769e3 100644
--- 
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
@@ -17,6 +17,7 @@
 package org.apache.kafka.coordinator.group.streams;
 
 import org.apache.kafka.common.utils.MockTime;
+import org.apache.kafka.common.utils.internals.ExponentialBackoff;
 
 import org.junit.jupiter.api.Test;
 
@@ -28,12 +29,27 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 public class StreamsGroupTopologyDescriptionBackoffTest {
 
+    /**
+     * Build a back-off backed by a jitter-free {@link ExponentialBackoff} so 
delay
+     * assertions are deterministic.
+     */
+    private static StreamsGroupTopologyDescriptionBackoff 
deterministicBackoff(MockTime time) {
+        return new StreamsGroupTopologyDescriptionBackoff(
+            time,
+            new ExponentialBackoff(
+                StreamsGroupTopologyDescriptionBackoff.INITIAL_DELAY_MS,
+                StreamsGroupTopologyDescriptionBackoff.MULTIPLIER,
+                StreamsGroupTopologyDescriptionBackoff.MAX_DELAY_MS,
+                0.0));
+    }
+
     @Test
     public void testFirstArmUsesInitialDelay() {
         MockTime time = new MockTime();
-        StreamsGroupTopologyDescriptionBackoff backoff = new 
StreamsGroupTopologyDescriptionBackoff(time);
+        StreamsGroupTopologyDescriptionBackoff backoff = 
deterministicBackoff(time);
         backoff.armOrExtend("g", 1);
         assertTrue(backoff.isActive("g", 1));
+        assertEquals(0, backoff.entry("g").attempts());
         time.sleep(StreamsGroupTopologyDescriptionBackoff.INITIAL_DELAY_MS - 
1);
         assertTrue(backoff.isActive("g", 1));
         time.sleep(1);
@@ -41,43 +57,42 @@ public class StreamsGroupTopologyDescriptionBackoffTest {
     }
 
     @Test
-    public void testConsecutiveArmsDoubleTheWindowUpToMax() {
+    public void testConsecutiveArmsAdvanceAttemptsUpToCap() {
         MockTime time = new MockTime();
-        StreamsGroupTopologyDescriptionBackoff backoff = new 
StreamsGroupTopologyDescriptionBackoff(time);
+        StreamsGroupTopologyDescriptionBackoff backoff = 
deterministicBackoff(time);
 
-        long expected = 
StreamsGroupTopologyDescriptionBackoff.INITIAL_DELAY_MS;
         backoff.armOrExtend("g", 1);
-        assertEquals(expected, backoff.entry("g").currentDelayMs());
+        assertEquals(0, backoff.entry("g").attempts());
 
-        // Each consecutive arm at the same epoch doubles, until we hit the 
cap.
+        // Each consecutive arm at the same epoch advances the attempt count. 
The actual
+        // delay is delegated to ExponentialBackoff and capped at MAX_DELAY_MS.
         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(i + 1, backoff.entry("g").attempts(), "iteration " + 
i);
         }
-        assertEquals(StreamsGroupTopologyDescriptionBackoff.MAX_DELAY_MS,
-            backoff.entry("g").currentDelayMs());
+
+        // Window length is capped: arming when the formula would exceed 
MAX_DELAY_MS still
+        // produces a window no longer than MAX_DELAY_MS.
+        long delay = backoff.entry("g").nextAttemptMs() - time.milliseconds();
+        assertEquals(StreamsGroupTopologyDescriptionBackoff.MAX_DELAY_MS, 
delay);
     }
 
     @Test
-    public void testDifferentEpochResetsTheWindow() {
+    public void testDifferentEpochResetsAttempts() {
         MockTime time = new MockTime();
-        StreamsGroupTopologyDescriptionBackoff backoff = new 
StreamsGroupTopologyDescriptionBackoff(time);
+        StreamsGroupTopologyDescriptionBackoff backoff = 
deterministicBackoff(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", 1); // advances attempts
+        assertEquals(1, backoff.entry("g").attempts());
         backoff.armOrExtend("g", 2);
-        assertEquals(StreamsGroupTopologyDescriptionBackoff.INITIAL_DELAY_MS,
-            backoff.entry("g").currentDelayMs());
+        assertEquals(0, backoff.entry("g").attempts());
         assertEquals(2, backoff.entry("g").topologyEpoch());
     }
 
     @Test
     public void testIsActiveIsScopedToEpoch() {
         MockTime time = new MockTime();
-        StreamsGroupTopologyDescriptionBackoff backoff = new 
StreamsGroupTopologyDescriptionBackoff(time);
+        StreamsGroupTopologyDescriptionBackoff backoff = 
deterministicBackoff(time);
         backoff.armOrExtend("g", 1);
         assertTrue(backoff.isActive("g", 1));
         // A query at a different epoch never matches — the broker should 
re-solicit.
@@ -85,30 +100,70 @@ public class StreamsGroupTopologyDescriptionBackoffTest {
     }
 
     @Test
-    public void testClearRemovesEntry() {
+    public void testClearRemovesEntryWhenEpochMatches() {
         MockTime time = new MockTime();
-        StreamsGroupTopologyDescriptionBackoff backoff = new 
StreamsGroupTopologyDescriptionBackoff(time);
+        StreamsGroupTopologyDescriptionBackoff backoff = 
deterministicBackoff(time);
         backoff.armOrExtend("g", 1);
         assertNotNull(backoff.entry("g"));
-        backoff.clear("g");
+        backoff.clear("g", 1);
         assertNull(backoff.entry("g"));
         assertFalse(backoff.isActive("g", 1));
     }
 
+    @Test
+    public void testClearIsNoOpWhenEpochDoesNotMatch() {
+        // A late post-plugin callback at the old epoch must not wipe a window 
a concurrent
+        // heartbeat armed at the advanced epoch.
+        MockTime time = new MockTime();
+        StreamsGroupTopologyDescriptionBackoff backoff = 
deterministicBackoff(time);
+        backoff.armOrExtend("g", 6);
+        long armedAt = backoff.entry("g").nextAttemptMs();
+
+        backoff.clear("g", 5);
+        assertNotNull(backoff.entry("g"));
+        assertEquals(6, backoff.entry("g").topologyEpoch());
+        assertEquals(armedAt, backoff.entry("g").nextAttemptMs());
+    }
+
+    @Test
+    public void testClearGroupRemovesEntryUnconditionally() {
+        // Used by paths that remove the group entirely (DeleteGroups, 
periodic cleanup):
+        // back-off state is irrelevant because the group is gone.
+        MockTime time = new MockTime();
+        StreamsGroupTopologyDescriptionBackoff backoff = 
deterministicBackoff(time);
+        backoff.armOrExtend("g", 6);
+        backoff.clearGroup("g");
+        assertNull(backoff.entry("g"));
+    }
+
+    @Test
+    public void testArmOrExtendIsNoOpWhenStoredEpochIsNewer() {
+        // A late post-plugin callback at the old epoch must not overwrite an 
entry a
+        // concurrent heartbeat armed at the advanced epoch.
+        MockTime time = new MockTime();
+        StreamsGroupTopologyDescriptionBackoff backoff = 
deterministicBackoff(time);
+        backoff.armOrExtend("g", 6);
+        long armedAt = backoff.entry("g").nextAttemptMs();
+
+        backoff.armOrExtend("g", 5);
+        assertEquals(6, backoff.entry("g").topologyEpoch());
+        assertEquals(0, backoff.entry("g").attempts());
+        assertEquals(armedAt, backoff.entry("g").nextAttemptMs());
+    }
+
     @Test
     public void testArmIfNotActiveArmsWhenIdle() {
         MockTime time = new MockTime();
-        StreamsGroupTopologyDescriptionBackoff backoff = new 
StreamsGroupTopologyDescriptionBackoff(time);
+        StreamsGroupTopologyDescriptionBackoff backoff = 
deterministicBackoff(time);
         assertTrue(backoff.armIfNotActive("g", 1));
         assertTrue(backoff.isActive("g", 1));
-        assertEquals(StreamsGroupTopologyDescriptionBackoff.INITIAL_DELAY_MS,
-            backoff.entry("g").currentDelayMs());
+        assertEquals(0, backoff.entry("g").attempts());
     }
 
     @Test
     public void testArmIfNotActiveReturnsFalseWhenAlreadyActive() {
         MockTime time = new MockTime();
-        StreamsGroupTopologyDescriptionBackoff backoff = new 
StreamsGroupTopologyDescriptionBackoff(time);
+        StreamsGroupTopologyDescriptionBackoff backoff = 
deterministicBackoff(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.
@@ -119,34 +174,47 @@ public class StreamsGroupTopologyDescriptionBackoffTest {
     @Test
     public void testArmIfNotActiveReArmsAfterEpochAdvance() {
         MockTime time = new MockTime();
-        StreamsGroupTopologyDescriptionBackoff backoff = new 
StreamsGroupTopologyDescriptionBackoff(time);
+        StreamsGroupTopologyDescriptionBackoff backoff = 
deterministicBackoff(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());
+        assertEquals(0, backoff.entry("g").attempts());
     }
 
     @Test
-    public void testArmIfNotActiveDoublesAfterExpiredWindowAtSameEpoch() {
+    public void 
testArmIfNotActiveAdvancesAttemptsAfterExpiredWindowAtSameEpoch() {
         // 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.
+        // those re-arms instead of resetting attempts every cycle.
         MockTime time = new MockTime();
-        StreamsGroupTopologyDescriptionBackoff backoff = new 
StreamsGroupTopologyDescriptionBackoff(time);
+        StreamsGroupTopologyDescriptionBackoff backoff = 
deterministicBackoff(time);
 
-        long expected = 
StreamsGroupTopologyDescriptionBackoff.INITIAL_DELAY_MS;
         assertTrue(backoff.armIfNotActive("g", 1));
-        assertEquals(expected, backoff.entry("g").currentDelayMs());
+        assertEquals(0, backoff.entry("g").attempts());
 
         for (int i = 0; i < 20; i++) {
-            time.sleep(backoff.entry("g").currentDelayMs());
+            time.sleep(backoff.entry("g").nextAttemptMs() - 
time.milliseconds());
             assertTrue(backoff.armIfNotActive("g", 1));
-            expected = Math.min(expected * 2, 
StreamsGroupTopologyDescriptionBackoff.MAX_DELAY_MS);
-            assertEquals(expected, backoff.entry("g").currentDelayMs(), 
"iteration " + i);
+            assertEquals(i + 1, backoff.entry("g").attempts(), "iteration " + 
i);
         }
-        assertEquals(StreamsGroupTopologyDescriptionBackoff.MAX_DELAY_MS,
-            backoff.entry("g").currentDelayMs());
+    }
+
+    @Test
+    public void testProductionWiringUsesJitter() {
+        // Sanity check that the no-arg constructor wires a jittered 
ExponentialBackoff:
+        // the first delay should fall inside [(1 - JITTER), (1 + JITTER)] * 
INITIAL_DELAY_MS,
+        // not be exactly INITIAL_DELAY_MS (with high probability — but we can 
at least
+        // verify the entry exists and the upper-bound holds across many arms).
+        MockTime time = new MockTime();
+        StreamsGroupTopologyDescriptionBackoff backoff = new 
StreamsGroupTopologyDescriptionBackoff(time);
+        backoff.armOrExtend("g", 1);
+        long delay = backoff.entry("g").nextAttemptMs() - time.milliseconds();
+        long lowerBound = (long) ((1 - 
StreamsGroupTopologyDescriptionBackoff.JITTER)
+            * StreamsGroupTopologyDescriptionBackoff.INITIAL_DELAY_MS);
+        long upperBound = (long) ((1 + 
StreamsGroupTopologyDescriptionBackoff.JITTER)
+            * StreamsGroupTopologyDescriptionBackoff.INITIAL_DELAY_MS);
+        assertTrue(delay >= lowerBound && delay <= upperBound,
+            "expected delay in [" + lowerBound + ", " + upperBound + "], got " 
+ delay);
     }
 }
diff --git 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionConverterTest.java
 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionConverterTest.java
new file mode 100644
index 00000000000..811b7a425da
--- /dev/null
+++ 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionConverterTest.java
@@ -0,0 +1,197 @@
+/*
+ * 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.errors.InvalidRequestException;
+import 
org.apache.kafka.common.message.StreamsGroupTopologyDescriptionUpdateRequestData;
+import 
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescription;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class StreamsGroupTopologyDescriptionConverterTest {
+
+    @Test
+    public void testConvertsAllThreeNodeKindsAndPreservesOrder() {
+        
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode source 
=
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode()
+                .setName("src")
+                .setNodeType((byte) 1)
+                .setSourceTopics(List.of("topic-a", "topic-b"))
+                .setSuccessors(List.of("proc-1", "proc-2"));
+
+        
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
processor =
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode()
+                .setName("proc")
+                .setNodeType((byte) 2)
+                .setStores(List.of("store-x", "store-y"))
+                .setSuccessors(List.of("sink"));
+
+        
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode sink =
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode()
+                .setName("sink")
+                .setNodeType((byte) 3)
+                .setSinkTopic("out-topic")
+                .setSuccessors(List.of());
+
+        
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionSubtopology 
subtopology =
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionSubtopology()
+                .setSubtopologyId("0")
+                .setNodes(List.of(source, processor, sink));
+
+        StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription 
wire =
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription()
+                .setSubtopologies(List.of(subtopology))
+                .setGlobalStores(List.of());
+
+        StreamsGroupTopologyDescription pojo = 
StreamsGroupTopologyDescriptionConverter.fromRequest(wire);
+
+        assertEquals(1, pojo.subtopologies().size());
+        StreamsGroupTopologyDescription.Subtopology st = 
pojo.subtopologies().iterator().next();
+        assertEquals("0", st.id());
+
+        Iterator<StreamsGroupTopologyDescription.Node> nodes = 
st.nodes().iterator();
+        StreamsGroupTopologyDescription.Source src = 
assertInstanceOf(StreamsGroupTopologyDescription.Source.class, nodes.next());
+        assertEquals("src", src.name());
+        assertEquals(List.of("topic-a", "topic-b"), new 
ArrayList<>(src.topics()));
+        assertEquals(List.of("proc-1", "proc-2"), new 
ArrayList<>(src.successors()));
+
+        StreamsGroupTopologyDescription.Processor proc = 
assertInstanceOf(StreamsGroupTopologyDescription.Processor.class, nodes.next());
+        assertEquals("proc", proc.name());
+        assertEquals(List.of("store-x", "store-y"), new 
ArrayList<>(proc.stores()));
+
+        StreamsGroupTopologyDescription.Sink sk = 
assertInstanceOf(StreamsGroupTopologyDescription.Sink.class, nodes.next());
+        assertEquals("sink", sk.name());
+        assertTrue(sk.topic().isPresent());
+        assertEquals("out-topic", sk.topic().get());
+    }
+
+    @Test
+    public void testSinkWithoutTopicYieldsEmptyOptional() {
+        
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode sink =
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode()
+                .setName("sink")
+                .setNodeType((byte) 3)
+                .setSinkTopic(null)
+                .setSuccessors(List.of());
+
+        StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription 
wire =
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription()
+                .setSubtopologies(List.of(
+                    new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionSubtopology()
+                        .setSubtopologyId("0")
+                        .setNodes(List.of(sink))))
+                .setGlobalStores(List.of());
+
+        StreamsGroupTopologyDescription pojo = 
StreamsGroupTopologyDescriptionConverter.fromRequest(wire);
+        StreamsGroupTopologyDescription.Sink sk = 
(StreamsGroupTopologyDescription.Sink)
+            pojo.subtopologies().iterator().next().nodes().iterator().next();
+        assertTrue(sk.topic().isEmpty());
+    }
+
+    @Test
+    public void testGlobalStoreIsConverted() {
+        
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode source 
=
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode()
+                .setName("g-src")
+                .setNodeType((byte) 1)
+                .setSourceTopics(List.of("global-topic"))
+                .setSuccessors(List.of("g-proc"));
+
+        
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
processor =
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode()
+                .setName("g-proc")
+                .setNodeType((byte) 2)
+                .setStores(List.of("global-store"))
+                .setSuccessors(List.of());
+
+        
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionGlobalStore 
gs =
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionGlobalStore()
+                .setSource(source)
+                .setProcessor(processor);
+
+        StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription 
wire =
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription()
+                .setSubtopologies(List.of())
+                .setGlobalStores(List.of(gs));
+
+        StreamsGroupTopologyDescription pojo = 
StreamsGroupTopologyDescriptionConverter.fromRequest(wire);
+        assertEquals(1, pojo.globalStores().size());
+        StreamsGroupTopologyDescription.GlobalStore converted = 
pojo.globalStores().iterator().next();
+        assertEquals("g-src", converted.source().name());
+        assertEquals("g-proc", converted.processor().name());
+    }
+
+    @Test
+    public void testUnknownNodeTypeIsRejected() {
+        
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
unknown =
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode()
+                .setName("?")
+                .setNodeType((byte) 99)
+                .setSuccessors(List.of());
+
+        StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription 
wire =
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription()
+                .setSubtopologies(List.of(
+                    new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionSubtopology()
+                        .setSubtopologyId("0")
+                        .setNodes(List.of(unknown))))
+                .setGlobalStores(List.of());
+
+        assertThrows(InvalidRequestException.class,
+            () -> StreamsGroupTopologyDescriptionConverter.fromRequest(wire));
+    }
+
+    @Test
+    public void testGlobalStoreWithMismatchedNodesIsRejected() {
+        
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
sourceA =
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode()
+                .setName("a").setNodeType((byte) 1).setSuccessors(List.of());
+        
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
sourceB =
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode()
+                .setName("b").setNodeType((byte) 1).setSuccessors(List.of());
+        
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionGlobalStore 
gs =
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionGlobalStore()
+                .setSource(sourceA).setProcessor(sourceB);
+
+        StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription 
wire =
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription()
+                .setSubtopologies(List.of()).setGlobalStores(List.of(gs));
+
+        assertThrows(InvalidRequestException.class,
+            () -> StreamsGroupTopologyDescriptionConverter.fromRequest(wire));
+    }
+
+    @Test
+    public void testEmptyTopologyIsAccepted() {
+        StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription 
wire =
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription()
+                .setSubtopologies(List.of())
+                .setGlobalStores(List.of());
+        StreamsGroupTopologyDescription pojo = 
StreamsGroupTopologyDescriptionConverter.fromRequest(wire);
+        assertTrue(pojo.subtopologies().isEmpty());
+        assertTrue(pojo.globalStores().isEmpty());
+    }
+}

Reply via email to