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 12c81a2e76e KAFKA-20624: Wire topology description plugin into 
GroupCoordinatorService — read path (describe) and broker wiring (#22627)
12c81a2e76e is described below

commit 12c81a2e76e4f7d78cfc7b69d9ba3ff6cb2276a7
Author: TengYao Chi <[email protected]>
AuthorDate: Mon Jun 22 13:27:12 2026 +0100

    KAFKA-20624: Wire topology description plugin into GroupCoordinatorService 
— read path (describe) and broker wiring (#22627)
    
    JIRA: KAFKA-20624  This PR is a part of KIP-1331
    
    Update RPC KafkaApis wiring. handleStreamsGroupTopologyDescriptionUpdate
    has lived as a placeholder stub since KAFKA-20620 (always returning
    UNSUPPORTED_VERSION), leaving the RPC unreachable end-to-end even though
    the coordinator-side method merged with KAFKA-20623 split 2 (#22552).
    Replace the stub with the real handler: streams-protocol-enabled gate,
    READ on GROUP authorization, then dispatch to
    groupCoordinator.streamsGroupTopologyDescriptionUpdate and surface
    either  the coordinator's response or a getErrorResponse-wrapped
    exception via  sendMaybeThrottle. Same shape as
    handleStreamsGroupHeartbeat. The  READ-on-GROUP choice follows
    KIP-1331's explicit "like an offset commit,  a topology push is not a
    modification of the GROUP" framing — apps  deployed with READ-only group
    ACLs can push topologies without an ACL  upgrade.
    
    Describe path. streamsGroupDescribe is extended with an
    IncludeTopologyDescription flag. When set, a new
    StreamsGroupTopologyDescriptionManager.attachTopologyDescriptions
    building  block calls plugin.getTopology(groupId, topologyEpoch) for
    each  DescribedGroup whose persisted StoredDescriptionTopologyEpoch
    matches the  group's current topology epoch, and populates the
    response's  TopologyDescription and TopologyDescriptionStatus fields.
    Per-group status  follows KIP-1331: NOT_REQUESTED (default; client did
    not ask), NOT_STORED  (no description recorded or epoch mismatched or
    plugin returned null),  ERROR (plugin failed), AVAILABLE (topology
    attached). Chain assembly stays  on GroupCoordinatorService; the manager
    exposes only the per-group plugin  invocation as a building block,
    mirroring invokeSetTopology and  invokeDeleteTopologies. A new
    StreamsGroupTopologyDescriptionConverter.toDescribeResponse sibling of
    fromRequest translates the broker-side POJO into the wire schema and is
    documented to assume the POJO's canonical-constructor non-null
    collection  invariants, with the manager's catch(Exception) folding any
    contract  bypass into per-group ERROR so a single malformed response
    cannot poison  the batch.
    
    Broker wiring. BrokerServer's plugin instantiation (via
    config.getConfiguredInstance) and RequestConvertToJson entries for
    StreamsGroupTopologyDescriptionUpdate Request/Response and for the
    TopologyDescription fields on the describe response were already in
    place  from earlier sub-tasks; no change needed.
    
    Reviewers: Lucas Brutschy <[email protected]>
---
 core/src/main/scala/kafka/server/KafkaApis.scala   |  32 ++-
 .../kafka/api/AuthorizerIntegrationTest.scala      |  19 +-
 .../scala/unit/kafka/server/KafkaApisTest.scala    | 155 +++++++++++++-
 .../kafka/coordinator/group/GroupCoordinator.java  |  13 +-
 .../coordinator/group/GroupCoordinatorService.java |  11 +-
 .../StreamsGroupTopologyDescriptionConverter.java  |  72 +++++++
 .../StreamsGroupTopologyDescriptionManager.java    | 133 ++++++++++++
 .../group/GroupCoordinatorServiceTest.java         |   8 +-
 ...pCoordinatorServiceTopologyDescriptionTest.java | 226 +++++++++++++++++++++
 9 files changed, 645 insertions(+), 24 deletions(-)

diff --git a/core/src/main/scala/kafka/server/KafkaApis.scala 
b/core/src/main/scala/kafka/server/KafkaApis.scala
index a3058af66c4..8e4a695baa4 100644
--- a/core/src/main/scala/kafka/server/KafkaApis.scala
+++ b/core/src/main/scala/kafka/server/KafkaApis.scala
@@ -2922,13 +2922,32 @@ class KafkaApis(val requestChannel: RequestChannel,
     }
   }
 
-  // Stub handler for KIP-1331. The full handler lands in a later sub-task; 
until then this
-  // responds with UNSUPPORTED_VERSION so callers fail loud rather than hit 
the IllegalStateException
-  // default branch in handle().
   def handleStreamsGroupTopologyDescriptionUpdate(request: Request): 
CompletableFuture[Unit] = {
     val updateRequest = 
request.body(classOf[StreamsGroupTopologyDescriptionUpdateRequest])
-    requestHelper.sendMaybeThrottle(request, 
updateRequest.getErrorResponse(Errors.UNSUPPORTED_VERSION.exception))
-    CompletableFuture.completedFuture[Unit](())
+
+    if (!isStreamsGroupProtocolEnabled) {
+      // The streams group protocol is disabled on this broker, so the RPC is 
unreachable
+      // even if a topology description plugin is configured.
+      requestHelper.sendMaybeThrottle(request, 
updateRequest.getErrorResponse(Errors.UNSUPPORTED_VERSION.exception))
+      CompletableFuture.completedFuture[Unit](())
+    } else if (!authHelper.authorize(request.context, READ, GROUP, 
updateRequest.data.groupId)) {
+      // Per KIP-1331: like offset commits, a topology push is not treated as 
a modification
+      // of the GROUP, so READ on the GROUP resource is sufficient. This lets 
apps deployed
+      // with READ-only group ACLs push topology descriptions without an ACL 
upgrade.
+      requestHelper.sendMaybeThrottle(request, 
updateRequest.getErrorResponse(Errors.GROUP_AUTHORIZATION_FAILED.exception))
+      CompletableFuture.completedFuture[Unit](())
+    } else {
+      groupCoordinator.streamsGroupTopologyDescriptionUpdate(
+        request.context,
+        updateRequest.data
+      ).handle[Unit] { (response, exception) =>
+        if (exception != null) {
+          requestHelper.sendMaybeThrottle(request, 
updateRequest.getErrorResponse(exception))
+        } else {
+          requestHelper.sendMaybeThrottle(request, new 
StreamsGroupTopologyDescriptionUpdateResponse(response))
+        }
+      }
+    }
   }
 
   def handleStreamsGroupDescribe(request: Request): CompletableFuture[Unit] = {
@@ -2956,7 +2975,8 @@ class KafkaApis(val requestChannel: RequestChannel,
 
       groupCoordinator.streamsGroupDescribe(
         request.context,
-        authorizedGroups.asJava
+        authorizedGroups.asJava,
+        streamsGroupDescribeRequest.data.includeTopologyDescription
       ).handle[Unit] { (results, exception) =>
         if (exception != null) {
           requestHelper.sendMaybeThrottle(request, 
streamsGroupDescribeRequest.getErrorResponse(exception))
diff --git 
a/core/src/test/scala/integration/kafka/api/AuthorizerIntegrationTest.scala 
b/core/src/test/scala/integration/kafka/api/AuthorizerIntegrationTest.scala
index f21e75c0eb8..92e89d9df62 100644
--- a/core/src/test/scala/integration/kafka/api/AuthorizerIntegrationTest.scala
+++ b/core/src/test/scala/integration/kafka/api/AuthorizerIntegrationTest.scala
@@ -38,7 +38,7 @@ import 
org.apache.kafka.common.message.JoinGroupRequestData.JoinGroupRequestProt
 import org.apache.kafka.common.message.LeaveGroupRequestData.MemberIdentity
 import 
org.apache.kafka.common.message.ListOffsetsRequestData.{ListOffsetsPartition, 
ListOffsetsTopic}
 import 
org.apache.kafka.common.message.OffsetForLeaderEpochRequestData.{OffsetForLeaderPartition,
 OffsetForLeaderTopic, OffsetForLeaderTopicCollection}
-import org.apache.kafka.common.message.{AddOffsetsToTxnRequestData, 
AlterPartitionReassignmentsRequestData, AlterReplicaLogDirsRequestData, 
AlterShareGroupOffsetsRequestData, ConsumerGroupDescribeRequestData, 
ConsumerGroupHeartbeatRequestData, ConsumerGroupHeartbeatResponseData, 
CreateAclsRequestData, CreatePartitionsRequestData, CreateTopicsRequestData, 
DeleteAclsRequestData, DeleteGroupsRequestData, DeleteRecordsRequestData, 
DeleteShareGroupOffsetsRequestData, DeleteShareGroupStateRequ [...]
+import org.apache.kafka.common.message.{AddOffsetsToTxnRequestData, 
AlterPartitionReassignmentsRequestData, AlterReplicaLogDirsRequestData, 
AlterShareGroupOffsetsRequestData, ConsumerGroupDescribeRequestData, 
ConsumerGroupHeartbeatRequestData, ConsumerGroupHeartbeatResponseData, 
CreateAclsRequestData, CreatePartitionsRequestData, CreateTopicsRequestData, 
DeleteAclsRequestData, DeleteGroupsRequestData, DeleteRecordsRequestData, 
DeleteShareGroupOffsetsRequestData, DeleteShareGroupStateRequ [...]
 import org.apache.kafka.common.network.ListenerName
 import org.apache.kafka.common.protocol.{ApiKeys, Errors}
 import org.apache.kafka.common.record.internal.{MemoryRecords, RecordBatch, 
SimpleRecord}
@@ -232,7 +232,9 @@ class AuthorizerIntegrationTest extends 
AbstractAuthorizerIntegrationTest {
       resp.data.errorCode)),
     ApiKeys.STREAMS_GROUP_HEARTBEAT -> ((resp: StreamsGroupHeartbeatResponse) 
=> Errors.forCode(resp.data.errorCode)),
     ApiKeys.STREAMS_GROUP_DESCRIBE -> ((resp: StreamsGroupDescribeResponse) =>
-      Errors.forCode(resp.data.groups.asScala.find(g => streamsGroup == 
g.groupId).head.errorCode))
+      Errors.forCode(resp.data.groups.asScala.find(g => streamsGroup == 
g.groupId).head.errorCode)),
+    ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE ->
+      ((resp: StreamsGroupTopologyDescriptionUpdateResponse) => 
Errors.forCode(resp.data.errorCode))
   )
 
   def findErrorForTopicId(id: Uuid, response: AbstractResponse): Errors = {
@@ -303,6 +305,7 @@ class AuthorizerIntegrationTest extends 
AbstractAuthorizerIntegrationTest {
     ApiKeys.ALTER_SHARE_GROUP_OFFSETS -> (shareGroupReadAcl ++ topicReadAcl),
     ApiKeys.STREAMS_GROUP_HEARTBEAT -> (streamsGroupReadAcl ++ 
topicDescribeAcl),
     ApiKeys.STREAMS_GROUP_DESCRIBE -> (streamsGroupDescribeAcl ++ 
topicDescribeAcl),
+    ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE -> streamsGroupReadAcl,
   )
 
   private def createMetadataRequest(allowAutoTopicCreation: Boolean) = {
@@ -883,6 +886,14 @@ class AuthorizerIntegrationTest extends 
AbstractAuthorizerIntegrationTest {
     new StreamsGroupDescribeRequestData()
       .setGroupIds(List(streamsGroup).asJava)
       
.setIncludeAuthorizedOperations(false)).build(ApiKeys.STREAMS_GROUP_DESCRIBE.latestVersion)
+
+  private def streamsGroupTopologyDescriptionUpdateRequest = new 
StreamsGroupTopologyDescriptionUpdateRequest.Builder(
+    new StreamsGroupTopologyDescriptionUpdateRequestData()
+      .setGroupId(streamsGroup)
+      .setMemberId(Uuid.randomUuid.toString)
+      .setTopologyEpoch(0)
+      .setTopologyDescription(new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription())
+  ).build(ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE.latestVersion)
   
   private def sendRequests(requestKeyToRequest: mutable.Map[ApiKeys, 
AbstractRequest], topicExists: Boolean = true,
                            topicNames: Map[Uuid, String] = getTopicNames()) = {
@@ -969,6 +980,7 @@ class AuthorizerIntegrationTest extends 
AbstractAuthorizerIntegrationTest {
       ApiKeys.ALTER_SHARE_GROUP_OFFSETS -> alterShareGroupOffsetsRequest,
       ApiKeys.STREAMS_GROUP_HEARTBEAT -> streamsGroupHeartbeatRequest,
       ApiKeys.STREAMS_GROUP_DESCRIBE -> streamsGroupDescribeRequest,
+      ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE -> 
streamsGroupTopologyDescriptionUpdateRequest,
 
       // Delete the topic last
       ApiKeys.DELETE_TOPICS -> deleteTopicsRequest
@@ -1003,7 +1015,8 @@ class AuthorizerIntegrationTest extends 
AbstractAuthorizerIntegrationTest {
       ApiKeys.SHARE_ACKNOWLEDGE -> shareAcknowledgeRequest,
       ApiKeys.DESCRIBE_SHARE_GROUP_OFFSETS -> describeShareGroupOffsetsRequest,
       ApiKeys.STREAMS_GROUP_HEARTBEAT -> streamsGroupHeartbeatRequest,
-      ApiKeys.STREAMS_GROUP_DESCRIBE -> streamsGroupDescribeRequest
+      ApiKeys.STREAMS_GROUP_DESCRIBE -> streamsGroupDescribeRequest,
+      ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE -> 
streamsGroupTopologyDescriptionUpdateRequest
     )
 
     sendRequests(requestKeyToRequest, topicExists = false, topicNames)
diff --git a/core/src/test/scala/unit/kafka/server/KafkaApisTest.scala 
b/core/src/test/scala/unit/kafka/server/KafkaApisTest.scala
index e9a196118f2..de7ed5c902d 100644
--- a/core/src/test/scala/unit/kafka/server/KafkaApisTest.scala
+++ b/core/src/test/scala/unit/kafka/server/KafkaApisTest.scala
@@ -11239,6 +11239,113 @@ class KafkaApisTest extends Logging {
     assertEquals(Errors.GROUP_AUTHORIZATION_FAILED.code, 
response.data.errorCode)
   }
 
+  @Test
+  def 
testStreamsGroupTopologyDescriptionUpdateReturnsUnsupportedVersionWhenStreamsProtocolDisabled():
 Unit = {
+    // Streams group protocol disabled on this broker -> the gate at the top of
+    // handleStreamsGroupTopologyDescriptionUpdate short-circuits with 
UNSUPPORTED_VERSION,
+    // matching the sibling handleStreamsGroupHeartbeat behavior and the 
placeholder semantics
+    // KAFKA-20620 introduced before this real handler landed.
+    val updateRequest = new 
StreamsGroupTopologyDescriptionUpdateRequestData().setGroupId("group")
+
+    val requestChannelRequest = buildRequest(new 
StreamsGroupTopologyDescriptionUpdateRequest.Builder(updateRequest).build())
+    metadataCache = {
+      val cache = new KRaftMetadataCache(brokerId, () => 
KRaftVersion.KRAFT_VERSION_1)
+      val delta = new MetadataDelta.Builder()
+        .setImage(MetadataImage.EMPTY)
+        .build()
+      delta.replay(new FeatureLevelRecord()
+        .setName(MetadataVersion.FEATURE_NAME)
+        .setFeatureLevel(MetadataVersion.MINIMUM_VERSION.featureLevel())
+      )
+      cache.setImage(delta.apply(MetadataProvenance.EMPTY))
+      cache
+    }
+    kafkaApis = createKafkaApis()
+    kafkaApis.handle(requestChannelRequest, RequestLocal.noCaching)
+
+    val response = 
verifyNoThrottling[StreamsGroupTopologyDescriptionUpdateResponse](requestChannelRequest)
+    assertEquals(Errors.UNSUPPORTED_VERSION.code, response.data.errorCode)
+  }
+
+  @Test
+  def testStreamsGroupTopologyDescriptionUpdateRequest(): Unit = {
+    // Happy path: streams protocol enabled, no authorizer configured (default 
ALLOW),
+    // coordinator returns a clean response that must be forwarded to the 
client verbatim.
+    val features = mock(classOf[FinalizedFeatures])
+    
when(features.finalizedFeatures()).thenReturn(util.Map.of(StreamsVersion.FEATURE_NAME,
 1.toShort))
+
+    metadataCache = mock(classOf[KRaftMetadataCache])
+    when(metadataCache.features()).thenReturn(features)
+
+    val updateRequest = new 
StreamsGroupTopologyDescriptionUpdateRequestData().setGroupId("group")
+    val requestChannelRequest = buildRequest(new 
StreamsGroupTopologyDescriptionUpdateRequest.Builder(updateRequest).build())
+
+    val future = new 
CompletableFuture[StreamsGroupTopologyDescriptionUpdateResponseData]()
+    when(groupCoordinator.streamsGroupTopologyDescriptionUpdate(
+      requestChannelRequest.context,
+      updateRequest
+    )).thenReturn(future)
+    kafkaApis = createKafkaApis()
+    kafkaApis.handle(requestChannelRequest, RequestLocal.noCaching)
+
+    val expectedResponse = new 
StreamsGroupTopologyDescriptionUpdateResponseData()
+    future.complete(expectedResponse)
+    val response = 
verifyNoThrottling[StreamsGroupTopologyDescriptionUpdateResponse](requestChannelRequest)
+    assertEquals(expectedResponse, response.data)
+  }
+
+  @Test
+  def testStreamsGroupTopologyDescriptionUpdateRequestFutureFailed(): Unit = {
+    // Coordinator-side exception (e.g. NOT_COORDINATOR, fenced member, plugin 
transient
+    // failure) must be folded into an error response via getErrorResponse and 
surfaced to
+    // the client.
+    val features = mock(classOf[FinalizedFeatures])
+    
when(features.finalizedFeatures()).thenReturn(util.Map.of(StreamsVersion.FEATURE_NAME,
 1.toShort))
+
+    metadataCache = mock(classOf[KRaftMetadataCache])
+    when(metadataCache.features()).thenReturn(features)
+
+    val updateRequest = new 
StreamsGroupTopologyDescriptionUpdateRequestData().setGroupId("group")
+    val requestChannelRequest = buildRequest(new 
StreamsGroupTopologyDescriptionUpdateRequest.Builder(updateRequest).build())
+
+    val future = new 
CompletableFuture[StreamsGroupTopologyDescriptionUpdateResponseData]()
+    when(groupCoordinator.streamsGroupTopologyDescriptionUpdate(
+      requestChannelRequest.context,
+      updateRequest
+    )).thenReturn(future)
+    kafkaApis = createKafkaApis()
+    kafkaApis.handle(requestChannelRequest, RequestLocal.noCaching)
+
+    future.completeExceptionally(Errors.NOT_COORDINATOR.exception)
+    val response = 
verifyNoThrottling[StreamsGroupTopologyDescriptionUpdateResponse](requestChannelRequest)
+    assertEquals(Errors.NOT_COORDINATOR.code, response.data.errorCode)
+  }
+
+  @Test
+  def 
testStreamsGroupTopologyDescriptionUpdateRequestGroupAuthorizationFailed(): 
Unit = {
+    // Per KIP-1331 the RPC requires READ on the GROUP resource (like offset 
commits). When
+    // the authorizer denies that ACL, the request must NOT reach the 
coordinator — fail
+    // fast at the KafkaApis layer with GROUP_AUTHORIZATION_FAILED.
+    val features = mock(classOf[FinalizedFeatures])
+    
when(features.finalizedFeatures()).thenReturn(util.Map.of(StreamsVersion.FEATURE_NAME,
 1.toShort))
+
+    metadataCache = mock(classOf[KRaftMetadataCache])
+    when(metadataCache.features()).thenReturn(features)
+
+    val updateRequest = new 
StreamsGroupTopologyDescriptionUpdateRequestData().setGroupId("group")
+    val requestChannelRequest = buildRequest(new 
StreamsGroupTopologyDescriptionUpdateRequest.Builder(updateRequest).build())
+
+    val authorizer: Authorizer = mock(classOf[Authorizer])
+    when(authorizer.authorize(any[RequestContext], any[util.List[Action]]))
+      .thenReturn(util.List.of(AuthorizationResult.DENIED))
+    kafkaApis = createKafkaApis(authorizer = Some(authorizer))
+    kafkaApis.handle(requestChannelRequest, RequestLocal.noCaching)
+
+    val response = 
verifyNoThrottling[StreamsGroupTopologyDescriptionUpdateResponse](requestChannelRequest)
+    assertEquals(Errors.GROUP_AUTHORIZATION_FAILED.code, 
response.data.errorCode)
+    verify(groupCoordinator, 
never()).streamsGroupTopologyDescriptionUpdate(any(), any())
+  }
+
   @Test
   def testStreamsGroupHeartbeatRequestTopicAuthorizationFailed(): Unit = {
     val features = mock(classOf[FinalizedFeatures])
@@ -11727,7 +11834,8 @@ class KafkaApisTest extends Logging {
     val future = new 
CompletableFuture[util.List[StreamsGroupDescribeResponseData.DescribedGroup]]()
     when(groupCoordinator.streamsGroupDescribe(
       any[RequestContext],
-      any[util.List[String]]
+      any[util.List[String]],
+      any[Boolean]
     )).thenReturn(future)
     kafkaApis = createKafkaApis()
     kafkaApis.handle(requestChannelRequest, RequestLocal.noCaching)
@@ -11839,7 +11947,8 @@ class KafkaApisTest extends Logging {
     val future = new 
CompletableFuture[util.List[StreamsGroupDescribeResponseData.DescribedGroup]]()
     when(groupCoordinator.streamsGroupDescribe(
       any[RequestContext],
-      any[util.List[String]]
+      any[util.List[String]],
+      any[Boolean]
     )).thenReturn(future)
     future.complete(util.List.of)
     kafkaApis = createKafkaApis(
@@ -11866,7 +11975,8 @@ class KafkaApisTest extends Logging {
     val future = new 
CompletableFuture[util.List[StreamsGroupDescribeResponseData.DescribedGroup]]()
     when(groupCoordinator.streamsGroupDescribe(
       any[RequestContext],
-      any[util.List[String]]
+      any[util.List[String]],
+      any[Boolean]
     )).thenReturn(future)
     kafkaApis = createKafkaApis()
     kafkaApis.handle(requestChannelRequest, RequestLocal.noCaching)
@@ -11876,6 +11986,42 @@ class KafkaApisTest extends Logging {
     assertEquals(Errors.FENCED_MEMBER_EPOCH.code, 
response.data.groups.get(0).errorCode)
   }
 
+  @ParameterizedTest
+  @ValueSource(booleans = Array(true, false))
+  def 
testStreamsGroupDescribeForwardsIncludeTopologyDescriptionFlag(includeTopologyDescription:
 Boolean): Unit = {
+    // The KafkaApis layer must thread IncludeTopologyDescription from the 
request schema
+    // through to the coordinator unchanged — the coordinator gates the plugin 
call on it,
+    // so a silent drop here would make the flag unobservable to operators.
+    val features = mock(classOf[FinalizedFeatures])
+    
when(features.finalizedFeatures()).thenReturn(util.Map.of(StreamsVersion.FEATURE_NAME,
 1.toShort))
+
+    metadataCache = mock(classOf[KRaftMetadataCache])
+    when(metadataCache.features()).thenReturn(features)
+
+    val streamsGroupDescribeRequestData = new StreamsGroupDescribeRequestData()
+      .setIncludeTopologyDescription(includeTopologyDescription)
+    streamsGroupDescribeRequestData.groupIds.add("group-id")
+    // IncludeTopologyDescription is v1+ and v1 is marked 
latestVersionUnstable; build at v1
+    // explicitly so the field reaches the wire.
+    val requestChannelRequest = buildRequest(new 
StreamsGroupDescribeRequest.Builder(streamsGroupDescribeRequestData).build(1.toShort))
+
+    val future = new 
CompletableFuture[util.List[StreamsGroupDescribeResponseData.DescribedGroup]]()
+    when(groupCoordinator.streamsGroupDescribe(
+      any[RequestContext],
+      any[util.List[String]],
+      ArgumentMatchers.eq(includeTopologyDescription)
+    )).thenReturn(future)
+    kafkaApis = createKafkaApis()
+    kafkaApis.handle(requestChannelRequest, RequestLocal.noCaching)
+
+    future.complete(util.List.of(new 
StreamsGroupDescribeResponseData.DescribedGroup().setGroupId("group-id")))
+    verify(groupCoordinator).streamsGroupDescribe(
+      any[RequestContext],
+      any[util.List[String]],
+      ArgumentMatchers.eq(includeTopologyDescription)
+    )
+  }
+
   @ParameterizedTest
   @ValueSource(booleans = Array(true, false))
   def 
testStreamsGroupDescribeFilterUnauthorizedTopics(includeAuthorizedOperations: 
Boolean): Unit = {
@@ -11916,7 +12062,8 @@ class KafkaApisTest extends Logging {
     val future = new 
CompletableFuture[util.List[StreamsGroupDescribeResponseData.DescribedGroup]]()
     when(groupCoordinator.streamsGroupDescribe(
       any[RequestContext],
-      any[util.List[String]]
+      any[util.List[String]],
+      any[Boolean]
     )).thenReturn(future)
     kafkaApis = createKafkaApis(
       authorizer = Some(authorizer)
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 717664a5939..f5995e1328b 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
@@ -240,14 +240,21 @@ public interface GroupCoordinator {
     /**
      * Describe streams groups.
      *
-     * @param context           The coordinator request context.
-     * @param groupIds          The group ids.
+     * @param context                       The coordinator request context.
+     * @param groupIds                      The group ids.
+     * @param includeTopologyDescription    Whether the client requested the 
full topology
+     *                                      description from the topology 
description plugin.
+     *                                      When {@code false}, the
+     *                                      {@code TopologyDescription} / 
{@code TopologyDescriptionStatus}
+     *                                      fields are left at their defaults 
and the plugin
+     *                                      is not consulted.
      *
      * @return A future yielding the results or an exception.
      */
     CompletableFuture<List<StreamsGroupDescribeResponseData.DescribedGroup>> 
streamsGroupDescribe(
         AuthorizableRequestContext context,
-        List<String> groupIds
+        List<String> groupIds,
+        boolean includeTopologyDescription
     );
 
     /**
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 80a3f29ff61..cad84307b08 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
@@ -103,7 +103,6 @@ import 
org.apache.kafka.coordinator.group.GroupCoordinatorShard.DeletedTopic;
 import 
org.apache.kafka.coordinator.group.api.assignor.ConsumerGroupPartitionAssignor;
 import 
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescriptionPlugin;
 import org.apache.kafka.coordinator.group.metrics.GroupCoordinatorMetrics;
-import org.apache.kafka.coordinator.group.streams.StreamsGroupDescribeResult;
 import org.apache.kafka.coordinator.group.streams.StreamsGroupHeartbeatResult;
 import 
org.apache.kafka.coordinator.group.streams.StreamsGroupTopologyDescriptionConverter;
 import 
org.apache.kafka.coordinator.group.streams.StreamsGroupTopologyDescriptionManager;
@@ -419,6 +418,7 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
             .collect(Collectors.toSet());
         this.partitionMetadataClient = partitionMetadataClient;
         this.streamsGroupTopologyDescriptionManager = new 
StreamsGroupTopologyDescriptionManager(
+            logContext,
             streamsGroupTopologyDescriptionPlugin,
             time
         );
@@ -1330,12 +1330,13 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
     }
 
     /**
-     * See {@link 
GroupCoordinator#streamsGroupDescribe(AuthorizableRequestContext, List)}.
+     * See {@link 
GroupCoordinator#streamsGroupDescribe(AuthorizableRequestContext, List, 
boolean)}.
      */
     @Override
     public 
CompletableFuture<List<StreamsGroupDescribeResponseData.DescribedGroup>> 
streamsGroupDescribe(
         AuthorizableRequestContext context,
-        List<String> groupIds
+        List<String> groupIds,
+        boolean includeTopologyDescription
     ) {
         if (!isActive.get()) {
             return 
CompletableFuture.completedFuture(StreamsGroupDescribeRequest.getErrorDescribedGroupList(
@@ -1367,7 +1368,9 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
                     "streams-group-describe",
                     topicPartition,
                     (coordinator, lastCommittedOffset) -> 
coordinator.streamsGroupDescribe(groupList, lastCommittedOffset)
-                ).thenApply(StreamsGroupDescribeResult::describedGroups)
+                ).thenCompose(result -> includeTopologyDescription
+                    ? 
streamsGroupTopologyDescriptionManager.attachTopologyDescriptions(result)
+                    : 
CompletableFuture.completedFuture(result.describedGroups()))
                 .exceptionally(exception -> handleOperationException(
                     "streams-group-describe",
                     groupList,
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
index 94de07f38b1..b5867e07c93 100644
--- 
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
@@ -17,6 +17,7 @@
 package org.apache.kafka.coordinator.group.streams;
 
 import org.apache.kafka.common.errors.InvalidRequestException;
+import org.apache.kafka.common.message.StreamsGroupDescribeResponseData;
 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;
@@ -26,6 +27,7 @@ import 
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescri
 import 
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescription.Source;
 import 
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescription.Subtopology;
 
+import java.util.ArrayList;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Optional;
@@ -81,6 +83,76 @@ public final class StreamsGroupTopologyDescriptionConverter {
         return new GlobalStore((Source) source, (Processor) processor);
     }
 
+    /**
+     * Sibling of {@link #fromRequest}: translates the broker-side {@link 
StreamsGroupTopologyDescription}
+     * POJO returned by {@code plugin.getTopology} into the describe-response 
wire schema. The two
+     * schemas share field names but live in different generated message 
classes; they are kept in
+     * sync by this converter.
+     *
+     * <p>This method assumes every collection on the POJO (subtopologies, 
nodes, successors,
+     * topics, stores, etc.) is non-null. The {@link 
StreamsGroupTopologyDescription} record and
+     * its nested types enforce that invariant in their canonical constructors 
via
+     * {@code Objects.requireNonNull} + {@code List.copyOf} / {@code 
Collections.unmodifiableSet},
+     * so a well-formed plugin response can never reach this method with a 
null collection. A
+     * pathological plugin that bypasses the constructor invariant would 
surface as an
+     * {@code NullPointerException} here; that is caught at the only call site
+     * ({@code 
StreamsGroupTopologyDescriptionManager#attachTopologyDescriptions}) and folded
+     * into a per-group {@code TOPOLOGY_DESCRIPTION_STATUS_ERROR}, so the rest 
of the describe
+     * batch is unaffected.
+     */
+    public static StreamsGroupDescribeResponseData.TopologyDescription 
toDescribeResponse(
+        StreamsGroupTopologyDescription topology
+    ) {
+        List<StreamsGroupDescribeResponseData.TopologyDescriptionSubtopology> 
subtopologies =
+            new ArrayList<>(topology.subtopologies().size());
+        for (Subtopology subtopology : topology.subtopologies()) {
+            List<StreamsGroupDescribeResponseData.TopologyDescriptionNode> 
nodes =
+                new ArrayList<>(subtopology.nodes().size());
+            for (Node node : subtopology.nodes()) {
+                nodes.add(toResponseNode(node));
+            }
+            subtopologies.add(
+                new 
StreamsGroupDescribeResponseData.TopologyDescriptionSubtopology()
+                    .setSubtopologyId(subtopology.id())
+                    .setNodes(nodes)
+            );
+        }
+        List<StreamsGroupDescribeResponseData.TopologyDescriptionGlobalStore> 
globalStores =
+            new ArrayList<>(topology.globalStores().size());
+        for (GlobalStore globalStore : topology.globalStores()) {
+            globalStores.add(
+                new 
StreamsGroupDescribeResponseData.TopologyDescriptionGlobalStore()
+                    .setSource(toResponseNode(globalStore.source()))
+                    .setProcessor(toResponseNode(globalStore.processor()))
+            );
+        }
+        return new StreamsGroupDescribeResponseData.TopologyDescription()
+            .setSubtopologies(subtopologies)
+            .setGlobalStores(globalStores);
+    }
+
+    private static StreamsGroupDescribeResponseData.TopologyDescriptionNode 
toResponseNode(Node node) {
+        StreamsGroupDescribeResponseData.TopologyDescriptionNode wire =
+            new StreamsGroupDescribeResponseData.TopologyDescriptionNode()
+                .setName(node.name())
+                .setSuccessors(new ArrayList<>(node.successors()));
+        if (node instanceof Source source) {
+            wire.setNodeType(NODE_TYPE_SOURCE);
+            wire.setSourceTopics(new ArrayList<>(source.topics()));
+        } else if (node instanceof Processor processor) {
+            wire.setNodeType(NODE_TYPE_PROCESSOR);
+            wire.setStores(new ArrayList<>(processor.stores()));
+        } else if (node instanceof Sink sink) {
+            wire.setNodeType(NODE_TYPE_SINK);
+            sink.topic().ifPresent(wire::setSinkTopic);
+        } else {
+            throw new IllegalStateException(
+                "Unknown topology node type: " + node.getClass().getName()
+            );
+        }
+        return wire;
+    }
+
     private static Node convertNode(
         
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode wire
     ) {
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 93cf81504e3..61da755aa9e 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,16 +20,20 @@ import 
org.apache.kafka.common.errors.CoordinatorLoadInProgressException;
 import org.apache.kafka.common.errors.CoordinatorNotAvailableException;
 import org.apache.kafka.common.errors.GroupIdNotFoundException;
 import org.apache.kafka.common.errors.NotCoordinatorException;
+import org.apache.kafka.common.message.StreamsGroupDescribeResponseData;
 import org.apache.kafka.common.message.StreamsGroupHeartbeatResponseData;
 import 
org.apache.kafka.common.message.StreamsGroupTopologyDescriptionUpdateResponseData;
 import org.apache.kafka.common.protocol.Errors;
 import org.apache.kafka.common.requests.ApiError;
 import org.apache.kafka.common.requests.StreamsGroupHeartbeatResponse.Status;
 import org.apache.kafka.common.utils.Time;
+import org.apache.kafka.common.utils.internals.LogContext;
 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 org.slf4j.Logger;
+
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -40,6 +44,10 @@ import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CompletionException;
 
+import static 
org.apache.kafka.common.requests.StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_AVAILABLE;
+import static 
org.apache.kafka.common.requests.StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_ERROR;
+import static 
org.apache.kafka.common.requests.StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_NOT_STORED;
+
 /**
  * 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
@@ -56,13 +64,16 @@ import java.util.concurrent.CompletionException;
  * convergence after a restart.
  */
 public class StreamsGroupTopologyDescriptionManager implements AutoCloseable {
+    private final Logger log;
     private final Optional<StreamsGroupTopologyDescriptionPlugin> plugin;
     private final StreamsGroupTopologyDescriptionBackoff backoff;
 
     public StreamsGroupTopologyDescriptionManager(
+        LogContext logContext,
         Optional<StreamsGroupTopologyDescriptionPlugin> plugin,
         Time time
     ) {
+        this.log = 
logContext.logger(StreamsGroupTopologyDescriptionManager.class);
         this.plugin = plugin;
         this.backoff = new StreamsGroupTopologyDescriptionBackoff(time);
     }
@@ -299,6 +310,128 @@ public class StreamsGroupTopologyDescriptionManager 
implements AutoCloseable {
             "Topology description plugin failed to delete the topology."));
     }
 
+    /**
+     * Populate {@code TopologyDescription} and {@code 
TopologyDescriptionStatus} on each
+     * {@code DescribedGroup} carried by {@code result}, calling {@code 
plugin.getTopology} only
+     * for groups whose persisted {@code StoredDescriptionTopologyEpoch} 
matches the group's
+     * current topology epoch. Returns a future that completes when every 
per-group plugin call
+     * has settled; the future never completes exceptionally — per-group 
errors fold into
+     * {@code TOPOLOGY_DESCRIPTION_STATUS_ERROR} on the corresponding 
describedGroup.
+     *
+     * <p>Per-group status decisions:
+     * <ul>
+     *   <li>{@code errorCode != NONE} — the group could not be resolved; 
leave the status
+     *       field at its {@code NOT_REQUESTED} default since the client 
should consult the
+     *       group's error code first.</li>
+     *   <li>No plugin configured on this broker — every successful group 
becomes
+     *       {@code NOT_STORED}: from the client's perspective the broker 
simply has no
+     *       description to serve.</li>
+     *   <li>{@code Topology} field is null on the response (group has not yet 
declared a
+     *       topology), {@code storedEpoch} is missing or {@code -1}, or 
{@code storedEpoch}
+     *       does not match {@code topology().epoch()} — {@code 
NOT_STORED}.</li>
+     *   <li>Plugin call returns null (the plugin no longer has the data, e.g. 
backend wipe)
+     *       — {@code NOT_STORED}.</li>
+     *   <li>Plugin call completes exceptionally, throws synchronously, or 
returns a null
+     *       future (SPI contract violation) — {@code ERROR}. Conversion 
failures from the
+     *       returned POJO to the wire schema also fold into {@code ERROR} so 
a single
+     *       malformed plugin response cannot poison the rest of the 
batch.</li>
+     *   <li>Plugin call returns a non-null description — {@code AVAILABLE}, 
with the
+     *       converted topology attached.</li>
+     * </ul>
+     */
+    public 
CompletableFuture<List<StreamsGroupDescribeResponseData.DescribedGroup>> 
attachTopologyDescriptions(
+        StreamsGroupDescribeResult result
+    ) {
+        if (plugin.isEmpty()) {
+            for (StreamsGroupDescribeResponseData.DescribedGroup 
describedGroup : result.describedGroups()) {
+                if (describedGroup.errorCode() == Errors.NONE.code()) {
+                    
describedGroup.setTopologyDescriptionStatus(TOPOLOGY_DESCRIPTION_STATUS_NOT_STORED);
+                }
+            }
+            return CompletableFuture.completedFuture(result.describedGroups());
+        }
+        final StreamsGroupTopologyDescriptionPlugin topologyDescriptionPlugin 
= plugin.get();
+        List<CompletableFuture<Void>> pluginFutures = new ArrayList<>();
+        for (StreamsGroupDescribeResponseData.DescribedGroup describedGroup : 
result.describedGroups()) {
+            CompletableFuture<Void> outcome = 
maybeAttachOne(topologyDescriptionPlugin, describedGroup, result);
+            if (outcome != null) pluginFutures.add(outcome);
+        }
+        if (pluginFutures.isEmpty()) {
+            return CompletableFuture.completedFuture(result.describedGroups());
+        }
+        return CompletableFuture.allOf(pluginFutures.toArray(new 
CompletableFuture<?>[0]))
+            .thenApply(unused -> result.describedGroups());
+    }
+
+    /**
+     * Inspect one describedGroup and, if eligible, fire {@code 
plugin.getTopology}. Returns
+     * null when no plugin call is needed (status has already been decided 
synchronously from
+     * the response shape); otherwise returns a future that completes once the 
plugin call has
+     * been folded into the describedGroup's status / topology fields.
+     */
+    private CompletableFuture<Void> maybeAttachOne(
+        StreamsGroupTopologyDescriptionPlugin p,
+        StreamsGroupDescribeResponseData.DescribedGroup describedGroup,
+        StreamsGroupDescribeResult result
+    ) {
+        if (describedGroup.errorCode() != Errors.NONE.code()) {
+            return null;
+        }
+        if (describedGroup.topology() == null) {
+            
describedGroup.setTopologyDescriptionStatus(TOPOLOGY_DESCRIPTION_STATUS_NOT_STORED);
+            return null;
+        }
+        Integer storedEpoch = 
result.storedDescriptionTopologyEpochs().get(describedGroup.groupId());
+        if (storedEpoch == null || storedEpoch == -1 || storedEpoch != 
describedGroup.topology().epoch()) {
+            
describedGroup.setTopologyDescriptionStatus(TOPOLOGY_DESCRIPTION_STATUS_NOT_STORED);
+            return null;
+        }
+        int topologyEpoch = describedGroup.topology().epoch();
+        CompletableFuture<StreamsGroupTopologyDescription> pluginFuture;
+        try {
+            pluginFuture = p.getTopology(describedGroup.groupId(), 
topologyEpoch);
+        } catch (Exception e) {
+            // SPI contract violation: synchronous throw treated as ERROR.
+            
describedGroup.setTopologyDescriptionStatus(TOPOLOGY_DESCRIPTION_STATUS_ERROR);
+            return CompletableFuture.completedFuture(null);
+        }
+        if (pluginFuture == null) {
+            
describedGroup.setTopologyDescriptionStatus(TOPOLOGY_DESCRIPTION_STATUS_ERROR);
+            return CompletableFuture.completedFuture(null);
+        }
+        return pluginFuture.handle((topology, throwable) -> {
+            applyGetTopologyOutcome(describedGroup, topology, throwable);
+            return null;
+        });
+    }
+
+    private void applyGetTopologyOutcome(
+        StreamsGroupDescribeResponseData.DescribedGroup describedGroup,
+        StreamsGroupTopologyDescription topology,
+        Throwable throwable
+    ) {
+        if (throwable != null) {
+            Throwable cause = Errors.maybeUnwrapException(throwable);
+            log.warn("Topology description plugin getTopology failed for group 
{}.",
+                describedGroup.groupId(), cause != null ? cause : throwable);
+            
describedGroup.setTopologyDescriptionStatus(TOPOLOGY_DESCRIPTION_STATUS_ERROR);
+            return;
+        }
+        if (topology == null) {
+            
describedGroup.setTopologyDescriptionStatus(TOPOLOGY_DESCRIPTION_STATUS_NOT_STORED);
+            return;
+        }
+        try {
+            describedGroup.setTopologyDescription(
+                
StreamsGroupTopologyDescriptionConverter.toDescribeResponse(topology));
+            
describedGroup.setTopologyDescriptionStatus(TOPOLOGY_DESCRIPTION_STATUS_AVAILABLE);
+        } catch (Exception conversionError) {
+            // Defensive catch, should be unreachable in practice
+            describedGroup.setTopologyDescription(null);
+            
describedGroup.setTopologyDescriptionStatus(TOPOLOGY_DESCRIPTION_STATUS_ERROR);
+        }
+    }
+
     // Visible for testing.
     StreamsGroupTopologyDescriptionBackoff backoff() {
         return backoff;
diff --git 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTest.java
 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTest.java
index 33a71646068..16adc5b0618 100644
--- 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTest.java
+++ 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTest.java
@@ -2180,7 +2180,7 @@ public class GroupCoordinatorServiceTest {
         )).thenReturn(describedGroupFuture);
 
         
CompletableFuture<List<StreamsGroupDescribeResponseData.DescribedGroup>> future 
=
-            
service.streamsGroupDescribe(requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), 
Arrays.asList("group-id-1", "group-id-2"));
+            
service.streamsGroupDescribe(requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), 
Arrays.asList("group-id-1", "group-id-2"), false);
 
         assertFalse(future.isDone());
         describedGroupFuture.complete(new 
StreamsGroupDescribeResult(List.of(describedGroup2), Map.of()));
@@ -2220,7 +2220,7 @@ public class GroupCoordinatorServiceTest {
         
)).thenReturn(CompletableFuture.completedFuture(List.of(describedGroup)));
 
         
CompletableFuture<List<StreamsGroupDescribeResponseData.DescribedGroup>> future 
=
-            
service.streamsGroupDescribe(requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), 
Arrays.asList("", null));
+            
service.streamsGroupDescribe(requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), 
Arrays.asList("", null), false);
 
         assertEquals(expectedDescribedGroups, future.get());
     }
@@ -2244,7 +2244,7 @@ public class GroupCoordinatorServiceTest {
         ));
 
         
CompletableFuture<List<StreamsGroupDescribeResponseData.DescribedGroup>> future 
=
-            
service.streamsGroupDescribe(requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), 
List.of("group-id"));
+            
service.streamsGroupDescribe(requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), 
List.of("group-id"), false);
 
         assertEquals(
             List.of(new StreamsGroupDescribeResponseData.DescribedGroup()
@@ -2271,7 +2271,7 @@ public class GroupCoordinatorServiceTest {
         ));
 
         
CompletableFuture<List<StreamsGroupDescribeResponseData.DescribedGroup>> future 
=
-            
service.streamsGroupDescribe(requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), 
List.of("group-id"));
+            
service.streamsGroupDescribe(requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), 
List.of("group-id"), false);
 
         assertEquals(
             List.of(new StreamsGroupDescribeResponseData.DescribedGroup()
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 8fe7b89a60b..43a06050339 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
@@ -23,20 +23,24 @@ import 
org.apache.kafka.common.errors.NotCoordinatorException;
 import org.apache.kafka.common.errors.UnknownMemberIdException;
 import org.apache.kafka.common.internals.Topic;
 import org.apache.kafka.common.message.DeleteGroupsResponseData;
+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.protocol.ApiKeys;
 import org.apache.kafka.common.protocol.Errors;
+import org.apache.kafka.common.requests.StreamsGroupDescribeResponse;
 import org.apache.kafka.common.utils.MockTime;
 import org.apache.kafka.common.utils.internals.BufferSupplier;
 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.StreamsGroupTopologyDescription;
 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.StreamsGroupDescribeResult;
 import org.apache.kafka.coordinator.group.streams.StreamsGroupHeartbeatResult;
 import org.apache.kafka.server.share.persister.NoOpStatePersister;
 import org.apache.kafka.server.util.timer.MockTimer;
@@ -913,6 +917,228 @@ public class 
GroupCoordinatorServiceTopologyDescriptionTest {
         assertEquals("Topology description plugin failed to delete the 
topology.", badResult.errorMessage());
     }
 
+    @Test
+    public void testDescribeWithIncludeFlagDisabledLeavesStatusDefault() 
throws Exception {
+        // includeTopologyDescription=false -> plugin is not consulted 
regardless of whether
+        // the group would otherwise be eligible. TopologyDescriptionStatus 
stays at the
+        // default 0 (NOT_REQUESTED) and the response carries no 
topologyDescription.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+
+        StreamsGroupDescribeResponseData.DescribedGroup describedGroup = 
describedGroupWithTopology("foo", 5);
+        when(runtime.scheduleReadOperation(eq("streams-group-describe"), 
eq(GROUP_TP), any()))
+            .thenReturn(CompletableFuture.completedFuture(
+                new StreamsGroupDescribeResult(List.of(describedGroup), 
Map.of("foo", 5))));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        List<StreamsGroupDescribeResponseData.DescribedGroup> result = 
service.streamsGroupDescribe(
+            requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), List.of("foo"), 
false
+        ).get(5, TimeUnit.SECONDS);
+
+        assertEquals(1, result.size());
+        
assertEquals(StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_NOT_REQUESTED,
 result.get(0).topologyDescriptionStatus());
+        assertNull(result.get(0).topologyDescription());
+        verify(plugin, never()).getTopology(anyString(), anyInt());
+    }
+
+    @Test
+    public void testDescribeSetsNotStoredWhenNoPluginConfigured() throws 
Exception {
+        // includeTopologyDescription=true but no plugin on this broker: every 
successful group
+        // becomes NOT_STORED (the broker has nothing to serve). The errored 
group keeps its
+        // default 0 status because the client should consult the group's 
errorCode first.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+
+        StreamsGroupDescribeResponseData.DescribedGroup goodGroup = 
describedGroupWithTopology("good", 5);
+        StreamsGroupDescribeResponseData.DescribedGroup errorGroup = new 
StreamsGroupDescribeResponseData.DescribedGroup()
+            .setGroupId("error")
+            .setErrorCode(Errors.GROUP_ID_NOT_FOUND.code());
+        when(runtime.scheduleReadOperation(eq("streams-group-describe"), 
eq(GROUP_TP), any()))
+            .thenReturn(CompletableFuture.completedFuture(
+                new StreamsGroupDescribeResult(List.of(goodGroup, errorGroup), 
Map.of("good", 5))));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.empty(), true);
+        List<StreamsGroupDescribeResponseData.DescribedGroup> result = 
service.streamsGroupDescribe(
+            requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), List.of("good", 
"error"), true
+        ).get(5, TimeUnit.SECONDS);
+
+        StreamsGroupDescribeResponseData.DescribedGroup good = 
findByGroupId(result, "good");
+        StreamsGroupDescribeResponseData.DescribedGroup err = 
findByGroupId(result, "error");
+        
assertEquals(StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_NOT_STORED,
 good.topologyDescriptionStatus());
+        
assertEquals(StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_NOT_REQUESTED,
 err.topologyDescriptionStatus());
+    }
+
+    @Test
+    public void testDescribeFiltersNonEligibleGroupsToNotStored() throws 
Exception {
+        // One test covers the three synchronous "no plugin call" branches: 
topology() == null,
+        // storedEpoch missing from the result map, storedEpoch mismatched. 
All three must
+        // resolve to NOT_STORED without invoking the plugin.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+
+        StreamsGroupDescribeResponseData.DescribedGroup noTopology = new 
StreamsGroupDescribeResponseData.DescribedGroup()
+            .setGroupId("no-topology"); // topology field is null
+        StreamsGroupDescribeResponseData.DescribedGroup epochMissing = 
describedGroupWithTopology("epoch-missing", 4);
+        StreamsGroupDescribeResponseData.DescribedGroup epochMismatch = 
describedGroupWithTopology("epoch-mismatch", 4);
+        when(runtime.scheduleReadOperation(eq("streams-group-describe"), 
eq(GROUP_TP), any()))
+            .thenReturn(CompletableFuture.completedFuture(
+                new StreamsGroupDescribeResult(
+                    List.of(noTopology, epochMissing, epochMismatch),
+                    // "epoch-missing" absent from map; "epoch-mismatch" 
stored at 7 vs current 4.
+                    Map.of("epoch-mismatch", 7))));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        List<StreamsGroupDescribeResponseData.DescribedGroup> result = 
service.streamsGroupDescribe(
+            requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE),
+            List.of("no-topology", "epoch-missing", "epoch-mismatch"),
+            true
+        ).get(5, TimeUnit.SECONDS);
+
+        for (StreamsGroupDescribeResponseData.DescribedGroup g : result) {
+            
assertEquals(StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_NOT_STORED,
+                g.topologyDescriptionStatus(), "group " + g.groupId());
+        }
+        verify(plugin, never()).getTopology(anyString(), anyInt());
+    }
+
+    @Test
+    public void testDescribeAttachesAvailableWhenPluginReturnsTopology() 
throws Exception {
+        // Happy path: storedEpoch == currentEpoch, plugin returns a non-null 
topology, the
+        // wire-schema topology is attached and the status becomes AVAILABLE.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+
+        StreamsGroupTopologyDescription pojo = new 
StreamsGroupTopologyDescription(
+            List.of(new StreamsGroupTopologyDescription.Subtopology("sub-0", 
List.of(
+                new StreamsGroupTopologyDescription.Source("src", 
Set.of("input"), Set.of())))),
+            List.of()
+        );
+        when(plugin.getTopology("foo", 
5)).thenReturn(CompletableFuture.completedFuture(pojo));
+
+        StreamsGroupDescribeResponseData.DescribedGroup describedGroup = 
describedGroupWithTopology("foo", 5);
+        when(runtime.scheduleReadOperation(eq("streams-group-describe"), 
eq(GROUP_TP), any()))
+            .thenReturn(CompletableFuture.completedFuture(
+                new StreamsGroupDescribeResult(List.of(describedGroup), 
Map.of("foo", 5))));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        List<StreamsGroupDescribeResponseData.DescribedGroup> result = 
service.streamsGroupDescribe(
+            requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), List.of("foo"), 
true
+        ).get(5, TimeUnit.SECONDS);
+
+        StreamsGroupDescribeResponseData.DescribedGroup g = result.get(0);
+        
assertEquals(StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_AVAILABLE,
 g.topologyDescriptionStatus());
+        assertNotNull(g.topologyDescription());
+        assertEquals(1, g.topologyDescription().subtopologies().size());
+        assertEquals("sub-0", 
g.topologyDescription().subtopologies().get(0).subtopologyId());
+    }
+
+    @Test
+    public void testDescribeMarksErrorWhenPluginFails() throws Exception {
+        // Plugin future completes exceptionally -> ERROR; the converted 
topology is not set.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(plugin.getTopology("foo", 5))
+            .thenReturn(CompletableFuture.failedFuture(new 
RuntimeException("plugin offline")));
+
+        StreamsGroupDescribeResponseData.DescribedGroup describedGroup = 
describedGroupWithTopology("foo", 5);
+        when(runtime.scheduleReadOperation(eq("streams-group-describe"), 
eq(GROUP_TP), any()))
+            .thenReturn(CompletableFuture.completedFuture(
+                new StreamsGroupDescribeResult(List.of(describedGroup), 
Map.of("foo", 5))));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        List<StreamsGroupDescribeResponseData.DescribedGroup> result = 
service.streamsGroupDescribe(
+            requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), List.of("foo"), 
true
+        ).get(5, TimeUnit.SECONDS);
+
+        StreamsGroupDescribeResponseData.DescribedGroup g = result.get(0);
+        
assertEquals(StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_ERROR, 
g.topologyDescriptionStatus());
+        assertNull(g.topologyDescription());
+    }
+
+    @Test
+    public void testDescribeMarksErrorWhenPluginThrowsSynchronously() throws 
Exception {
+        // SPI contract violation: synchronous throw is treated the same as an 
exceptional
+        // future -> ERROR. The exception must not propagate out of the 
describe response.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(plugin.getTopology("foo", 5)).thenThrow(new 
RuntimeException("synthetic sync throw"));
+
+        StreamsGroupDescribeResponseData.DescribedGroup describedGroup = 
describedGroupWithTopology("foo", 5);
+        when(runtime.scheduleReadOperation(eq("streams-group-describe"), 
eq(GROUP_TP), any()))
+            .thenReturn(CompletableFuture.completedFuture(
+                new StreamsGroupDescribeResult(List.of(describedGroup), 
Map.of("foo", 5))));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        List<StreamsGroupDescribeResponseData.DescribedGroup> result = 
service.streamsGroupDescribe(
+            requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), List.of("foo"), 
true
+        ).get(5, TimeUnit.SECONDS);
+
+        
assertEquals(StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_ERROR,
+            result.get(0).topologyDescriptionStatus());
+    }
+
+    @Test
+    public void testDescribeMarksNotStoredWhenPluginReturnsNullDescription() 
throws Exception {
+        // Plugin's future completes with null -> the plugin no longer holds 
the data
+        // (e.g. backend wipe). Treat as NOT_STORED, not ERROR — the broker 
successfully
+        // queried the plugin and learned there is nothing to return.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(plugin.getTopology("foo", 
5)).thenReturn(CompletableFuture.completedFuture(null));
+
+        StreamsGroupDescribeResponseData.DescribedGroup describedGroup = 
describedGroupWithTopology("foo", 5);
+        when(runtime.scheduleReadOperation(eq("streams-group-describe"), 
eq(GROUP_TP), any()))
+            .thenReturn(CompletableFuture.completedFuture(
+                new StreamsGroupDescribeResult(List.of(describedGroup), 
Map.of("foo", 5))));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        List<StreamsGroupDescribeResponseData.DescribedGroup> result = 
service.streamsGroupDescribe(
+            requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), List.of("foo"), 
true
+        ).get(5, TimeUnit.SECONDS);
+
+        
assertEquals(StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_NOT_STORED,
+            result.get(0).topologyDescriptionStatus());
+        assertNull(result.get(0).topologyDescription());
+    }
+
+    @Test
+    public void testDescribeLeavesErroredGroupsAlone() throws Exception {
+        // Groups with non-NONE errorCode are not eligible for topology 
attach; status stays
+        // at the default 0 (NOT_REQUESTED). The plugin must not be called for 
them.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+
+        StreamsGroupDescribeResponseData.DescribedGroup errorGroup = new 
StreamsGroupDescribeResponseData.DescribedGroup()
+            .setGroupId("err")
+            .setErrorCode(Errors.GROUP_ID_NOT_FOUND.code());
+        when(runtime.scheduleReadOperation(eq("streams-group-describe"), 
eq(GROUP_TP), any()))
+            .thenReturn(CompletableFuture.completedFuture(
+                new StreamsGroupDescribeResult(List.of(errorGroup), 
Map.of())));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        List<StreamsGroupDescribeResponseData.DescribedGroup> result = 
service.streamsGroupDescribe(
+            requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), List.of("err"), 
true
+        ).get(5, TimeUnit.SECONDS);
+
+        
assertEquals(StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_NOT_REQUESTED,
+            result.get(0).topologyDescriptionStatus());
+        verify(plugin, never()).getTopology(anyString(), anyInt());
+    }
+
+    private static StreamsGroupDescribeResponseData.DescribedGroup 
describedGroupWithTopology(
+        String groupId, int topologyEpoch
+    ) {
+        return new StreamsGroupDescribeResponseData.DescribedGroup()
+            .setGroupId(groupId)
+            .setErrorCode(Errors.NONE.code())
+            .setTopology(new 
StreamsGroupDescribeResponseData.Topology().setEpoch(topologyEpoch));
+    }
+
+    private static StreamsGroupDescribeResponseData.DescribedGroup 
findByGroupId(
+        List<StreamsGroupDescribeResponseData.DescribedGroup> groups, String 
groupId
+    ) {
+        return groups.stream().filter(g -> 
g.groupId().equals(groupId)).findFirst().orElseThrow();
+    }
+
     private static StreamsGroupTopologyDescriptionUpdateRequestData 
validUpdateRequest() {
         return new StreamsGroupTopologyDescriptionUpdateRequestData()
             .setGroupId("foo")

Reply via email to