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

frankvicky 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 88425232ded KAFKA-20627: Add broker integration tests for topology 
description plugin (#22775)
88425232ded is described below

commit 88425232ded74367a6b0fb94d31ac6ca233ec992
Author: Alieh Saeedi <[email protected]>
AuthorDate: Tue Jul 14 10:13:49 2026 +0200

    KAFKA-20627: Add broker integration tests for topology description plugin 
(#22775)
    
    Adds the integration test coverage for the streams group topology
    description plugin (KIP-1331) requested in
    [KAFKA-20627](https://issues.apache.org/jira/browse/KAFKA-20627):
    
    ### Broker-level RPC tests (`core`)
    - **`StreamsGroupTopologyDescriptionRequestTest`** (plugin configured
    with `InMemoryTopologyDescriptionPlugin`):
      - heartbeat solicits the push via `TopologyDescriptionRequired`, the
    push → describe lifecycle returns `AVAILABLE` with the stored topology,
    and there is no re-solicitation once stored
      - validation errors: empty group/member id → `INVALID_REQUEST`,
    unknown group → `GROUP_ID_NOT_FOUND`, unknown member →
    `UNKNOWN_MEMBER_ID`, stale topology epoch → `INVALID_REQUEST`
      - `DeleteGroups` succeeds on a group with a stored description and the
    group is gone afterwards
    - **`StreamsGroupTopologyDescriptionNoPluginRequestTest`** (no plugin
    configured): the push RPC returns `UNSUPPORTED_VERSION`, heartbeats
    never solicit a push, and describe reports `NOT_STORED`
    - `GroupCoordinatorBaseRequestTest`: new
    `streamsGroupTopologyDescriptionUpdate` helper and
    `IncludeTopologyDescription` support on `streamsGroupDescribe`
    
    ### End-to-end tests with real `KafkaStreams` clients
    (`streams/integration-tests`)
    - **`TopologyDescriptionPluginIntegrationTest`** — 4 tests covering the
    core push lifecycle on a live cluster:
      - topology description reaches the plugin after join
      - two-member group deduplicates: only one push happens
      - permanent plugin failure ratchets the failed epoch and stops
    re-solicitation (call count stays at one, the application keeps running,
    describe reports `NOT_STORED`)
      - explicit `DeleteGroups` triggers `plugin.deleteTopology`
    - **`TopologyDescriptionPluginExpirationIntegrationTest`** — 2 tests
    covering natural group expiry:
      - group with stored topology: the cleanup cycle calls
    `plugin.deleteTopology` and clears the stored epoch before the
    expiration sweep tombstones the group
      - group whose push permanently failed (stored epoch `-1`): the
    tombstone fires directly without any `deleteTopology` call
    - **`TopologyDescriptionNoPluginIntegrationTest`** — 1 test keeping the
    plugin-less production default exercised end-to-end at the streams
    level: a real `KafkaStreams` client joins and runs normally against a
    broker without a plugin (no push is ever solicited), and describe with
    `includeTopologyDescription` reports `NOT_STORED`
    - The plugin-configured tests observe the broker through a new
    `TrackingTopologyDescriptionPlugin` test plugin (extends
    `InMemoryTopologyDescriptionPlugin`, records per-group
    `setTopology`/`deleteTopology` calls in static state and can inject
    permanent failures) — the embedded brokers run in the test JVM
    
    ### CLI and Admin API
    - `DescribeStreamsGroupTest#testDescribeStreamsGroupWithTopologyOption`:
    exercises `kafka-streams-groups.sh --describe --topology` end-to-end,
    waiting until the output contains `Topologies:` and the source topic
    name
    -
    
    
`PlaintextAdminIntegrationTest#testDescribeStreamsGroupsWithTopologyDescription`:
    `DescribeStreamsGroupsOptions.includeTopologyDescription` wiring
    (`NOT_REQUESTED` / `NOT_STORED` on a plugin-less cluster; the
    `AVAILABLE` path is covered via the Admin API in
    `TopologyDescriptionPluginIntegrationTest`)
    
    ### Infrastructure
    - `EmbeddedKafkaCluster.addDefaultBrokerPropsIfAbsent` now configures
    `InMemoryTopologyDescriptionPlugin` via `putIfAbsent`, so all streams
    integration tests exercise the topology description path and catch
    regressions without per-test changes. An explicitly empty value for
    `group.streams.topology.description.plugin.class` opts out and runs the
    broker with the plugin-less production default (a `Properties` cannot
    carry null and an empty class name would fail config parsing)
    - `streams:integration-tests` gains a `group-coordinator-api` test
    dependency; import-control allows `org.apache.kafka.server.streams` in
    streams integration tests
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    ---------
    
    Co-authored-by: Claude Fable 5 <[email protected]>
---
 build.gradle                                       |   1 +
 checkstyle/import-control.xml                      |   1 +
 .../kafka/api/PlaintextAdminIntegrationTest.scala  |  48 +++
 .../server/GroupCoordinatorBaseRequestTest.scala   |  26 +-
 ...oupTopologyDescriptionNoPluginRequestTest.scala | 142 +++++++++
 ...treamsGroupTopologyDescriptionRequestTest.scala | 352 +++++++++++++++++++++
 ...TopologyDescriptionNoPluginIntegrationTest.java | 116 +++++++
 ...DescriptionPluginExpirationIntegrationTest.java | 189 +++++++++++
 .../TopologyDescriptionPluginIntegrationTest.java  | 261 +++++++++++++++
 .../integration/utils/EmbeddedKafkaCluster.java    |  16 +
 .../utils/TrackingTopologyDescriptionPlugin.java   |  82 +++++
 .../tools/streams/DescribeStreamsGroupTest.java    |  26 ++
 12 files changed, 1258 insertions(+), 2 deletions(-)

diff --git a/build.gradle b/build.gradle
index 1bd566ccee4..7fe163a9245 100644
--- a/build.gradle
+++ b/build.gradle
@@ -3065,6 +3065,7 @@ project(':streams:integration-tests') {
 
     testImplementation testFixtures(project(':clients'))
     testImplementation project(':group-coordinator')
+    testImplementation project(':group-coordinator:group-coordinator-api')
     testImplementation project(':server')
     testImplementation project(':server-common')
     testImplementation testFixtures(project(':server-common'))
diff --git a/checkstyle/import-control.xml b/checkstyle/import-control.xml
index 9062dc0f199..3a0b41cabd1 100644
--- a/checkstyle/import-control.xml
+++ b/checkstyle/import-control.xml
@@ -422,6 +422,7 @@
       <allow 
class="org.apache.kafka.coordinator.transaction.TransactionLogConfig" />
       <allow pkg="org.apache.kafka.coordinator.group" />
       <allow pkg="org.apache.kafka.network" />
+      <allow pkg="org.apache.kafka.server.streams" />
     </subpackage>
 
     <subpackage name="test">
diff --git 
a/core/src/test/scala/integration/kafka/api/PlaintextAdminIntegrationTest.scala 
b/core/src/test/scala/integration/kafka/api/PlaintextAdminIntegrationTest.scala
index 913aec618a2..5b92f13c74c 100644
--- 
a/core/src/test/scala/integration/kafka/api/PlaintextAdminIntegrationTest.scala
+++ 
b/core/src/test/scala/integration/kafka/api/PlaintextAdminIntegrationTest.scala
@@ -4484,6 +4484,54 @@ class PlaintextAdminIntegrationTest extends 
BaseAdminIntegrationTest {
     }
   }
 
+  @Test
+  def testDescribeStreamsGroupsWithTopologyDescription(): Unit = {
+    val streamsGroupId = "stream_group_id"
+    val testTopicName = "test_topic"
+    val testNumPartitions = 1
+
+    val config = createConfig
+    client = Admin.create(config)
+
+    prepareTopics(List(testTopicName), testNumPartitions)
+    prepareRecords(testTopicName)
+
+    val streams = createStreamsGroup(
+      inputTopics = Set(testTopicName),
+      changelogTopics = Set(testTopicName + "-changelog"),
+      streamsGroupId = streamsGroupId
+    )
+    streams.poll(JDuration.ofMillis(500L))
+
+    try {
+      TestUtils.waitUntilTrue(() => {
+        val firstGroup = client.listGroups().all().get().stream()
+          .filter(g => g.groupId() == streamsGroupId).findFirst().orElse(null)
+        firstGroup != null && firstGroup.groupState().orElse(null) == 
GroupState.STABLE
+      }, "Streams group did not transition to STABLE before timeout")
+
+      // Without IncludeTopologyDescription the status stays at its 
NOT_REQUESTED default
+      // and no topology description is attached.
+      val groupWithoutTopology = 
client.describeStreamsGroups(util.List.of(streamsGroupId)).all().get().get(streamsGroupId)
+      assertNotNull(groupWithoutTopology)
+      assertEquals(StreamsGroupTopologyDescriptionStatus.NOT_REQUESTED, 
groupWithoutTopology.topologyDescriptionStatus())
+      assertTrue(groupWithoutTopology.topologyDescription().isEmpty)
+
+      // With IncludeTopologyDescription the brokers of this cluster have no 
topology
+      // description plugin configured, so there is no description to serve: 
NOT_STORED.
+      val groupWithTopology = client.describeStreamsGroups(
+        util.List.of(streamsGroupId),
+        new DescribeStreamsGroupsOptions().includeTopologyDescription(true)
+      ).all().get().get(streamsGroupId)
+      assertNotNull(groupWithTopology)
+      assertEquals(StreamsGroupTopologyDescriptionStatus.NOT_STORED, 
groupWithTopology.topologyDescriptionStatus())
+      assertTrue(groupWithTopology.topologyDescription().isEmpty)
+    } finally {
+      Utils.closeQuietly(streams, "streams")
+      Utils.closeQuietly(client, "adminClient")
+    }
+  }
+
   @Test
   def testDescribeStreamsGroupsNotReady(): Unit = {
     val streamsGroupId = "stream_group_id"
diff --git 
a/core/src/test/scala/unit/kafka/server/GroupCoordinatorBaseRequestTest.scala 
b/core/src/test/scala/unit/kafka/server/GroupCoordinatorBaseRequestTest.scala
index 307a3ae1a5e..2420ad149d6 100644
--- 
a/core/src/test/scala/unit/kafka/server/GroupCoordinatorBaseRequestTest.scala
+++ 
b/core/src/test/scala/unit/kafka/server/GroupCoordinatorBaseRequestTest.scala
@@ -26,9 +26,9 @@ import 
org.apache.kafka.common.message.LeaveGroupRequestData.MemberIdentity
 import org.apache.kafka.common.message.LeaveGroupResponseData.MemberResponse
 import 
org.apache.kafka.common.message.SyncGroupRequestData.SyncGroupRequestAssignment
 import 
org.apache.kafka.common.message.WriteTxnMarkersRequestData.{WritableTxnMarker, 
WritableTxnMarkerTopic}
-import org.apache.kafka.common.message.{AddOffsetsToTxnRequestData, 
AddOffsetsToTxnResponseData, ConsumerGroupDescribeRequestData, 
ConsumerGroupDescribeResponseData, ConsumerGroupHeartbeatRequestData, 
ConsumerGroupHeartbeatResponseData, DeleteGroupsRequestData, 
DeleteGroupsResponseData, DescribeGroupsRequestData, 
DescribeGroupsResponseData, EndTxnRequestData, HeartbeatRequestData, 
HeartbeatResponseData, InitProducerIdRequestData, JoinGroupRequestData, 
JoinGroupResponseData, LeaveGroupRes [...]
+import org.apache.kafka.common.message.{AddOffsetsToTxnRequestData, 
AddOffsetsToTxnResponseData, ConsumerGroupDescribeRequestData, 
ConsumerGroupDescribeResponseData, ConsumerGroupHeartbeatRequestData, 
ConsumerGroupHeartbeatResponseData, DeleteGroupsRequestData, 
DeleteGroupsResponseData, DescribeGroupsRequestData, 
DescribeGroupsResponseData, EndTxnRequestData, HeartbeatRequestData, 
HeartbeatResponseData, InitProducerIdRequestData, JoinGroupRequestData, 
JoinGroupResponseData, LeaveGroupRes [...]
 import org.apache.kafka.common.protocol.{ApiKeys, Errors}
-import org.apache.kafka.common.requests.{AbstractRequest, AbstractResponse, 
AddOffsetsToTxnRequest, AddOffsetsToTxnResponse, ConsumerGroupDescribeRequest, 
ConsumerGroupDescribeResponse, ConsumerGroupHeartbeatRequest, 
ConsumerGroupHeartbeatResponse, DeleteGroupsRequest, DeleteGroupsResponse, 
DescribeGroupsRequest, DescribeGroupsResponse, EndTxnRequest, EndTxnResponse, 
HeartbeatRequest, HeartbeatResponse, InitProducerIdRequest, 
InitProducerIdResponse, JoinGroupRequest, JoinGroupResponse, L [...]
+import org.apache.kafka.common.requests.{AbstractRequest, AbstractResponse, 
AddOffsetsToTxnRequest, AddOffsetsToTxnResponse, ConsumerGroupDescribeRequest, 
ConsumerGroupDescribeResponse, ConsumerGroupHeartbeatRequest, 
ConsumerGroupHeartbeatResponse, DeleteGroupsRequest, DeleteGroupsResponse, 
DescribeGroupsRequest, DescribeGroupsResponse, EndTxnRequest, EndTxnResponse, 
HeartbeatRequest, HeartbeatResponse, InitProducerIdRequest, 
InitProducerIdResponse, JoinGroupRequest, JoinGroupResponse, L [...]
 import org.apache.kafka.common.serialization.StringSerializer
 import org.apache.kafka.common.test.ClusterInstance
 import org.apache.kafka.common.utils.internals.ProducerIdAndEpoch
@@ -777,18 +777,40 @@ class GroupCoordinatorBaseRequestTest(cluster: 
ClusterInstance) {
   protected def streamsGroupDescribe(
     groupIds: List[String],
     includeAuthorizedOperations: Boolean = false,
+    includeTopologyDescription: Boolean = false,
     version: Short = 
ApiKeys.STREAMS_GROUP_DESCRIBE.latestVersion(isUnstableApiEnabled)
   ): List[StreamsGroupDescribeResponseData.DescribedGroup] = {
     val streamsGroupDescribeRequest = new StreamsGroupDescribeRequest.Builder(
       new StreamsGroupDescribeRequestData()
         .setGroupIds(groupIds.asJava)
         .setIncludeAuthorizedOperations(includeAuthorizedOperations)
+        .setIncludeTopologyDescription(includeTopologyDescription)
     ).build(version)
 
     val streamsGroupDescribeResponse = 
connectAndReceive[StreamsGroupDescribeResponse](streamsGroupDescribeRequest)
     streamsGroupDescribeResponse.data.groups.asScala.toList
   }
 
+  protected def streamsGroupTopologyDescriptionUpdate(
+    groupId: String,
+    memberId: String,
+    topologyEpoch: Int,
+    topologyDescription: 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription,
+    version: Short = 
ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE.latestVersion(isUnstableApiEnabled)
+  ): StreamsGroupTopologyDescriptionUpdateResponseData = {
+    val streamsGroupTopologyDescriptionUpdateRequest = new 
StreamsGroupTopologyDescriptionUpdateRequest.Builder(
+      new StreamsGroupTopologyDescriptionUpdateRequestData()
+        .setGroupId(groupId)
+        .setMemberId(memberId)
+        .setTopologyEpoch(topologyEpoch)
+        .setTopologyDescription(topologyDescription)
+    ).build(version)
+
+    val streamsGroupTopologyDescriptionUpdateResponse =
+      
connectAndReceive[StreamsGroupTopologyDescriptionUpdateResponse](streamsGroupTopologyDescriptionUpdateRequest)
+    streamsGroupTopologyDescriptionUpdateResponse.data
+  }
+
   protected def heartbeat(
     groupId: String,
     generationId: Int,
diff --git 
a/core/src/test/scala/unit/kafka/server/StreamsGroupTopologyDescriptionNoPluginRequestTest.scala
 
b/core/src/test/scala/unit/kafka/server/StreamsGroupTopologyDescriptionNoPluginRequestTest.scala
new file mode 100644
index 00000000000..7e85f30f2f8
--- /dev/null
+++ 
b/core/src/test/scala/unit/kafka/server/StreamsGroupTopologyDescriptionNoPluginRequestTest.scala
@@ -0,0 +1,142 @@
+/*
+ * 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 kafka.server
+
+import kafka.utils.TestUtils
+import org.apache.kafka.common.message.{StreamsGroupHeartbeatRequestData, 
StreamsGroupTopologyDescriptionUpdateRequestData}
+import org.apache.kafka.common.protocol.Errors
+import org.apache.kafka.common.requests.StreamsGroupDescribeResponse
+import org.apache.kafka.common.test.ClusterInstance
+import org.apache.kafka.common.test.api.{ClusterConfigProperty, ClusterTest, 
ClusterTestDefaults, Type}
+import org.apache.kafka.coordinator.group.GroupCoordinatorConfig
+import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertNull}
+
+import scala.jdk.CollectionConverters._
+
+/**
+ * Broker-level tests of the StreamsGroupTopologyDescriptionUpdate RPC when no 
topology
+ * description plugin is configured 
(`group.streams.topology.description.plugin.class`
+ * unset): the RPC is rejected with UNSUPPORTED_VERSION, heartbeats never 
solicit a push,
+ * and describe reports NOT_STORED. See 
[[StreamsGroupTopologyDescriptionRequestTest]]
+ * for the behavior with a plugin configured.
+ */
+@ClusterTestDefaults(
+  types = Array(Type.KRAFT),
+  serverProperties = Array(
+    new ClusterConfigProperty(key = 
GroupCoordinatorConfig.OFFSETS_TOPIC_PARTITIONS_CONFIG, value = "1"),
+    new ClusterConfigProperty(key = 
GroupCoordinatorConfig.OFFSETS_TOPIC_REPLICATION_FACTOR_CONFIG, value = "1"),
+    new ClusterConfigProperty(key = 
GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, value = 
"0")
+  )
+)
+class StreamsGroupTopologyDescriptionNoPluginRequestTest(cluster: 
ClusterInstance) extends GroupCoordinatorBaseRequestTest(cluster) {
+
+  private val topologyEpoch = 1
+
+  @ClusterTest
+  def 
testStreamsGroupTopologyDescriptionUpdateReturnsUnsupportedVersionWhenNoPluginConfigured():
 Unit = {
+    // The plugin gate is checked before any group lookup, so no group setup 
is needed.
+    val response = streamsGroupTopologyDescriptionUpdate(
+      groupId = "test-group",
+      memberId = "test-member",
+      topologyEpoch = topologyEpoch,
+      topologyDescription = createTopologyDescription("test-topic")
+    )
+    assertEquals(Errors.UNSUPPORTED_VERSION.code(), response.errorCode())
+    assertEquals("The broker has no streams group topology description plugin 
configured.", response.errorMessage())
+  }
+
+  @ClusterTest
+  def 
testHeartbeatDoesNotSolicitPushAndDescribeReturnsNotStoredWhenNoPluginConfigured():
 Unit = {
+    val admin = cluster.admin()
+    val groupId = "test-group"
+    val memberId = "test-member"
+    val topicName = "test-topic"
+
+    try {
+      TestUtils.createOffsetsTopicWithAdmin(
+        admin = admin,
+        brokers = cluster.brokers.values().asScala.toSeq,
+        controllers = cluster.controllers().values().asScala.toSeq
+      )
+      TestUtils.createTopicWithAdmin(
+        admin = admin,
+        brokers = cluster.brokers.values().asScala.toSeq,
+        controllers = cluster.controllers().values().asScala.toSeq,
+        topic = topicName,
+        numPartitions = 3
+      )
+
+      // Join the group and wait until the topology epoch is resolved (tasks 
assigned).
+      // No heartbeat along the way may solicit a topology description push.
+      TestUtils.waitUntilTrue(() => {
+        val response = streamsGroupHeartbeat(
+          groupId = groupId,
+          memberId = memberId,
+          rebalanceTimeoutMs = 1000,
+          activeTasks = List.empty,
+          standbyTasks = List.empty,
+          warmupTasks = List.empty,
+          topology = createMockTopology(topicName)
+        )
+        assertFalse(response.topologyDescriptionRequired(),
+          "Broker must not solicit a topology description push when no plugin 
is configured.")
+        response.errorCode == Errors.NONE.code() && response.activeTasks() != 
null
+      }, "StreamsGroupHeartbeatRequest did not succeed within the timeout 
period.")
+
+      // Describe with IncludeTopologyDescription: without a plugin the broker 
has no
+      // description to serve, so the status is NOT_STORED.
+      val describedGroup = streamsGroupDescribe(
+        groupIds = List(groupId),
+        includeTopologyDescription = true
+      ).head
+      assertEquals(Errors.NONE.code(), describedGroup.errorCode())
+      
assertEquals(StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_NOT_STORED,
 describedGroup.topologyDescriptionStatus())
+      assertNull(describedGroup.topologyDescription())
+    } finally {
+      admin.close()
+    }
+  }
+
+  private def createMockTopology(topicName: String): 
StreamsGroupHeartbeatRequestData.Topology = {
+    new StreamsGroupHeartbeatRequestData.Topology()
+      .setEpoch(topologyEpoch)
+      .setSubtopologies(List(
+        new StreamsGroupHeartbeatRequestData.Subtopology()
+          .setSubtopologyId("subtopology-1")
+          .setSourceTopics(List(topicName).asJava)
+          .setRepartitionSinkTopics(List.empty.asJava)
+          .setRepartitionSourceTopics(List.empty.asJava)
+      ).asJava)
+  }
+
+  private def createTopologyDescription(topicName: String): 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription = {
+    new StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription()
+      .setSubtopologies(List(
+        new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionSubtopology()
+          .setSubtopologyId("subtopology-1")
+          .setNodes(List(
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode()
+              .setName("KSTREAM-SOURCE-0000000000")
+              .setNodeType(1.toByte) // SOURCE
+              .setSourceTopics(List(topicName).asJava)
+              .setStores(List.empty.asJava)
+              .setSuccessors(List.empty.asJava)
+          ).asJava)
+      ).asJava)
+      .setGlobalStores(List.empty.asJava)
+  }
+}
diff --git 
a/core/src/test/scala/unit/kafka/server/StreamsGroupTopologyDescriptionRequestTest.scala
 
b/core/src/test/scala/unit/kafka/server/StreamsGroupTopologyDescriptionRequestTest.scala
new file mode 100644
index 00000000000..8352339f2ce
--- /dev/null
+++ 
b/core/src/test/scala/unit/kafka/server/StreamsGroupTopologyDescriptionRequestTest.scala
@@ -0,0 +1,352 @@
+/*
+ * 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 kafka.server
+
+import kafka.utils.TestUtils
+import org.apache.kafka.common.errors.UnsupportedVersionException
+import org.apache.kafka.common.message.{StreamsGroupHeartbeatRequestData, 
StreamsGroupTopologyDescriptionUpdateRequestData}
+import org.apache.kafka.common.protocol.{ApiKeys, Errors}
+import org.apache.kafka.common.requests.StreamsGroupDescribeResponse
+import org.apache.kafka.common.test.ClusterInstance
+import org.apache.kafka.common.test.api.{ClusterConfigProperty, ClusterTest, 
ClusterTestDefaults, Type}
+import org.apache.kafka.coordinator.group.GroupCoordinatorConfig
+import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, 
assertNotNull, assertNull, assertThrows}
+
+import scala.jdk.CollectionConverters._
+
+/**
+ * Broker-level tests of the StreamsGroupTopologyDescriptionUpdate RPC with a 
topology
+ * description plugin configured 
([[org.apache.kafka.server.streams.InMemoryTopologyDescriptionPlugin]]).
+ * See [[StreamsGroupTopologyDescriptionNoPluginRequestTest]] for the 
plugin-less
+ * UNSUPPORTED_VERSION behavior.
+ */
+@ClusterTestDefaults(
+  types = Array(Type.KRAFT),
+  serverProperties = Array(
+    new ClusterConfigProperty(key = 
GroupCoordinatorConfig.OFFSETS_TOPIC_PARTITIONS_CONFIG, value = "1"),
+    new ClusterConfigProperty(key = 
GroupCoordinatorConfig.OFFSETS_TOPIC_REPLICATION_FACTOR_CONFIG, value = "1"),
+    new ClusterConfigProperty(key = 
GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, value = 
"0"),
+    new ClusterConfigProperty(
+      key = 
GroupCoordinatorConfig.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_PLUGIN_CLASS_CONFIG,
+      value = 
"org.apache.kafka.server.streams.InMemoryTopologyDescriptionPlugin")
+  )
+)
+class StreamsGroupTopologyDescriptionRequestTest(cluster: ClusterInstance) 
extends GroupCoordinatorBaseRequestTest(cluster) {
+
+  private val topologyEpoch = 1
+
+  @ClusterTest
+  def testStreamsGroupTopologyDescriptionUpdateWithInvalidApiVersion(): Unit = 
{
+    assertThrows(classOf[UnsupportedVersionException], () =>
+      streamsGroupTopologyDescriptionUpdate(
+        groupId = "test-group",
+        memberId = "test-member",
+        topologyEpoch = topologyEpoch,
+        topologyDescription = createTopologyDescription("test-topic"),
+        version = -1)
+    )
+  }
+
+  @ClusterTest
+  def testHeartbeatSolicitsPushAndDescribeReturnsStoredTopologyDescription(): 
Unit = {
+    val admin = cluster.admin()
+    val groupId = "test-group"
+    val memberId = "test-member"
+    val topicName = "test-topic"
+
+    try {
+      TestUtils.createOffsetsTopicWithAdmin(
+        admin = admin,
+        brokers = cluster.brokers.values().asScala.toSeq,
+        controllers = cluster.controllers().values().asScala.toSeq
+      )
+      TestUtils.createTopicWithAdmin(
+        admin = admin,
+        brokers = cluster.brokers.values().asScala.toSeq,
+        controllers = cluster.controllers().values().asScala.toSeq,
+        topic = topicName,
+        numPartitions = 3
+      )
+
+      // Join the group and wait until the broker solicits a topology 
description push.
+      // The flag is only set on the first heartbeat after the group's 
topology epoch is
+      // resolved (the per-group back-off is armed at the same time), so it 
must be
+      // accumulated across heartbeats rather than asserted on the last 
response.
+      var solicited = false
+      var memberEpoch = 0
+      TestUtils.waitUntilTrue(() => {
+        val response = streamsGroupHeartbeat(
+          groupId = groupId,
+          memberId = memberId,
+          rebalanceTimeoutMs = 1000,
+          activeTasks = List.empty,
+          standbyTasks = List.empty,
+          warmupTasks = List.empty,
+          topology = createMockTopology(topicName)
+        )
+        solicited ||= response.topologyDescriptionRequired()
+        memberEpoch = response.memberEpoch()
+        response.errorCode == Errors.NONE.code() && solicited
+      }, "Broker did not solicit a topology description push within the 
timeout period.")
+
+      // Push the topology description for the current topology epoch.
+      val updateResponse = streamsGroupTopologyDescriptionUpdate(
+        groupId = groupId,
+        memberId = memberId,
+        topologyEpoch = topologyEpoch,
+        topologyDescription = createTopologyDescription(topicName)
+      )
+      assertEquals(Errors.NONE.code(), updateResponse.errorCode(), 
s"Unexpected error: ${updateResponse.errorMessage()}")
+
+      // Describe with IncludeTopologyDescription: the stored description must 
be returned.
+      val describedGroup = streamsGroupDescribe(
+        groupIds = List(groupId),
+        includeTopologyDescription = true
+      ).head
+      assertEquals(Errors.NONE.code(), describedGroup.errorCode())
+      
assertEquals(StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_AVAILABLE,
 describedGroup.topologyDescriptionStatus())
+      assertNotNull(describedGroup.topologyDescription())
+      assertEquals(1, 
describedGroup.topologyDescription().subtopologies().size())
+      val subtopology = 
describedGroup.topologyDescription().subtopologies().get(0)
+      assertEquals("subtopology-1", subtopology.subtopologyId())
+      assertEquals(1, subtopology.nodes().size())
+      assertEquals("KSTREAM-SOURCE-0000000000", 
subtopology.nodes().get(0).name())
+      assertEquals(List(topicName).asJava, 
subtopology.nodes().get(0).sourceTopics())
+
+      // Describe without IncludeTopologyDescription: the status stays at its
+      // NOT_REQUESTED default and no description is attached.
+      val describedGroupWithoutTopology = streamsGroupDescribe(
+        groupIds = List(groupId)
+      ).head
+      assertEquals(Errors.NONE.code(), 
describedGroupWithoutTopology.errorCode())
+      
assertEquals(StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_NOT_REQUESTED,
 describedGroupWithoutTopology.topologyDescriptionStatus())
+      assertNull(describedGroupWithoutTopology.topologyDescription())
+
+      // Once the description is stored at the current topology epoch, 
heartbeats must not
+      // solicit another push.
+      val heartbeatAfterPush = streamsGroupHeartbeat(
+        groupId = groupId,
+        memberId = memberId,
+        memberEpoch = memberEpoch,
+        rebalanceTimeoutMs = 1000,
+        activeTasks = List.empty,
+        standbyTasks = List.empty,
+        warmupTasks = List.empty
+      )
+      assertFalse(heartbeatAfterPush.topologyDescriptionRequired(),
+        "Broker must not re-solicit a topology description push once it is 
stored at the current epoch.")
+    } finally {
+      admin.close()
+    }
+  }
+
+  @ClusterTest
+  def testStreamsGroupTopologyDescriptionUpdateValidationErrors(): Unit = {
+    val admin = cluster.admin()
+    val groupId = "test-group"
+    val memberId = "test-member"
+    val topicName = "test-topic"
+
+    try {
+      TestUtils.createOffsetsTopicWithAdmin(
+        admin = admin,
+        brokers = cluster.brokers.values().asScala.toSeq,
+        controllers = cluster.controllers().values().asScala.toSeq
+      )
+      TestUtils.createTopicWithAdmin(
+        admin = admin,
+        brokers = cluster.brokers.values().asScala.toSeq,
+        controllers = cluster.controllers().values().asScala.toSeq,
+        topic = topicName,
+        numPartitions = 3
+      )
+
+      // Join the group so the coordinator is loaded and the group exists.
+      TestUtils.waitUntilTrue(() => {
+        val response = streamsGroupHeartbeat(
+          groupId = groupId,
+          memberId = memberId,
+          rebalanceTimeoutMs = 1000,
+          activeTasks = List.empty,
+          standbyTasks = List.empty,
+          warmupTasks = List.empty,
+          topology = createMockTopology(topicName)
+        )
+        response.errorCode == Errors.NONE.code()
+      }, "StreamsGroupHeartbeatRequest did not succeed within the timeout 
period.")
+
+      // Empty group id.
+      var response = streamsGroupTopologyDescriptionUpdate(
+        groupId = "",
+        memberId = memberId,
+        topologyEpoch = topologyEpoch,
+        topologyDescription = createTopologyDescription(topicName)
+      )
+      assertEquals(Errors.INVALID_REQUEST.code(), response.errorCode())
+      assertEquals("GroupId can't be empty.", response.errorMessage())
+
+      // Empty member id.
+      response = streamsGroupTopologyDescriptionUpdate(
+        groupId = groupId,
+        memberId = "",
+        topologyEpoch = topologyEpoch,
+        topologyDescription = createTopologyDescription(topicName)
+      )
+      assertEquals(Errors.INVALID_REQUEST.code(), response.errorCode())
+      assertEquals("MemberId can't be empty.", response.errorMessage())
+
+      // Unknown group.
+      response = streamsGroupTopologyDescriptionUpdate(
+        groupId = "unknown-group",
+        memberId = memberId,
+        topologyEpoch = topologyEpoch,
+        topologyDescription = createTopologyDescription(topicName)
+      )
+      assertEquals(Errors.GROUP_ID_NOT_FOUND.code(), response.errorCode())
+
+      // Unknown member.
+      response = streamsGroupTopologyDescriptionUpdate(
+        groupId = groupId,
+        memberId = "unknown-member",
+        topologyEpoch = topologyEpoch,
+        topologyDescription = createTopologyDescription(topicName)
+      )
+      assertEquals(Errors.UNKNOWN_MEMBER_ID.code(), response.errorCode())
+
+      // Stale topology epoch.
+      response = streamsGroupTopologyDescriptionUpdate(
+        groupId = groupId,
+        memberId = memberId,
+        topologyEpoch = 42,
+        topologyDescription = createTopologyDescription(topicName)
+      )
+      assertEquals(Errors.INVALID_REQUEST.code(), response.errorCode())
+      assertEquals(s"Topology epoch 42 does not match the group's current 
topology epoch $topologyEpoch.", response.errorMessage())
+    } finally {
+      admin.close()
+    }
+  }
+
+  @ClusterTest
+  def testDeleteGroupWithStoredTopologyDescription(): Unit = {
+    val admin = cluster.admin()
+    val groupId = "test-group"
+    val memberId = "test-member"
+    val topicName = "test-topic"
+
+    try {
+      TestUtils.createOffsetsTopicWithAdmin(
+        admin = admin,
+        brokers = cluster.brokers.values().asScala.toSeq,
+        controllers = cluster.controllers().values().asScala.toSeq
+      )
+      TestUtils.createTopicWithAdmin(
+        admin = admin,
+        brokers = cluster.brokers.values().asScala.toSeq,
+        controllers = cluster.controllers().values().asScala.toSeq,
+        topic = topicName,
+        numPartitions = 3
+      )
+
+      // Join and push the topology description.
+      TestUtils.waitUntilTrue(() => {
+        val response = streamsGroupHeartbeat(
+          groupId = groupId,
+          memberId = memberId,
+          rebalanceTimeoutMs = 1000,
+          activeTasks = List.empty,
+          standbyTasks = List.empty,
+          warmupTasks = List.empty,
+          topology = createMockTopology(topicName)
+        )
+        response.errorCode == Errors.NONE.code()
+      }, "StreamsGroupHeartbeatRequest did not succeed within the timeout 
period.")
+
+      val updateResponse = streamsGroupTopologyDescriptionUpdate(
+        groupId = groupId,
+        memberId = memberId,
+        topologyEpoch = topologyEpoch,
+        topologyDescription = createTopologyDescription(topicName)
+      )
+      assertEquals(Errors.NONE.code(), updateResponse.errorCode(), 
s"Unexpected error: ${updateResponse.errorMessage()}")
+
+      val describedGroup = streamsGroupDescribe(
+        groupIds = List(groupId),
+        includeTopologyDescription = true
+      ).head
+      
assertEquals(StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_AVAILABLE,
 describedGroup.topologyDescriptionStatus())
+
+      // The member leaves so the group becomes empty and can be deleted.
+      val leaveResponse = streamsGroupHeartbeat(
+        groupId = groupId,
+        memberId = memberId,
+        memberEpoch = -1,
+        rebalanceTimeoutMs = 1000,
+        activeTasks = List.empty,
+        standbyTasks = List.empty,
+        warmupTasks = List.empty
+      )
+      assertEquals(Errors.NONE.code(), leaveResponse.errorCode())
+
+      // Deleting the group drives plugin.deleteTopology before the tombstone; 
a NONE
+      // result (rather than GROUP_DELETION_FAILED) shows the plugin delete 
succeeded.
+      deleteGroups(
+        groupIds = List(groupId),
+        expectedErrors = List(Errors.NONE),
+        version = ApiKeys.DELETE_GROUPS.latestVersion(isUnstableApiEnabled)
+      )
+
+      // The group is gone.
+      val describedAfterDelete = streamsGroupDescribe(
+        groupIds = List(groupId),
+        includeTopologyDescription = true
+      ).head
+      assertEquals(Errors.GROUP_ID_NOT_FOUND.code(), 
describedAfterDelete.errorCode())
+    } finally {
+      admin.close()
+    }
+  }
+
+  private def createMockTopology(topicName: String): 
StreamsGroupHeartbeatRequestData.Topology = {
+    new StreamsGroupHeartbeatRequestData.Topology()
+      .setEpoch(topologyEpoch)
+      .setSubtopologies(List(
+        new StreamsGroupHeartbeatRequestData.Subtopology()
+          .setSubtopologyId("subtopology-1")
+          .setSourceTopics(List(topicName).asJava)
+          .setRepartitionSinkTopics(List.empty.asJava)
+          .setRepartitionSourceTopics(List.empty.asJava)
+      ).asJava)
+  }
+
+  private def createTopologyDescription(topicName: String): 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription = {
+    new StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription()
+      .setSubtopologies(List(
+        new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionSubtopology()
+          .setSubtopologyId("subtopology-1")
+          .setNodes(List(
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode()
+              .setName("KSTREAM-SOURCE-0000000000")
+              .setNodeType(1.toByte) // SOURCE
+              .setSourceTopics(List(topicName).asJava)
+              .setStores(List.empty.asJava)
+              .setSuccessors(List.empty.asJava)
+          ).asJava)
+      ).asJava)
+      .setGlobalStores(List.empty.asJava)
+  }
+}
diff --git 
a/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/TopologyDescriptionNoPluginIntegrationTest.java
 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/TopologyDescriptionNoPluginIntegrationTest.java
new file mode 100644
index 00000000000..90e4d8500fd
--- /dev/null
+++ 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/TopologyDescriptionNoPluginIntegrationTest.java
@@ -0,0 +1,116 @@
+/*
+ * 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.streams.integration;
+
+import org.apache.kafka.clients.admin.Admin;
+import org.apache.kafka.clients.admin.AdminClientConfig;
+import org.apache.kafka.clients.admin.DescribeStreamsGroupsOptions;
+import org.apache.kafka.clients.admin.StreamsGroupDescription;
+import org.apache.kafka.clients.admin.StreamsGroupTopologyDescriptionStatus;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.coordinator.group.GroupCoordinatorConfig;
+import org.apache.kafka.streams.GroupProtocol;
+import org.apache.kafka.streams.KafkaStreams;
+import org.apache.kafka.streams.StreamsBuilder;
+import org.apache.kafka.streams.StreamsConfig;
+import org.apache.kafka.streams.Topology;
+import org.apache.kafka.streams.integration.utils.EmbeddedKafkaCluster;
+import org.apache.kafka.streams.kstream.Consumed;
+import org.apache.kafka.test.TestUtils;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Properties;
+
+import static 
org.apache.kafka.streams.integration.utils.IntegrationTestUtils.startApplicationAndWaitUntilRunning;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * End-to-end test of a real {@link KafkaStreams} client against a broker 
running the
+ * plugin-less production default (no topology description plugin configured, 
KIP-1331).
+ * {@link EmbeddedKafkaCluster} configures an in-memory plugin by default for 
all streams
+ * integration tests, so this test opts out explicitly to keep the production 
default
+ * exercised at the streams level.
+ */
+@Timeout(600)
+@Tag("integration")
+public class TopologyDescriptionNoPluginIntegrationTest {
+
+    private static EmbeddedKafkaCluster cluster;
+    private static String bootstrapServers;
+
+    @BeforeAll
+    public static void startCluster() throws IOException {
+        final Properties props = new Properties();
+        
props.put(GroupCoordinatorConfig.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_PLUGIN_CLASS_CONFIG,
+            EmbeddedKafkaCluster.NO_TOPOLOGY_DESCRIPTION_PLUGIN);
+        cluster = new EmbeddedKafkaCluster(1, props);
+        cluster.start();
+        bootstrapServers = cluster.bootstrapServers();
+    }
+
+    @AfterAll
+    public static void closeCluster() {
+        cluster.stop();
+        cluster = null;
+    }
+
+    @Test
+    public void 
shouldRunStreamsApplicationAgainstBrokerWithoutTopologyDescriptionPlugin() 
throws Exception {
+        final String appId = "topology-description-no-plugin-app";
+        final String inputTopic = "topology-description-no-plugin-input";
+        cluster.createTopic(inputTopic, 1, 1);
+
+        final Properties config = new Properties();
+        config.put(StreamsConfig.APPLICATION_ID_CONFIG, appId);
+        config.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
+        config.put(StreamsConfig.STATE_DIR_CONFIG, 
TestUtils.tempDirectory().getPath());
+        config.put(StreamsConfig.GROUP_PROTOCOL_CONFIG, 
GroupProtocol.STREAMS.name().toLowerCase(Locale.getDefault()));
+
+        final StreamsBuilder builder = new StreamsBuilder();
+        builder.stream(inputTopic, Consumed.with(Serdes.String(), 
Serdes.String()))
+            .foreach((key, value) -> { });
+        final Topology topology = builder.build();
+
+        try (final Admin admin = 
Admin.create(Map.of(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, 
bootstrapServers));
+             final KafkaStreams streams = new KafkaStreams(topology, config)) {
+            // Without a plugin the broker never solicits a topology 
description push; the
+            // application must join and run normally regardless.
+            startApplicationAndWaitUntilRunning(streams);
+
+            final StreamsGroupDescription description = 
admin.describeStreamsGroups(
+                    List.of(appId),
+                    new 
DescribeStreamsGroupsOptions().includeTopologyDescription(true))
+                .describedGroups()
+                .get(appId)
+                .get();
+            assertFalse(description.members().isEmpty());
+            assertEquals(StreamsGroupTopologyDescriptionStatus.NOT_STORED, 
description.topologyDescriptionStatus());
+            assertTrue(description.topologyDescription().isEmpty());
+        }
+    }
+}
diff --git 
a/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/TopologyDescriptionPluginExpirationIntegrationTest.java
 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/TopologyDescriptionPluginExpirationIntegrationTest.java
new file mode 100644
index 00000000000..06762f1c3fa
--- /dev/null
+++ 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/TopologyDescriptionPluginExpirationIntegrationTest.java
@@ -0,0 +1,189 @@
+/*
+ * 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.streams.integration;
+
+import org.apache.kafka.clients.admin.Admin;
+import org.apache.kafka.clients.admin.AdminClientConfig;
+import org.apache.kafka.clients.admin.DescribeStreamsGroupsOptions;
+import org.apache.kafka.clients.admin.StreamsGroupDescription;
+import org.apache.kafka.clients.admin.StreamsGroupTopologyDescriptionStatus;
+import org.apache.kafka.common.errors.GroupIdNotFoundException;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.coordinator.group.GroupCoordinatorConfig;
+import org.apache.kafka.streams.GroupProtocol;
+import org.apache.kafka.streams.KafkaStreams;
+import org.apache.kafka.streams.StreamsBuilder;
+import org.apache.kafka.streams.StreamsConfig;
+import org.apache.kafka.streams.Topology;
+import org.apache.kafka.streams.integration.utils.EmbeddedKafkaCluster;
+import 
org.apache.kafka.streams.integration.utils.TrackingTopologyDescriptionPlugin;
+import org.apache.kafka.streams.kstream.Consumed;
+import org.apache.kafka.test.TestUtils;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.ExecutionException;
+
+import static 
org.apache.kafka.streams.integration.utils.IntegrationTestUtils.startApplicationAndWaitUntilRunning;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Tests of the topology description plugin behavior when a streams group 
expires naturally
+ * (KIP-1331): the periodic broker-side cleanup cycle calls {@code 
plugin.deleteTopology} and
+ * clears the stored topology epoch for empty groups without offsets, after 
which the regular
+ * expiration sweep tombstones the group. Groups whose push permanently failed 
have no stored
+ * plugin state, so they are tombstoned directly without a {@code 
deleteTopology} call.
+ *
+ * <p>The tests avoid producing any input data, so the groups never commit 
offsets and become
+ * eligible for expiration as soon as their last member leaves.
+ */
+@Timeout(600)
+@Tag("integration")
+public class TopologyDescriptionPluginExpirationIntegrationTest {
+
+    private static EmbeddedKafkaCluster cluster;
+    private static String bootstrapServers;
+
+    @BeforeAll
+    public static void startCluster() throws IOException {
+        final Properties props = new Properties();
+        props.put(
+            
GroupCoordinatorConfig.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_PLUGIN_CLASS_CONFIG,
+            TrackingTopologyDescriptionPlugin.class.getName());
+        // Run the offset-expiration sweep and the topology-description 
cleanup cycle (both
+        // are scheduled at this interval) frequently so expiry happens within 
seconds.
+        
props.put(GroupCoordinatorConfig.OFFSETS_RETENTION_CHECK_INTERVAL_MS_CONFIG, 
"1000");
+        
props.put(GroupCoordinatorConfig.STREAMS_GROUP_HEARTBEAT_INTERVAL_MS_CONFIG, 
"200");
+        cluster = new EmbeddedKafkaCluster(1, props);
+        cluster.start();
+        bootstrapServers = cluster.bootstrapServers();
+    }
+
+    @AfterAll
+    public static void closeCluster() {
+        cluster.stop();
+        cluster = null;
+    }
+
+    @BeforeEach
+    public void resetPlugin() {
+        TrackingTopologyDescriptionPlugin.reset();
+    }
+
+    @Test
+    public void shouldDeleteStoredTopologyDescriptionWhenGroupExpires() throws 
Exception {
+        final String appId = "topology-description-expiration-app";
+        final String inputTopic = "topology-description-expiration-input";
+        cluster.createTopic(inputTopic, 1, 1);
+
+        try (final Admin admin = createAdmin()) {
+            try (final KafkaStreams streams = new 
KafkaStreams(topology(inputTopic), streamsConfig(appId))) {
+                startApplicationAndWaitUntilRunning(streams);
+                TestUtils.waitForCondition(
+                    () -> describeGroup(admin, 
appId).topologyDescriptionStatus() == 
StreamsGroupTopologyDescriptionStatus.AVAILABLE,
+                    "Expected the topology description of group " + appId + " 
to become AVAILABLE");
+            }
+
+            // The member has left and the group has no committed offsets, so 
the cleanup
+            // cycle must call plugin.deleteTopology and clear the stored 
epoch, after which
+            // the expiration sweep tombstones the group.
+            TestUtils.waitForCondition(
+                () -> 
TrackingTopologyDescriptionPlugin.deleteTopologyCalls(appId) >= 1,
+                "Expected the cleanup cycle to invoke deleteTopology for group 
" + appId);
+            waitForGroupToBeDeleted(admin, appId);
+        }
+    }
+
+    @Test
+    public void 
shouldExpireGroupWithoutDeleteTopologyWhenPushPermanentlyFailed() throws 
Exception {
+        final String appId = "topology-description-failed-expiration-app";
+        final String inputTopic = 
"topology-description-failed-expiration-input";
+        cluster.createTopic(inputTopic, 1, 1);
+        TrackingTopologyDescriptionPlugin.failPermanently(appId);
+
+        try (final Admin admin = createAdmin()) {
+            try (final KafkaStreams streams = new 
KafkaStreams(topology(inputTopic), streamsConfig(appId))) {
+                startApplicationAndWaitUntilRunning(streams);
+                TestUtils.waitForCondition(
+                    () -> 
TrackingTopologyDescriptionPlugin.setTopologyCalls(appId) >= 1,
+                    "Expected a setTopology call for group " + appId);
+            }
+
+            // The push permanently failed, so no topology description is 
stored for the
+            // group: the tombstone fires directly, without any deleteTopology 
call.
+            waitForGroupToBeDeleted(admin, appId);
+            assertEquals(0, 
TrackingTopologyDescriptionPlugin.deleteTopologyCalls(appId),
+                "Expected no deleteTopology call for group " + appId + " whose 
push permanently failed");
+        }
+    }
+
+    private static Topology topology(final String inputTopic) {
+        final StreamsBuilder builder = new StreamsBuilder();
+        builder.stream(inputTopic, Consumed.with(Serdes.String(), 
Serdes.String()))
+            .foreach((key, value) -> { });
+        return builder.build();
+    }
+
+    private static Properties streamsConfig(final String appId) {
+        final Properties config = new Properties();
+        config.put(StreamsConfig.APPLICATION_ID_CONFIG, appId);
+        config.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
+        config.put(StreamsConfig.STATE_DIR_CONFIG, 
TestUtils.tempDirectory().getPath());
+        config.put(StreamsConfig.GROUP_PROTOCOL_CONFIG, 
GroupProtocol.STREAMS.name().toLowerCase(Locale.getDefault()));
+        return config;
+    }
+
+    private static Admin createAdmin() {
+        return Admin.create(Map.of(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, 
bootstrapServers));
+    }
+
+    private static StreamsGroupDescription describeGroup(final Admin admin, 
final String groupId) throws Exception {
+        return admin.describeStreamsGroups(
+                List.of(groupId),
+                new 
DescribeStreamsGroupsOptions().includeTopologyDescription(true))
+            .describedGroups()
+            .get(groupId)
+            .get();
+    }
+
+    private static void waitForGroupToBeDeleted(final Admin admin, final 
String groupId) throws InterruptedException {
+        TestUtils.waitForCondition(() -> {
+            try {
+                describeGroup(admin, groupId);
+                return false;
+            } catch (final ExecutionException exception) {
+                if (exception.getCause() instanceof GroupIdNotFoundException) {
+                    return true;
+                }
+                // Unexpected failures (e.g. admin timeouts) must not be 
folded into
+                // "group not yet deleted": waitForCondition retries a 
throwing condition
+                // and rethrows the last exception at timeout, preserving the 
diagnostics.
+                throw exception;
+            }
+        }, "Expected group " + groupId + " to be tombstoned by the expiration 
sweep");
+    }
+}
diff --git 
a/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/TopologyDescriptionPluginIntegrationTest.java
 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/TopologyDescriptionPluginIntegrationTest.java
new file mode 100644
index 00000000000..900117f1ff1
--- /dev/null
+++ 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/TopologyDescriptionPluginIntegrationTest.java
@@ -0,0 +1,261 @@
+/*
+ * 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.streams.integration;
+
+import org.apache.kafka.clients.admin.Admin;
+import org.apache.kafka.clients.admin.AdminClientConfig;
+import org.apache.kafka.clients.admin.DescribeStreamsGroupsOptions;
+import org.apache.kafka.clients.admin.StreamsGroupDescription;
+import org.apache.kafka.clients.admin.StreamsGroupTopologyDescription;
+import org.apache.kafka.clients.admin.StreamsGroupTopologyDescriptionStatus;
+import org.apache.kafka.common.errors.GroupIdNotFoundException;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.coordinator.group.GroupCoordinatorConfig;
+import org.apache.kafka.streams.GroupProtocol;
+import org.apache.kafka.streams.KafkaStreams;
+import org.apache.kafka.streams.StreamsBuilder;
+import org.apache.kafka.streams.StreamsConfig;
+import org.apache.kafka.streams.Topology;
+import org.apache.kafka.streams.integration.utils.EmbeddedKafkaCluster;
+import 
org.apache.kafka.streams.integration.utils.TrackingTopologyDescriptionPlugin;
+import org.apache.kafka.streams.kstream.Consumed;
+import org.apache.kafka.test.TestUtils;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.ExecutionException;
+
+import static 
org.apache.kafka.streams.integration.utils.IntegrationTestUtils.startApplicationAndWaitUntilRunning;
+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;
+
+/**
+ * End-to-end tests of the topology description push lifecycle (KIP-1331) on a 
live cluster:
+ * a real {@link KafkaStreams} client pushes its topology description when the 
broker solicits
+ * it on a heartbeat, and the broker stores it via the configured
+ * {@link TrackingTopologyDescriptionPlugin}.
+ */
+@Timeout(600)
+@Tag("integration")
+public class TopologyDescriptionPluginIntegrationTest {
+
+    private static EmbeddedKafkaCluster cluster;
+    private static String bootstrapServers;
+
+    @BeforeAll
+    public static void startCluster() throws IOException {
+        final Properties props = new Properties();
+        props.put(
+            
GroupCoordinatorConfig.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_PLUGIN_CLASS_CONFIG,
+            TrackingTopologyDescriptionPlugin.class.getName());
+        // Frequent heartbeats keep the solicitation round-trips short.
+        
props.put(GroupCoordinatorConfig.STREAMS_GROUP_HEARTBEAT_INTERVAL_MS_CONFIG, 
"200");
+        // Effectively disable the periodic cleanup cycle and expiration sweep 
(both run at
+        // this interval, 10 min by default): 
shouldInvokeDeleteTopologyOnDeleteGroups
+        // relies on the explicit DeleteGroups call being the only delete 
path, and a slow
+        // run crossing the default interval could otherwise expire the empty, 
offset-less
+        // group first.
+        
props.put(GroupCoordinatorConfig.OFFSETS_RETENTION_CHECK_INTERVAL_MS_CONFIG, 
String.valueOf(Integer.MAX_VALUE));
+        cluster = new EmbeddedKafkaCluster(1, props);
+        cluster.start();
+        bootstrapServers = cluster.bootstrapServers();
+    }
+
+    @AfterAll
+    public static void closeCluster() {
+        cluster.stop();
+        cluster = null;
+    }
+
+    @BeforeEach
+    public void resetPlugin() {
+        TrackingTopologyDescriptionPlugin.reset();
+    }
+
+    @Test
+    public void shouldPushTopologyDescriptionToPluginAfterJoin() throws 
Exception {
+        final String appId = "topology-description-push-app";
+        final String inputTopic = "topology-description-push-input";
+        cluster.createTopic(inputTopic, 1, 1);
+
+        try (final Admin admin = createAdmin();
+             final KafkaStreams streams = new 
KafkaStreams(topology(inputTopic), streamsConfig(appId))) {
+            startApplicationAndWaitUntilRunning(streams);
+
+            waitForTopologyDescriptionAvailable(admin, appId);
+            
assertTrue(TrackingTopologyDescriptionPlugin.setTopologyCalls(appId) >= 1,
+                "Expected at least one setTopology call for group " + appId);
+
+            // The stored description carries the source topic.
+            final StreamsGroupTopologyDescription topologyDescription =
+                describeGroup(admin, appId, 
true).topologyDescription().orElseThrow();
+            assertEquals(1, topologyDescription.subtopologies().size());
+            final boolean sourceTopicFound = 
topologyDescription.subtopologies().stream()
+                .flatMap(subtopology -> subtopology.nodes().stream())
+                
.filter(StreamsGroupTopologyDescription.Source.class::isInstance)
+                .map(StreamsGroupTopologyDescription.Source.class::cast)
+                .anyMatch(source -> source.topics().contains(inputTopic));
+            assertTrue(sourceTopicFound, "Expected a source node reading from 
" + inputTopic);
+
+            // Describing without requesting the topology description must not 
attach one.
+            final StreamsGroupDescription withoutTopology = 
describeGroup(admin, appId, false);
+            assertEquals(StreamsGroupTopologyDescriptionStatus.NOT_REQUESTED, 
withoutTopology.topologyDescriptionStatus());
+            assertTrue(withoutTopology.topologyDescription().isEmpty());
+        }
+    }
+
+    @Test
+    public void shouldPushTopologyDescriptionOnlyOnceForTwoMembers() throws 
Exception {
+        final String appId = "topology-description-dedup-app";
+        final String inputTopic = "topology-description-dedup-input";
+        cluster.createTopic(inputTopic, 2, 1);
+
+        try (final Admin admin = createAdmin();
+             final KafkaStreams streams1 = new 
KafkaStreams(topology(inputTopic), streamsConfig(appId));
+             final KafkaStreams streams2 = new 
KafkaStreams(topology(inputTopic), streamsConfig(appId))) {
+            startApplicationAndWaitUntilRunning(List.of(streams1, streams2));
+
+            waitForTopologyDescriptionAvailable(admin, appId);
+            TestUtils.waitForCondition(
+                () -> describeGroup(admin, appId, false).members().size() == 2,
+                "Expected two members in group " + appId);
+
+            // The broker solicits the push from a single member at a time and 
stops
+            // soliciting once the description is stored, so a two-member 
group must
+            // trigger exactly one setTopology call. Accepted flake risk: the 
client
+            // retries the push on a retriable send failure (e.g. a disconnect 
after the
+            // broker already processed the request), which would produce a 
duplicate
+            // setTopology call at the same epoch — client-retry noise, not a 
dedup
+            // regression. If this assertion ever flakes, that is why.
+            assertEquals(1, 
TrackingTopologyDescriptionPlugin.setTopologyCalls(appId),
+                "Expected exactly one setTopology call for group " + appId);
+        }
+    }
+
+    @Test
+    public void shouldStopSolicitingPushAfterPermanentPluginFailure() throws 
Exception {
+        final String appId = "topology-description-failure-app";
+        final String inputTopic = "topology-description-failure-input";
+        cluster.createTopic(inputTopic, 1, 1);
+        TrackingTopologyDescriptionPlugin.failPermanently(appId);
+
+        try (final Admin admin = createAdmin();
+             final KafkaStreams streams = new 
KafkaStreams(topology(inputTopic), streamsConfig(appId))) {
+            startApplicationAndWaitUntilRunning(streams);
+
+            TestUtils.waitForCondition(
+                () -> 
TrackingTopologyDescriptionPlugin.setTopologyCalls(appId) >= 1,
+                "Expected a setTopology call for group " + appId);
+
+            // The permanent failure ratchets the group's failed topology 
epoch, so the broker
+            // must not re-solicit the push: the call count stays at one 
across many heartbeat
+            // intervals, the application keeps running, and describe reports 
NOT_STORED.
+            // Note: this window is shorter than the 30s initial 
re-solicitation back-off, so
+            // it cannot distinguish the permanent-failure ratchet from an 
armed back-off (a
+            // permanent failure misclassified as transient would also stay at 
one call here);
+            // that classification is pinned down at the unit level by
+            // GroupCoordinatorServiceTopologyDescriptionTest. What this does 
catch is the
+            // ratchet being lost entirely, since re-solicitation would then 
happen within a
+            // couple of heartbeats.
+            Thread.sleep(2000);
+            assertEquals(1, 
TrackingTopologyDescriptionPlugin.setTopologyCalls(appId),
+                "Expected no re-solicitation after a permanent plugin failure 
for group " + appId);
+            assertEquals(KafkaStreams.State.RUNNING, streams.state());
+            assertEquals(StreamsGroupTopologyDescriptionStatus.NOT_STORED,
+                describeGroup(admin, appId, true).topologyDescriptionStatus());
+        }
+    }
+
+    @Test
+    public void shouldInvokeDeleteTopologyOnDeleteGroups() throws Exception {
+        final String appId = "topology-description-delete-app";
+        final String inputTopic = "topology-description-delete-input";
+        cluster.createTopic(inputTopic, 1, 1);
+
+        try (final Admin admin = createAdmin()) {
+            try (final KafkaStreams streams = new 
KafkaStreams(topology(inputTopic), streamsConfig(appId))) {
+                startApplicationAndWaitUntilRunning(streams);
+                waitForTopologyDescriptionAvailable(admin, appId);
+            }
+
+            // The application has left the group; once it is empty it can be 
deleted.
+            TestUtils.waitForCondition(
+                () -> describeGroup(admin, appId, false).members().isEmpty(),
+                "Expected group " + appId + " to become empty");
+            assertEquals(0, 
TrackingTopologyDescriptionPlugin.deleteTopologyCalls(appId));
+
+            admin.deleteStreamsGroups(List.of(appId)).all().get();
+
+            
assertTrue(TrackingTopologyDescriptionPlugin.deleteTopologyCalls(appId) >= 1,
+                "Expected DeleteGroups to invoke deleteTopology for group " + 
appId);
+            final ExecutionException exception = 
assertThrows(ExecutionException.class,
+                () -> admin.describeStreamsGroups(List.of(appId)).all().get());
+            assertInstanceOf(GroupIdNotFoundException.class, 
exception.getCause());
+        }
+    }
+
+    private static Topology topology(final String inputTopic) {
+        final StreamsBuilder builder = new StreamsBuilder();
+        builder.stream(inputTopic, Consumed.with(Serdes.String(), 
Serdes.String()))
+            .foreach((key, value) -> { });
+        return builder.build();
+    }
+
+    private static Properties streamsConfig(final String appId) {
+        final Properties config = new Properties();
+        config.put(StreamsConfig.APPLICATION_ID_CONFIG, appId);
+        config.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
+        config.put(StreamsConfig.STATE_DIR_CONFIG, 
TestUtils.tempDirectory().getPath());
+        config.put(StreamsConfig.GROUP_PROTOCOL_CONFIG, 
GroupProtocol.STREAMS.name().toLowerCase(Locale.getDefault()));
+        return config;
+    }
+
+    private static Admin createAdmin() {
+        return Admin.create(Map.of(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, 
bootstrapServers));
+    }
+
+    private static StreamsGroupDescription describeGroup(
+        final Admin admin,
+        final String groupId,
+        final boolean includeTopologyDescription
+    ) throws Exception {
+        return admin.describeStreamsGroups(
+                List.of(groupId),
+                new 
DescribeStreamsGroupsOptions().includeTopologyDescription(includeTopologyDescription))
+            .describedGroups()
+            .get(groupId)
+            .get();
+    }
+
+    private static void waitForTopologyDescriptionAvailable(final Admin admin, 
final String groupId) throws InterruptedException {
+        TestUtils.waitForCondition(
+            () -> describeGroup(admin, groupId, 
true).topologyDescriptionStatus() == 
StreamsGroupTopologyDescriptionStatus.AVAILABLE,
+            "Expected the topology description of group " + groupId + " to 
become AVAILABLE");
+    }
+}
diff --git 
a/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/utils/EmbeddedKafkaCluster.java
 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/utils/EmbeddedKafkaCluster.java
index ba3451589f0..f2b34ec14d9 100644
--- 
a/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/utils/EmbeddedKafkaCluster.java
+++ 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/utils/EmbeddedKafkaCluster.java
@@ -48,6 +48,7 @@ import 
org.apache.kafka.coordinator.transaction.TransactionLogConfig;
 import org.apache.kafka.network.SocketServerConfigs;
 import org.apache.kafka.server.config.ServerConfigs;
 import org.apache.kafka.server.config.ServerLogConfigs;
+import org.apache.kafka.server.streams.InMemoryTopologyDescriptionPlugin;
 import org.apache.kafka.server.util.MockTime;
 import org.apache.kafka.storage.internals.log.CleanerConfig;
 import org.apache.kafka.test.TestCondition;
@@ -89,6 +90,17 @@ import static 
org.apache.kafka.common.utils.Utils.mkProperties;
 public class EmbeddedKafkaCluster {
 
     private static final Logger log = 
LoggerFactory.getLogger(EmbeddedKafkaCluster.class);
+
+    /**
+     * Value for {@link 
GroupCoordinatorConfig#STREAMS_GROUP_TOPOLOGY_DESCRIPTION_PLUGIN_CLASS_CONFIG}
+     * that opts a test cluster out of the {@link 
InMemoryTopologyDescriptionPlugin} configured by
+     * default, running the broker with the plugin-less production default. 
Needed because the
+     * config's default is null, but a {@link Properties} cannot carry null 
and an empty class name
+     * would fail config parsing, so {@link #addDefaultBrokerPropsIfAbsent} 
removes the property
+     * when it is set to this value.
+     */
+    public static final String NO_TOPOLOGY_DESCRIPTION_PLUGIN = "";
+
     private final KafkaClusterTestKit cluster;
     private final Properties brokerConfig;
     public final MockTime time;
@@ -401,6 +413,10 @@ public class EmbeddedKafkaCluster {
 
     private void addDefaultBrokerPropsIfAbsent(final Properties brokerConfig) {
         
brokerConfig.putIfAbsent(CleanerConfig.LOG_CLEANER_DEDUPE_BUFFER_SIZE_PROP, 2 * 
1024 * 1024L);
+        
brokerConfig.putIfAbsent(GroupCoordinatorConfig.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_PLUGIN_CLASS_CONFIG,
 InMemoryTopologyDescriptionPlugin.class.getName());
+        if 
(NO_TOPOLOGY_DESCRIPTION_PLUGIN.equals(brokerConfig.get(GroupCoordinatorConfig.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_PLUGIN_CLASS_CONFIG)))
 {
+            
brokerConfig.remove(GroupCoordinatorConfig.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_PLUGIN_CLASS_CONFIG);
+        }
         
brokerConfig.putIfAbsent(GroupCoordinatorConfig.STREAMS_GROUP_MIN_SESSION_TIMEOUT_MS_CONFIG,
 "100");
         
brokerConfig.putIfAbsent(GroupCoordinatorConfig.STREAMS_GROUP_MIN_HEARTBEAT_INTERVAL_MS_CONFIG,
 "100");
         
brokerConfig.putIfAbsent(GroupCoordinatorConfig.GROUP_MIN_SESSION_TIMEOUT_MS_CONFIG,
 "0");
diff --git 
a/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/utils/TrackingTopologyDescriptionPlugin.java
 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/utils/TrackingTopologyDescriptionPlugin.java
new file mode 100644
index 00000000000..e6563c18ec2
--- /dev/null
+++ 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/utils/TrackingTopologyDescriptionPlugin.java
@@ -0,0 +1,82 @@
+/*
+ * 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.streams.integration.utils;
+
+import 
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescription;
+import 
org.apache.kafka.coordinator.group.api.streams.StreamsTopologyDescriptionPermanentFailureException;
+import org.apache.kafka.server.streams.InMemoryTopologyDescriptionPlugin;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * An {@link InMemoryTopologyDescriptionPlugin} for integration tests that 
records every
+ * {@code setTopology} / {@code deleteTopology} invocation per group and can 
be told to
+ * reject pushes for selected groups with a permanent failure.
+ *
+ * <p>The brokers of an embedded cluster run in the test JVM, so the recorded 
state is kept
+ * in static fields the test can inspect directly. Call {@link #reset()} 
between tests.
+ */
+public class TrackingTopologyDescriptionPlugin extends 
InMemoryTopologyDescriptionPlugin {
+
+    private static final Map<String, AtomicInteger> SET_TOPOLOGY_CALLS = new 
ConcurrentHashMap<>();
+    private static final Map<String, AtomicInteger> DELETE_TOPOLOGY_CALLS = 
new ConcurrentHashMap<>();
+    private static final Set<String> FAIL_PERMANENTLY_GROUPS = 
ConcurrentHashMap.newKeySet();
+
+    public static void reset() {
+        SET_TOPOLOGY_CALLS.clear();
+        DELETE_TOPOLOGY_CALLS.clear();
+        FAIL_PERMANENTLY_GROUPS.clear();
+    }
+
+    /**
+     * Make every subsequent {@code setTopology} for this group fail with a
+     * {@link StreamsTopologyDescriptionPermanentFailureException}.
+     */
+    public static void failPermanently(final String groupId) {
+        FAIL_PERMANENTLY_GROUPS.add(groupId);
+    }
+
+    public static int setTopologyCalls(final String groupId) {
+        final AtomicInteger calls = SET_TOPOLOGY_CALLS.get(groupId);
+        return calls == null ? 0 : calls.get();
+    }
+
+    public static int deleteTopologyCalls(final String groupId) {
+        final AtomicInteger calls = DELETE_TOPOLOGY_CALLS.get(groupId);
+        return calls == null ? 0 : calls.get();
+    }
+
+    @Override
+    public CompletableFuture<Void> setTopology(final String groupId, final int 
topologyEpoch, final StreamsGroupTopologyDescription description) {
+        SET_TOPOLOGY_CALLS.computeIfAbsent(groupId, id -> new 
AtomicInteger()).incrementAndGet();
+        if (FAIL_PERMANENTLY_GROUPS.contains(groupId)) {
+            return CompletableFuture.failedFuture(
+                new 
StreamsTopologyDescriptionPermanentFailureException("Topology description 
rejected by test plugin."));
+        }
+        return super.setTopology(groupId, topologyEpoch, description);
+    }
+
+    @Override
+    public CompletableFuture<Void> deleteTopology(final String groupId) {
+        DELETE_TOPOLOGY_CALLS.computeIfAbsent(groupId, id -> new 
AtomicInteger()).incrementAndGet();
+        return super.deleteTopology(groupId);
+    }
+}
diff --git 
a/tools/src/test/java/org/apache/kafka/tools/streams/DescribeStreamsGroupTest.java
 
b/tools/src/test/java/org/apache/kafka/tools/streams/DescribeStreamsGroupTest.java
index 0e62f2d7acb..9a4a16a9a20 100644
--- 
a/tools/src/test/java/org/apache/kafka/tools/streams/DescribeStreamsGroupTest.java
+++ 
b/tools/src/test/java/org/apache/kafka/tools/streams/DescribeStreamsGroupTest.java
@@ -20,6 +20,7 @@ import org.apache.kafka.clients.admin.AdminClientConfig;
 import org.apache.kafka.clients.consumer.ConsumerConfig;
 import org.apache.kafka.common.serialization.Serdes;
 import org.apache.kafka.common.utils.internals.Exit;
+import org.apache.kafka.coordinator.group.GroupCoordinatorConfig;
 import org.apache.kafka.streams.GroupProtocol;
 import org.apache.kafka.streams.KafkaStreams;
 import org.apache.kafka.streams.StreamsBuilder;
@@ -75,6 +76,12 @@ public class DescribeStreamsGroupTest {
     public static void setup() throws Exception {
         // start the cluster and create the input topic
         final Properties props = new Properties();
+        // Configure the topology description plugin explicitly rather than 
relying on
+        // EmbeddedKafkaCluster's default: 
testDescribeStreamsGroupWithTopologyOption
+        // depends on it, and without a plugin the --topology option would 
time out
+        // with nothing stored on the broker.
+        
props.put(GroupCoordinatorConfig.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_PLUGIN_CLASS_CONFIG,
+            
"org.apache.kafka.server.streams.InMemoryTopologyDescriptionPlugin");
         cluster = new EmbeddedKafkaCluster(1, props);
         cluster.start();
         cluster.createTopic(INPUT_TOPIC, 2, 1);
@@ -247,6 +254,25 @@ public class DescribeStreamsGroupTest {
         }
     }
 
+    @Test
+    public void testDescribeStreamsGroupWithTopologyOption() throws Exception {
+        final List<String> args = List.of("--bootstrap-server", 
bootstrapServers, "--describe", "--topology", "--group", APP_ID);
+        final AtomicReference<String> out = new AtomicReference<>("");
+        // The streams client pushes the topology description when the broker 
solicits it on
+        // a heartbeat, so retry until the broker-side plugin has it stored. 
Until then the
+        // command reports that no description is stored and exits non-zero, 
so the exit code
+        // is not asserted inside the retry loop. The solicit -> push -> store 
round trip can
+        // take a couple of heartbeat intervals (5s each on this cluster), so 
allow a timeout
+        // well above that.
+        TestUtils.waitForCondition(() -> {
+            String output = ToolsTestUtils.grabConsoleOutput(() -> 
StreamsGroupCommand.execute(args.toArray(new String[0])));
+            out.set(output);
+            return output.contains("Topologies:") && 
output.contains(INPUT_TOPIC);
+        }, 60_000L, () -> String.format("Expected the topology description 
with source topic %s, but found:%n%s", INPUT_TOPIC, out.get()));
+
+        assertTrue(out.get().contains("Sub-topology: 0"), "Expected a 
sub-topology in the output, but found:\n" + out.get());
+    }
+
     @Test
     public void testDescribeNonExistingStreamsGroup() {
         final String nonExistingGroup = "non-existing-group";

Reply via email to