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 402087dc42e KAFKA-20626: Add topology description to Admin client,
DescribeStreamsGroups handler, and CLI tools (#22636)
402087dc42e is described below
commit 402087dc42e8ec06751e9bbc312f4bf029db0488
Author: Alieh Saeedi <[email protected]>
AuthorDate: Tue Jun 23 21:02:56 2026 +0200
KAFKA-20626: Add topology description to Admin client,
DescribeStreamsGroups handler, and CLI tools (#22636)
## Summary
Implements the client-side surface of
[KIP-1331](https://cwiki.apache.org/confluence/display/KAFKA/KIP-1331%3A+Streams+Group+Topology+Description+Plugin)
([KAFKA-20626](https://issues.apache.org/jira/browse/KAFKA-20626)), on
top of the already-defined `StreamsGroupDescribe` v1 schema
(`IncludeTopologyDescription` / `TopologyDescription` /
`TopologyDescriptionStatus`).
### Admin client
- **`StreamsGroupTopologyDescription`** — admin POJO mirroring
`org.apache.kafka.streams.TopologyDescription` (Subtopology,
Source/Processor/Sink nodes, GlobalStore) but string-based, so callers
don't depend on `kafka-streams`. The wire format only carries
successors; **predecessor edges are reconstructed** from the successor
lists when the description is built.
- **`StreamsGroupTopologyDescriptionStatus`** — enum
`NOT_REQUESTED(0)`/`NOT_STORED(1)`/`ERROR(2)`/`AVAILABLE(3)` with
`id()`/`forId()`.
- Extended **`StreamsGroupDescription`** with `topologyDescription()`
(`Optional`) and `topologyDescriptionStatus()`. The new fields are added
via an additive constructor; the existing constructor defaults to
`NOT_REQUESTED`/empty, preserving backward compatibility.
- Extended **`DescribeStreamsGroupsOptions`** with
`includeTopologyDescription(boolean)`.
- **`DescribeStreamsGroupsHandler`** sets the request flag, parses the
topology (including the global-store source→processor pair), and
populates the description only when the status is `AVAILABLE`.
`KafkaAdminClient` passes the option through.
- Implemented **`MockAdminClient.describeStreamsGroups`** (with
`addStreamsGroupDescription`).
### CLI (`kafka-streams-groups.sh`)
- New `--describe --topology` sub-action (mutually exclusive with
`--members`/`--offsets`/`--state`).
- **`TopologyDescriptionFormatter`** renders output mirroring
`Topology#describe()`, with sorted node/topic/store sets for stable
output.
- Exit code `0` for `AVAILABLE`, `1` for `NOT_STORED`/`ERROR`.
### Tests & docs
- `DescribeStreamsGroupsHandlerTest`,
`TopologyDescriptionFormatterTest`, and CLI coverage in
`StreamsGroupCommandTest` (available → exit 0 + correct output + option
captured; not-stored → exit 1).
- Documented the new option in `kafka-streams-group-sh.md`.
### Note
`StreamsGroupDescribeRequest.json` v1 remains `latestVersionUnstable:
true` (per its own comment, it flips when the broker
topology-description manager lands). Until then, `--topology` against a
live cluster surfaces `UnsupportedVersionException`, which is the KIP's
documented compatibility behavior. Unit tests construct the
request/response directly and are unaffected.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewers: Lucas Brutschy <[email protected]>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
.../admin/DescribeStreamsGroupsOptions.java | 15 +
.../kafka/clients/admin/KafkaAdminClient.java | 2 +-
.../clients/admin/StreamsGroupDescription.java | 59 ++-
.../admin/StreamsGroupTopologyDescription.java | 422 +++++++++++++++++++++
.../StreamsGroupTopologyDescriptionStatus.java | 82 ++++
.../internals/DescribeStreamsGroupsHandler.java | 119 +++++-
.../kafka/clients/admin/MockAdminClientTest.java | 132 +++++++
.../DescribeStreamsGroupsHandlerTest.java | 311 +++++++++++++++
.../kafka/clients/admin/MockAdminClient.java | 43 ++-
.../developer-guide/kafka-streams-group-sh.md | 7 +-
.../kafka/tools/streams/StreamsGroupCommand.java | 49 ++-
.../tools/streams/StreamsGroupCommandOptions.java | 9 +-
.../streams/TopologyDescriptionFormatter.java | 108 ++++++
.../tools/streams/StreamsGroupCommandTest.java | 62 +++
.../streams/TopologyDescriptionFormatterTest.java | 167 ++++++++
15 files changed, 1570 insertions(+), 17 deletions(-)
diff --git
a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeStreamsGroupsOptions.java
b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeStreamsGroupsOptions.java
index 88e6d097768..65f11f8c362 100644
---
a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeStreamsGroupsOptions.java
+++
b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeStreamsGroupsOptions.java
@@ -29,6 +29,7 @@ import java.util.Collection;
@InterfaceStability.Evolving
public class DescribeStreamsGroupsOptions extends
AbstractOptions<DescribeStreamsGroupsOptions> {
private boolean includeAuthorizedOperations;
+ private boolean includeTopologyDescription;
public DescribeStreamsGroupsOptions includeAuthorizedOperations(boolean
includeAuthorizedOperations) {
this.includeAuthorizedOperations = includeAuthorizedOperations;
@@ -38,4 +39,18 @@ public class DescribeStreamsGroupsOptions extends
AbstractOptions<DescribeStream
public boolean includeAuthorizedOperations() {
return includeAuthorizedOperations;
}
+
+ /**
+ * Whether to include the full topology description, as recorded by the
broker's topology description plugin, in the
+ * response. Requesting a topology description against a broker that does
not support it fails with
+ * {@link org.apache.kafka.common.errors.UnsupportedVersionException}.
+ */
+ public DescribeStreamsGroupsOptions includeTopologyDescription(boolean
includeTopologyDescription) {
+ this.includeTopologyDescription = includeTopologyDescription;
+ return this;
+ }
+
+ public boolean includeTopologyDescription() {
+ return includeTopologyDescription;
+ }
}
\ No newline at end of file
diff --git
a/clients/src/main/java/org/apache/kafka/clients/admin/KafkaAdminClient.java
b/clients/src/main/java/org/apache/kafka/clients/admin/KafkaAdminClient.java
index a02b4e7bcf2..5e2924e9ce9 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/KafkaAdminClient.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/KafkaAdminClient.java
@@ -3856,7 +3856,7 @@ public class KafkaAdminClient extends AdminClient {
final
DescribeStreamsGroupsOptions options) {
SimpleAdminApiFuture<CoordinatorKey, StreamsGroupDescription> future =
DescribeStreamsGroupsHandler.newFuture(groupIds);
- DescribeStreamsGroupsHandler handler = new
DescribeStreamsGroupsHandler(options.includeAuthorizedOperations(), logContext);
+ DescribeStreamsGroupsHandler handler = new
DescribeStreamsGroupsHandler(options.includeAuthorizedOperations(),
options.includeTopologyDescription(), logContext);
invokeDriver(handler, future, options.timeoutMs);
return new DescribeStreamsGroupsResult(future.all().entrySet().stream()
.collect(Collectors.toMap(entry -> entry.getKey().idValue,
Map.Entry::getValue)));
diff --git
a/clients/src/main/java/org/apache/kafka/clients/admin/StreamsGroupDescription.java
b/clients/src/main/java/org/apache/kafka/clients/admin/StreamsGroupDescription.java
index 024285324a0..cb74803a610 100644
---
a/clients/src/main/java/org/apache/kafka/clients/admin/StreamsGroupDescription.java
+++
b/clients/src/main/java/org/apache/kafka/clients/admin/StreamsGroupDescription.java
@@ -24,6 +24,7 @@ import org.apache.kafka.common.annotation.InterfaceStability;
import java.util.Collection;
import java.util.Objects;
+import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@@ -42,6 +43,8 @@ public class StreamsGroupDescription {
private final GroupState groupState;
private final Node coordinator;
private final Set<AclOperation> authorizedOperations;
+ private final Optional<StreamsGroupTopologyDescription>
topologyDescription;
+ private final StreamsGroupTopologyDescriptionStatus
topologyDescriptionStatus;
public StreamsGroupDescription(
final String groupId,
@@ -53,6 +56,34 @@ public class StreamsGroupDescription {
final GroupState groupState,
final Node coordinator,
final Set<AclOperation> authorizedOperations
+ ) {
+ this(
+ groupId,
+ groupEpoch,
+ targetAssignmentEpoch,
+ topologyEpoch,
+ subtopologies,
+ members,
+ groupState,
+ coordinator,
+ authorizedOperations,
+ Optional.empty(),
+ StreamsGroupTopologyDescriptionStatus.NOT_REQUESTED
+ );
+ }
+
+ public StreamsGroupDescription(
+ final String groupId,
+ final int groupEpoch,
+ final int targetAssignmentEpoch,
+ final int topologyEpoch,
+ final Collection<StreamsGroupSubtopologyDescription> subtopologies,
+ final Collection<StreamsGroupMemberDescription> members,
+ final GroupState groupState,
+ final Node coordinator,
+ final Set<AclOperation> authorizedOperations,
+ final Optional<StreamsGroupTopologyDescription>
topologyDescription,
+ final StreamsGroupTopologyDescriptionStatus
topologyDescriptionStatus
) {
this.groupId = Objects.requireNonNull(groupId, "groupId must be
non-null");
this.groupEpoch = groupEpoch;
@@ -63,6 +94,8 @@ public class StreamsGroupDescription {
this.groupState = Objects.requireNonNull(groupState, "groupState must
be non-null");
this.coordinator = Objects.requireNonNull(coordinator, "coordinator
must be non-null");
this.authorizedOperations = authorizedOperations;
+ this.topologyDescription = Objects.requireNonNull(topologyDescription,
"topologyDescription must be non-null");
+ this.topologyDescriptionStatus =
Objects.requireNonNull(topologyDescriptionStatus, "topologyDescriptionStatus
must be non-null");
}
/**
@@ -128,6 +161,22 @@ public class StreamsGroupDescription {
return authorizedOperations;
}
+ /**
+ * The full topology description for this group, as recorded by the
broker's topology description plugin.
+ * Present if and only if {@link #topologyDescriptionStatus()} is
+ * {@link StreamsGroupTopologyDescriptionStatus#AVAILABLE AVAILABLE}.
+ */
+ public Optional<StreamsGroupTopologyDescription> topologyDescription() {
+ return topologyDescription;
+ }
+
+ /**
+ * The status of the topology description for this group, paired with
{@link #topologyDescription()}.
+ */
+ public StreamsGroupTopologyDescriptionStatus topologyDescriptionStatus() {
+ return topologyDescriptionStatus;
+ }
+
@Override
public boolean equals(final Object o) {
if (this == o) {
@@ -145,7 +194,9 @@ public class StreamsGroupDescription {
&& Objects.equals(members, that.members)
&& groupState == that.groupState
&& Objects.equals(coordinator, that.coordinator)
- && Objects.equals(authorizedOperations, that.authorizedOperations);
+ && Objects.equals(authorizedOperations, that.authorizedOperations)
+ && Objects.equals(topologyDescription, that.topologyDescription)
+ && topologyDescriptionStatus == that.topologyDescriptionStatus;
}
@Override
@@ -159,7 +210,9 @@ public class StreamsGroupDescription {
members,
groupState,
coordinator,
- authorizedOperations
+ authorizedOperations,
+ topologyDescription,
+ topologyDescriptionStatus
);
}
@@ -175,6 +228,8 @@ public class StreamsGroupDescription {
", groupState=" + groupState +
", coordinator=" + coordinator +
", authorizedOperations=" +
authorizedOperations.stream().map(AclOperation::toString).collect(Collectors.joining(","))
+
+ ", topologyDescription=" +
topologyDescription.map(Object::toString).orElse("") +
+ ", topologyDescriptionStatus=" + topologyDescriptionStatus +
')';
}
}
diff --git
a/clients/src/main/java/org/apache/kafka/clients/admin/StreamsGroupTopologyDescription.java
b/clients/src/main/java/org/apache/kafka/clients/admin/StreamsGroupTopologyDescription.java
new file mode 100644
index 00000000000..79d6de44e3a
--- /dev/null
+++
b/clients/src/main/java/org/apache/kafka/clients/admin/StreamsGroupTopologyDescription.java
@@ -0,0 +1,422 @@
+/*
+ * 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.clients.admin;
+
+import org.apache.kafka.common.annotation.InterfaceStability;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * A description of a Kafka Streams topology, as recorded by the topology
description plugin configured on the broker.
+ * <p>
+ * This type mirrors {@code org.apache.kafka.streams.TopologyDescription} in
shape but lives in the admin client so that
+ * callers do not need to depend on {@code kafka-streams}. The wire format
only carries the successor relation between
+ * nodes; the {@link Node#predecessors() predecessors} are reconstructed from
the successors when this description is built.
+ */
[email protected]
+public class StreamsGroupTopologyDescription {
+
+ private final Collection<Subtopology> subtopologies;
+ private final Collection<GlobalStore> globalStores;
+
+ public StreamsGroupTopologyDescription(
+ final Collection<Subtopology> subtopologies,
+ final Collection<GlobalStore> globalStores
+ ) {
+ this.subtopologies = List.copyOf(Objects.requireNonNull(subtopologies,
"subtopologies must be non-null"));
+ this.globalStores = List.copyOf(Objects.requireNonNull(globalStores,
"globalStores must be non-null"));
+ }
+
+ /**
+ * The subtopologies that make up this topology.
+ */
+ public Collection<Subtopology> subtopologies() {
+ return subtopologies;
+ }
+
+ /**
+ * The global state stores used by this topology.
+ */
+ public Collection<GlobalStore> globalStores() {
+ return globalStores;
+ }
+
+ @Override
+ public boolean equals(final Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ final StreamsGroupTopologyDescription that =
(StreamsGroupTopologyDescription) o;
+ return Objects.equals(subtopologies, that.subtopologies)
+ && Objects.equals(globalStores, that.globalStores);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(subtopologies, globalStores);
+ }
+
+ @Override
+ public String toString() {
+ return "StreamsGroupTopologyDescription(" +
+ "subtopologies=" + subtopologies +
+ ", globalStores=" + globalStores +
+ ')';
+ }
+
+ /**
+ * A connected sub-graph of a topology.
+ */
+ public static class Subtopology {
+
+ private final String id;
+ private final Collection<Node> nodes;
+
+ public Subtopology(final String id, final Collection<Node> nodes) {
+ this.id = Objects.requireNonNull(id, "id must be non-null");
+ this.nodes = List.copyOf(Objects.requireNonNull(nodes, "nodes must
be non-null"));
+ }
+
+ /**
+ * The subtopology identifier, unique within the topology.
+ */
+ public String id() {
+ return id;
+ }
+
+ /**
+ * The processing nodes in this subtopology.
+ */
+ public Collection<Node> nodes() {
+ return nodes;
+ }
+
+ @Override
+ public boolean equals(final Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ final Subtopology that = (Subtopology) o;
+ // Nodes within a subtopology are unordered, so compare them
set-wise rather than by wire order.
+ return Objects.equals(id, that.id) && Objects.equals(new
HashSet<>(nodes), new HashSet<>(that.nodes));
+ }
+
+ @Override
+ public int hashCode() {
+ // Mirror the order-insensitive equality above; HashSet.hashCode()
is independent of iteration order.
+ return Objects.hash(id, new HashSet<>(nodes));
+ }
+
+ @Override
+ public String toString() {
+ return "Subtopology(id=" + id + ", nodes=" + nodes + ')';
+ }
+ }
+
+ /**
+ * A node of a topology. Can be a {@link Source}, {@link Sink}, or {@link
Processor} node.
+ */
+ public interface Node {
+
+ /**
+ * The name of this node.
+ */
+ String name();
+
+ /**
+ * The names of the successor nodes within the subtopology.
+ */
+ Set<String> successors();
+
+ /**
+ * The names of the predecessor nodes within the subtopology,
reconstructed from the successor relation.
+ */
+ Set<String> predecessors();
+ }
+
+ /**
+ * A source node of a topology.
+ */
+ public static final class Source implements Node {
+
+ private final String name;
+ private final Set<String> topics;
+ private final Set<String> successors;
+ private final Set<String> predecessors;
+
+ public Source(
+ final String name,
+ final Set<String> topics,
+ final Set<String> successors,
+ final Set<String> predecessors
+ ) {
+ this.name = Objects.requireNonNull(name, "name must be non-null");
+ this.topics = Set.copyOf(Objects.requireNonNull(topics, "topics
must be non-null"));
+ this.successors = Set.copyOf(Objects.requireNonNull(successors,
"successors must be non-null"));
+ this.predecessors =
Set.copyOf(Objects.requireNonNull(predecessors, "predecessors must be
non-null"));
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public Set<String> successors() {
+ return successors;
+ }
+
+ @Override
+ public Set<String> predecessors() {
+ return predecessors;
+ }
+
+ /**
+ * The topics this source node reads from. May be empty if the source
topics are dynamically determined.
+ */
+ public Set<String> topics() {
+ return topics;
+ }
+
+ @Override
+ public boolean equals(final Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ final Source source = (Source) o;
+ return Objects.equals(name, source.name)
+ && Objects.equals(topics, source.topics)
+ && Objects.equals(successors, source.successors)
+ && Objects.equals(predecessors, source.predecessors);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name, topics, successors, predecessors);
+ }
+
+ @Override
+ public String toString() {
+ return "Source(name=" + name + ", topics=" + topics + ",
successors=" + successors + ", predecessors=" + predecessors + ')';
+ }
+ }
+
+ /**
+ * A processor node of a topology.
+ */
+ public static final class Processor implements Node {
+
+ private final String name;
+ private final Set<String> stores;
+ private final Set<String> successors;
+ private final Set<String> predecessors;
+
+ public Processor(
+ final String name,
+ final Set<String> stores,
+ final Set<String> successors,
+ final Set<String> predecessors
+ ) {
+ this.name = Objects.requireNonNull(name, "name must be non-null");
+ this.stores = Set.copyOf(Objects.requireNonNull(stores, "stores
must be non-null"));
+ this.successors = Set.copyOf(Objects.requireNonNull(successors,
"successors must be non-null"));
+ this.predecessors =
Set.copyOf(Objects.requireNonNull(predecessors, "predecessors must be
non-null"));
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public Set<String> successors() {
+ return successors;
+ }
+
+ @Override
+ public Set<String> predecessors() {
+ return predecessors;
+ }
+
+ /**
+ * The names of the state stores accessed by this processor node.
+ */
+ public Set<String> stores() {
+ return stores;
+ }
+
+ @Override
+ public boolean equals(final Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ final Processor processor = (Processor) o;
+ return Objects.equals(name, processor.name)
+ && Objects.equals(stores, processor.stores)
+ && Objects.equals(successors, processor.successors)
+ && Objects.equals(predecessors, processor.predecessors);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name, stores, successors, predecessors);
+ }
+
+ @Override
+ public String toString() {
+ return "Processor(name=" + name + ", stores=" + stores + ",
successors=" + successors + ", predecessors=" + predecessors + ')';
+ }
+ }
+
+ /**
+ * A sink node of a topology.
+ */
+ public static final class Sink implements Node {
+
+ private final String name;
+ private final Optional<String> topic;
+ private final Set<String> successors;
+ private final Set<String> predecessors;
+
+ public Sink(
+ final String name,
+ final Optional<String> topic,
+ final Set<String> successors,
+ final Set<String> predecessors
+ ) {
+ this.name = Objects.requireNonNull(name, "name must be non-null");
+ this.topic = Objects.requireNonNull(topic, "topic must be
non-null");
+ this.successors = Set.copyOf(Objects.requireNonNull(successors,
"successors must be non-null"));
+ this.predecessors =
Set.copyOf(Objects.requireNonNull(predecessors, "predecessors must be
non-null"));
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public Set<String> successors() {
+ return successors;
+ }
+
+ @Override
+ public Set<String> predecessors() {
+ return predecessors;
+ }
+
+ /**
+ * The topic this sink node writes to. Empty if the topic is
dynamically determined.
+ */
+ public Optional<String> topic() {
+ return topic;
+ }
+
+ @Override
+ public boolean equals(final Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ final Sink sink = (Sink) o;
+ return Objects.equals(name, sink.name)
+ && Objects.equals(topic, sink.topic)
+ && Objects.equals(successors, sink.successors)
+ && Objects.equals(predecessors, sink.predecessors);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name, topic, successors, predecessors);
+ }
+
+ @Override
+ public String toString() {
+ return "Sink(name=" + name + ", topic=" + topic.orElse(null) + ",
successors=" + successors + ", predecessors=" + predecessors + ')';
+ }
+ }
+
+ /**
+ * A global state store, made up of a {@link Source} node and the {@link
Processor} node that maintains the store.
+ */
+ public static class GlobalStore {
+
+ private final Source source;
+ private final Processor processor;
+
+ public GlobalStore(final Source source, final Processor processor) {
+ this.source = Objects.requireNonNull(source, "source must be
non-null");
+ this.processor = Objects.requireNonNull(processor, "processor must
be non-null");
+ }
+
+ /**
+ * The source node providing data to the global store.
+ */
+ public Source source() {
+ return source;
+ }
+
+ /**
+ * The processor node that maintains the global store.
+ */
+ public Processor processor() {
+ return processor;
+ }
+
+ @Override
+ public boolean equals(final Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ final GlobalStore that = (GlobalStore) o;
+ return Objects.equals(source, that.source) &&
Objects.equals(processor, that.processor);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(source, processor);
+ }
+
+ @Override
+ public String toString() {
+ return "GlobalStore(source=" + source + ", processor=" + processor
+ ')';
+ }
+ }
+}
diff --git
a/clients/src/main/java/org/apache/kafka/clients/admin/StreamsGroupTopologyDescriptionStatus.java
b/clients/src/main/java/org/apache/kafka/clients/admin/StreamsGroupTopologyDescriptionStatus.java
new file mode 100644
index 00000000000..22fcf4604a0
--- /dev/null
+++
b/clients/src/main/java/org/apache/kafka/clients/admin/StreamsGroupTopologyDescriptionStatus.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.clients.admin;
+
+import org.apache.kafka.common.annotation.InterfaceStability;
+
+/**
+ * The status of the topology description that accompanies a {@link
StreamsGroupDescription}.
+ * <p>
+ * The status indicates whether a topology description was requested and, if
so, whether one could
+ * be retrieved. It is paired with {@link
StreamsGroupDescription#topologyDescription()}: the
+ * description is present if and only if the status is {@link #AVAILABLE}.
+ */
[email protected]
+public enum StreamsGroupTopologyDescriptionStatus {
+
+ /**
+ * The topology description was not requested (the caller did not set
+ * {@link
DescribeStreamsGroupsOptions#includeTopologyDescription(boolean)}).
+ */
+ NOT_REQUESTED((byte) 0),
+
+ /**
+ * No topology description is recorded for this group, for example because
no topology description
+ * plugin is configured on the broker or the client has not pushed a
description yet.
+ */
+ NOT_STORED((byte) 1),
+
+ /**
+ * The broker failed to fetch the topology description. See the broker
logs for details.
+ */
+ ERROR((byte) 2),
+
+ /**
+ * The topology description is available and carried in {@link
StreamsGroupDescription#topologyDescription()}.
+ */
+ AVAILABLE((byte) 3);
+
+ private final byte id;
+
+ StreamsGroupTopologyDescriptionStatus(final byte id) {
+ this.id = id;
+ }
+
+ /**
+ * The wire identifier of this status.
+ */
+ public byte id() {
+ return id;
+ }
+
+ /**
+ * Returns the status corresponding to the given wire identifier.
+ *
+ * @param id the wire identifier.
+ * @return the matching status.
+ * @throws IllegalArgumentException if the identifier is unknown.
+ */
+ public static StreamsGroupTopologyDescriptionStatus forId(final byte id) {
+ for (final StreamsGroupTopologyDescriptionStatus status : values()) {
+ if (status.id == id) {
+ return status;
+ }
+ }
+ throw new IllegalArgumentException("Unknown topology description
status id: " + id);
+ }
+}
diff --git
a/clients/src/main/java/org/apache/kafka/clients/admin/internals/DescribeStreamsGroupsHandler.java
b/clients/src/main/java/org/apache/kafka/clients/admin/internals/DescribeStreamsGroupsHandler.java
index 69314e6c30f..11d734e5e39 100644
---
a/clients/src/main/java/org/apache/kafka/clients/admin/internals/DescribeStreamsGroupsHandler.java
+++
b/clients/src/main/java/org/apache/kafka/clients/admin/internals/DescribeStreamsGroupsHandler.java
@@ -20,6 +20,8 @@ import org.apache.kafka.clients.admin.StreamsGroupDescription;
import org.apache.kafka.clients.admin.StreamsGroupMemberAssignment;
import org.apache.kafka.clients.admin.StreamsGroupMemberDescription;
import org.apache.kafka.clients.admin.StreamsGroupSubtopologyDescription;
+import org.apache.kafka.clients.admin.StreamsGroupTopologyDescription;
+import org.apache.kafka.clients.admin.StreamsGroupTopologyDescriptionStatus;
import org.apache.kafka.common.GroupState;
import org.apache.kafka.common.Node;
import org.apache.kafka.common.acl.AclOperation;
@@ -48,14 +50,22 @@ import static
org.apache.kafka.clients.admin.internals.AdminUtils.validAclOperat
public class DescribeStreamsGroupsHandler extends
AdminApiHandler.Batched<CoordinatorKey, StreamsGroupDescription> {
+ static final byte NODE_TYPE_SOURCE = 1;
+ static final byte NODE_TYPE_PROCESSOR = 2;
+ static final byte NODE_TYPE_SINK = 3;
+
private final boolean includeAuthorizedOperations;
+ private final boolean includeTopologyDescription;
private final Logger log;
private final AdminApiLookupStrategy<CoordinatorKey> lookupStrategy;
+
public DescribeStreamsGroupsHandler(
boolean includeAuthorizedOperations,
+ boolean includeTopologyDescription,
LogContext logContext) {
this.includeAuthorizedOperations = includeAuthorizedOperations;
+ this.includeTopologyDescription = includeTopologyDescription;
this.log = logContext.logger(DescribeStreamsGroupsHandler.class);
this.lookupStrategy = new CoordinatorStrategy(CoordinatorType.GROUP,
logContext);
}
@@ -91,7 +101,8 @@ public class DescribeStreamsGroupsHandler extends
AdminApiHandler.Batched<Coordi
}).collect(Collectors.toList());
StreamsGroupDescribeRequestData data = new
StreamsGroupDescribeRequestData()
.setGroupIds(groupIds)
- .setIncludeAuthorizedOperations(includeAuthorizedOperations);
+ .setIncludeAuthorizedOperations(includeAuthorizedOperations)
+ .setIncludeTopologyDescription(includeTopologyDescription);
return new StreamsGroupDescribeRequest.Builder(data);
}
@@ -120,6 +131,28 @@ public class DescribeStreamsGroupsHandler extends
AdminApiHandler.Batched<Coordi
final Set<AclOperation> authorizedOperations =
validAclOperations(describedGroup.authorizedOperations());
+ final StreamsGroupTopologyDescriptionStatus
topologyDescriptionStatus;
+ try {
+ topologyDescriptionStatus =
+
StreamsGroupTopologyDescriptionStatus.forId(describedGroup.topologyDescriptionStatus());
+ } catch (IllegalArgumentException e) {
+ log.error("`DescribeStreamsGroups` response for group id {}
contains an unknown topology description status {}",
+ groupIdKey.idValue,
describedGroup.topologyDescriptionStatus());
+ failed.put(groupIdKey, new IllegalStateException(
+ "Unknown topology description status " +
describedGroup.topologyDescriptionStatus(), e));
+ continue;
+ }
+ final Optional<StreamsGroupTopologyDescription>
topologyDescription;
+ try {
+ topologyDescription =
+ convertTopologyDescription(topologyDescriptionStatus,
describedGroup.topologyDescription());
+ } catch (IllegalStateException e) {
+ log.error("`DescribeStreamsGroups` response for group id {}
contains a topology description that could not be parsed",
+ groupIdKey.idValue, e);
+ failed.put(groupIdKey, e);
+ continue;
+ }
+
final StreamsGroupDescription streamsGroupDescription = new
StreamsGroupDescription(
describedGroup.groupId(),
describedGroup.groupEpoch(),
@@ -129,7 +162,9 @@ public class DescribeStreamsGroupsHandler extends
AdminApiHandler.Batched<Coordi
convertMembers(describedGroup.members()),
GroupState.parse(describedGroup.groupState()),
coordinator,
- authorizedOperations
+ authorizedOperations,
+ topologyDescription,
+ topologyDescriptionStatus
);
completed.put(groupIdKey, streamsGroupDescription);
}
@@ -189,6 +224,86 @@ public class DescribeStreamsGroupsHandler extends
AdminApiHandler.Batched<Coordi
));
}
+ private Optional<StreamsGroupTopologyDescription>
convertTopologyDescription(
+ final StreamsGroupTopologyDescriptionStatus status,
+ final StreamsGroupDescribeResponseData.TopologyDescription
topologyDescription) {
+
+ if (status != StreamsGroupTopologyDescriptionStatus.AVAILABLE) {
+ return Optional.empty();
+ }
+ if (topologyDescription == null) {
+ throw new IllegalStateException("Topology description is missing
despite status AVAILABLE");
+ }
+ final List<StreamsGroupTopologyDescription.Subtopology> subtopologies
= topologyDescription.subtopologies().stream()
+ .map(this::convertTopologySubtopology)
+ .collect(Collectors.toList());
+ final List<StreamsGroupTopologyDescription.GlobalStore> globalStores =
topologyDescription.globalStores().stream()
+ .map(this::convertGlobalStore)
+ .collect(Collectors.toList());
+ return Optional.of(new StreamsGroupTopologyDescription(subtopologies,
globalStores));
+ }
+
+ private StreamsGroupTopologyDescription.Subtopology
convertTopologySubtopology(
+ final
StreamsGroupDescribeResponseData.TopologyDescriptionSubtopology subtopology) {
+ final Map<String, Set<String>> predecessors =
reconstructPredecessors(subtopology.nodes());
+ final List<StreamsGroupTopologyDescription.Node> nodes =
subtopology.nodes().stream()
+ .map(node -> convertTopologyNode(node, predecessors))
+ .collect(Collectors.toList());
+ return new
StreamsGroupTopologyDescription.Subtopology(subtopology.subtopologyId(), nodes);
+ }
+
+ private StreamsGroupTopologyDescription.GlobalStore convertGlobalStore(
+ final
StreamsGroupDescribeResponseData.TopologyDescriptionGlobalStore globalStore) {
+ final List<StreamsGroupDescribeResponseData.TopologyDescriptionNode>
pair =
+ List.of(globalStore.source(), globalStore.processor());
+ final Map<String, Set<String>> predecessors =
reconstructPredecessors(pair);
+ final StreamsGroupTopologyDescription.Node source =
convertTopologyNode(globalStore.source(), predecessors);
+ final StreamsGroupTopologyDescription.Node processor =
convertTopologyNode(globalStore.processor(), predecessors);
+ if (!(source instanceof StreamsGroupTopologyDescription.Source)
+ || !(processor instanceof
StreamsGroupTopologyDescription.Processor)) {
+ throw new IllegalStateException("Global store must be composed of
a source and a processor node.");
+ }
+ return new StreamsGroupTopologyDescription.GlobalStore(
+ (StreamsGroupTopologyDescription.Source) source,
+ (StreamsGroupTopologyDescription.Processor) processor
+ );
+ }
+
+ /**
+ * Reconstructs the predecessor relation from the successor lists carried
on the wire. For every node, each of its
+ * successors gains this node as a predecessor.
+ */
+ private Map<String, Set<String>> reconstructPredecessors(
+ final
List<StreamsGroupDescribeResponseData.TopologyDescriptionNode> nodes) {
+ final Map<String, Set<String>> predecessors = new HashMap<>();
+ for (final StreamsGroupDescribeResponseData.TopologyDescriptionNode
node : nodes) {
+ for (final String successor : node.successors()) {
+ predecessors.computeIfAbsent(successor, ignored -> new
HashSet<>()).add(node.name());
+ }
+ }
+ return predecessors;
+ }
+
+ private StreamsGroupTopologyDescription.Node convertTopologyNode(
+ final StreamsGroupDescribeResponseData.TopologyDescriptionNode
node,
+ final Map<String, Set<String>> predecessors) {
+ final Set<String> successors = Set.copyOf(node.successors());
+ final Set<String> nodePredecessors =
predecessors.getOrDefault(node.name(), Set.of());
+ switch (node.nodeType()) {
+ case NODE_TYPE_SOURCE:
+ return new StreamsGroupTopologyDescription.Source(
+ node.name(), Set.copyOf(node.sourceTopics()), successors,
nodePredecessors);
+ case NODE_TYPE_PROCESSOR:
+ return new StreamsGroupTopologyDescription.Processor(
+ node.name(), Set.copyOf(node.stores()), successors,
nodePredecessors);
+ case NODE_TYPE_SINK:
+ return new StreamsGroupTopologyDescription.Sink(
+ node.name(), Optional.ofNullable(node.sinkTopic()),
successors, nodePredecessors);
+ default:
+ throw new IllegalStateException("Unknown topology node type: "
+ node.nodeType());
+ }
+ }
+
private StreamsGroupMemberAssignment.TaskIds convertTaskIds(final
StreamsGroupDescribeResponseData.TaskIds taskIds) {
return new StreamsGroupMemberAssignment.TaskIds(
taskIds.subtopologyId(),
diff --git
a/clients/src/test/java/org/apache/kafka/clients/admin/MockAdminClientTest.java
b/clients/src/test/java/org/apache/kafka/clients/admin/MockAdminClientTest.java
new file mode 100644
index 00000000000..a9df351c87f
--- /dev/null
+++
b/clients/src/test/java/org/apache/kafka/clients/admin/MockAdminClientTest.java
@@ -0,0 +1,132 @@
+/*
+ * 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.clients.admin;
+
+import org.apache.kafka.common.GroupState;
+import org.apache.kafka.common.Node;
+import org.apache.kafka.common.errors.GroupIdNotFoundException;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class MockAdminClientTest {
+
+ @Test
+ public void testDescribeStreamsGroupsReturnsRegisteredDescription() throws
Exception {
+ String groupId = "stream-group";
+ StreamsGroupDescription description =
newStreamsGroupDescription(groupId);
+
+ try (MockAdminClient admin = new MockAdminClient()) {
+ admin.addStreamsGroupDescription(description);
+
+ StreamsGroupDescription result = admin
+ .describeStreamsGroups(List.of(groupId))
+ .all()
+ .get()
+ .get(groupId);
+
+ assertEquals(description, result);
+ }
+ }
+
+ @Test
+ public void
testDescribeStreamsGroupsReturnsTopologyDescriptionWhenRequested() throws
Exception {
+ String groupId = "stream-group";
+ StreamsGroupDescription description =
newStreamsGroupDescriptionWithTopology(groupId);
+
+ try (MockAdminClient admin = new MockAdminClient()) {
+ admin.addStreamsGroupDescription(description);
+
+ StreamsGroupDescription result = admin
+ .describeStreamsGroups(List.of(groupId), new
DescribeStreamsGroupsOptions().includeTopologyDescription(true))
+ .all()
+ .get()
+ .get(groupId);
+
+ assertEquals(StreamsGroupTopologyDescriptionStatus.AVAILABLE,
result.topologyDescriptionStatus());
+ assertEquals(description.topologyDescription(),
result.topologyDescription());
+ }
+ }
+
+ @Test
+ public void
testDescribeStreamsGroupsOmitsTopologyDescriptionWhenNotRequested() throws
Exception {
+ String groupId = "stream-group";
+ StreamsGroupDescription description =
newStreamsGroupDescriptionWithTopology(groupId);
+
+ try (MockAdminClient admin = new MockAdminClient()) {
+ admin.addStreamsGroupDescription(description);
+
+ StreamsGroupDescription result = admin
+ .describeStreamsGroups(List.of(groupId))
+ .all()
+ .get()
+ .get(groupId);
+
+ assertEquals(StreamsGroupTopologyDescriptionStatus.NOT_REQUESTED,
result.topologyDescriptionStatus());
+ assertEquals(Optional.empty(), result.topologyDescription());
+ }
+ }
+
+ @Test
+ public void testDescribeStreamsGroupsUnknownGroupFails() {
+ try (MockAdminClient admin = new MockAdminClient()) {
+ ExecutionException exception = assertThrows(
+ ExecutionException.class,
+ () ->
admin.describeStreamsGroups(List.of("missing-group")).all().get());
+ assertInstanceOf(GroupIdNotFoundException.class,
exception.getCause());
+ }
+ }
+
+ private StreamsGroupDescription newStreamsGroupDescription(String groupId)
{
+ return new StreamsGroupDescription(
+ groupId,
+ 0,
+ 0,
+ 0,
+ List.of(),
+ List.of(),
+ GroupState.STABLE,
+ new Node(0, "host", 0),
+ Set.of(),
+ Optional.empty(),
+ StreamsGroupTopologyDescriptionStatus.NOT_REQUESTED);
+ }
+
+ private StreamsGroupDescription
newStreamsGroupDescriptionWithTopology(String groupId) {
+ StreamsGroupTopologyDescription topology = new
StreamsGroupTopologyDescription(List.of(), List.of());
+ return new StreamsGroupDescription(
+ groupId,
+ 0,
+ 0,
+ 0,
+ List.of(),
+ List.of(),
+ GroupState.STABLE,
+ new Node(0, "host", 0),
+ Set.of(),
+ Optional.of(topology),
+ StreamsGroupTopologyDescriptionStatus.AVAILABLE);
+ }
+}
diff --git
a/clients/src/test/java/org/apache/kafka/clients/admin/internals/DescribeStreamsGroupsHandlerTest.java
b/clients/src/test/java/org/apache/kafka/clients/admin/internals/DescribeStreamsGroupsHandlerTest.java
new file mode 100644
index 00000000000..d83a5381c4d
--- /dev/null
+++
b/clients/src/test/java/org/apache/kafka/clients/admin/internals/DescribeStreamsGroupsHandlerTest.java
@@ -0,0 +1,311 @@
+/*
+ * 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.clients.admin.internals;
+
+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.Node;
+import org.apache.kafka.common.message.StreamsGroupDescribeRequestData;
+import org.apache.kafka.common.message.StreamsGroupDescribeResponseData;
+import org.apache.kafka.common.requests.StreamsGroupDescribeRequest;
+import org.apache.kafka.common.requests.StreamsGroupDescribeResponse;
+import org.apache.kafka.common.utils.internals.LogContext;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class DescribeStreamsGroupsHandlerTest {
+
+ private final LogContext logContext = new LogContext();
+ private final String groupId = "group-id";
+ private final Node coordinator = new Node(1, "host", 1234);
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ public void testBuildRequestSetsIncludeTopologyDescription(boolean
includeTopologyDescription) {
+ DescribeStreamsGroupsHandler handler =
+ new DescribeStreamsGroupsHandler(false,
includeTopologyDescription, logContext);
+
+ StreamsGroupDescribeRequest.Builder builder =
+ handler.buildBatchedRequest(1,
Set.of(CoordinatorKey.byGroupId(groupId)));
+ StreamsGroupDescribeRequestData data = builder.build().data();
+
+ assertEquals(includeTopologyDescription,
data.includeTopologyDescription());
+ assertEquals(List.of(groupId), data.groupIds());
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = StreamsGroupTopologyDescriptionStatus.class, names =
{"NOT_REQUESTED", "NOT_STORED", "ERROR"})
+ public void
testTopologyDescriptionAbsentWhenStatusNotAvailable(StreamsGroupTopologyDescriptionStatus
status) {
+ StreamsGroupDescribeResponseData.DescribedGroup describedGroup =
newDescribedGroup()
+ .setTopologyDescriptionStatus(status.id())
+ .setTopologyDescription(null);
+
+ StreamsGroupDescription description = describe(describedGroup);
+
+ assertEquals(status, description.topologyDescriptionStatus());
+ assertTrue(description.topologyDescription().isEmpty());
+ }
+
+ @Test
+ public void testTopologyDescriptionParsedWhenAvailable() {
+ StreamsGroupDescribeResponseData.TopologyDescriptionNode source =
+ new StreamsGroupDescribeResponseData.TopologyDescriptionNode()
+ .setName("source")
+ .setNodeType(DescribeStreamsGroupsHandler.NODE_TYPE_SOURCE)
+ .setSourceTopics(List.of("input"))
+ .setSuccessors(List.of("processor"));
+ StreamsGroupDescribeResponseData.TopologyDescriptionNode processor =
+ new StreamsGroupDescribeResponseData.TopologyDescriptionNode()
+ .setName("processor")
+ .setNodeType(DescribeStreamsGroupsHandler.NODE_TYPE_PROCESSOR)
+ .setStores(List.of("store"))
+ .setSuccessors(List.of("sink"));
+ StreamsGroupDescribeResponseData.TopologyDescriptionNode sink =
+ new StreamsGroupDescribeResponseData.TopologyDescriptionNode()
+ .setName("sink")
+ .setNodeType(DescribeStreamsGroupsHandler.NODE_TYPE_SINK)
+ .setSinkTopic("output")
+ .setSuccessors(List.of());
+
+ StreamsGroupDescribeResponseData.TopologyDescription wire =
+ new StreamsGroupDescribeResponseData.TopologyDescription()
+ .setSubtopologies(List.of(
+ new
StreamsGroupDescribeResponseData.TopologyDescriptionSubtopology()
+ .setSubtopologyId("0")
+ .setNodes(List.of(source, processor, sink))))
+ .setGlobalStores(List.of());
+
+ StreamsGroupDescribeResponseData.DescribedGroup describedGroup =
newDescribedGroup()
+
.setTopologyDescriptionStatus(StreamsGroupTopologyDescriptionStatus.AVAILABLE.id())
+ .setTopologyDescription(wire);
+
+ StreamsGroupDescription description = describe(describedGroup);
+
+ assertEquals(StreamsGroupTopologyDescriptionStatus.AVAILABLE,
description.topologyDescriptionStatus());
+ assertTrue(description.topologyDescription().isPresent());
+
+ StreamsGroupTopologyDescription topology =
description.topologyDescription().get();
+ assertEquals(1, topology.subtopologies().size());
+ assertTrue(topology.globalStores().isEmpty());
+
+ StreamsGroupTopologyDescription.Subtopology subtopology =
topology.subtopologies().iterator().next();
+ assertEquals("0", subtopology.id());
+ List<StreamsGroupTopologyDescription.Node> nodes =
List.copyOf(subtopology.nodes());
+ assertEquals(3, nodes.size());
+
+ StreamsGroupTopologyDescription.Source parsedSource =
+ assertInstanceOf(StreamsGroupTopologyDescription.Source.class,
nodes.get(0));
+ assertEquals("source", parsedSource.name());
+ assertEquals(Set.of("input"), parsedSource.topics());
+ assertEquals(Set.of("processor"), parsedSource.successors());
+ // Sources have no predecessors.
+ assertTrue(parsedSource.predecessors().isEmpty());
+
+ StreamsGroupTopologyDescription.Processor parsedProcessor =
+ assertInstanceOf(StreamsGroupTopologyDescription.Processor.class,
nodes.get(1));
+ assertEquals(Set.of("store"), parsedProcessor.stores());
+ assertEquals(Set.of("sink"), parsedProcessor.successors());
+ // Predecessor reconstructed from the source's successor list.
+ assertEquals(Set.of("source"), parsedProcessor.predecessors());
+
+ StreamsGroupTopologyDescription.Sink parsedSink =
+ assertInstanceOf(StreamsGroupTopologyDescription.Sink.class,
nodes.get(2));
+ assertEquals("output", parsedSink.topic().orElse(null));
+ assertTrue(parsedSink.successors().isEmpty());
+ assertEquals(Set.of("processor"), parsedSink.predecessors());
+ }
+
+ @Test
+ public void testGlobalStorePredecessorReconstruction() {
+ StreamsGroupDescribeResponseData.TopologyDescriptionNode globalSource =
+ new StreamsGroupDescribeResponseData.TopologyDescriptionNode()
+ .setName("global-source")
+ .setNodeType(DescribeStreamsGroupsHandler.NODE_TYPE_SOURCE)
+ .setSourceTopics(List.of("global-topic"))
+ .setSuccessors(List.of("global-processor"));
+ StreamsGroupDescribeResponseData.TopologyDescriptionNode
globalProcessor =
+ new StreamsGroupDescribeResponseData.TopologyDescriptionNode()
+ .setName("global-processor")
+ .setNodeType(DescribeStreamsGroupsHandler.NODE_TYPE_PROCESSOR)
+ .setStores(List.of("global-store"))
+ .setSuccessors(List.of());
+
+ StreamsGroupDescribeResponseData.TopologyDescription wire =
+ new StreamsGroupDescribeResponseData.TopologyDescription()
+ .setSubtopologies(List.of())
+ .setGlobalStores(List.of(
+ new
StreamsGroupDescribeResponseData.TopologyDescriptionGlobalStore()
+ .setSource(globalSource)
+ .setProcessor(globalProcessor)));
+
+ StreamsGroupDescribeResponseData.DescribedGroup describedGroup =
newDescribedGroup()
+
.setTopologyDescriptionStatus(StreamsGroupTopologyDescriptionStatus.AVAILABLE.id())
+ .setTopologyDescription(wire);
+
+ StreamsGroupTopologyDescription topology =
describe(describedGroup).topologyDescription().orElseThrow();
+ assertEquals(1, topology.globalStores().size());
+
+ StreamsGroupTopologyDescription.GlobalStore globalStore =
topology.globalStores().iterator().next();
+ assertTrue(globalStore.source().predecessors().isEmpty());
+ assertEquals(Set.of("global-processor"),
globalStore.source().successors());
+ assertEquals(Set.of("global-source"),
globalStore.processor().predecessors());
+ }
+
+ @Test
+ public void testTopologyDescriptionNotRequestedByDefault() {
+ StreamsGroupDescription description = describe(newDescribedGroup());
+ assertEquals(StreamsGroupTopologyDescriptionStatus.NOT_REQUESTED,
description.topologyDescriptionStatus());
+ assertFalse(description.topologyDescription().isPresent());
+ }
+
+ @Test
+ public void testUnknownTopologyDescriptionStatusFailsOnlyAffectedGroup() {
+ // A future broker version may add a topology description status id
that this client does not know about.
+ byte unknownStatusId = (byte) 99;
+ StreamsGroupDescribeResponseData.DescribedGroup describedGroup =
newDescribedGroup()
+ .setTopologyDescriptionStatus(unknownStatusId)
+ .setTopologyDescription(null);
+
+ DescribeStreamsGroupsHandler handler = new
DescribeStreamsGroupsHandler(false, true, logContext);
+ CoordinatorKey key = CoordinatorKey.byGroupId(groupId);
+ AdminApiHandler.ApiResult<CoordinatorKey, StreamsGroupDescription>
result = handler.handleResponse(
+ coordinator,
+ Set.of(key),
+ new StreamsGroupDescribeResponse(new
StreamsGroupDescribeResponseData()
+ .setGroups(List.of(describedGroup))));
+
+ // The unknown status must surface as a per-group failure rather than
propagating an exception.
+ assertTrue(result.completedKeys.isEmpty());
+ assertEquals(Set.of(key), result.failedKeys.keySet());
+ assertInstanceOf(IllegalStateException.class,
result.failedKeys.get(key));
+ }
+
+ @Test
+ public void
testAvailableStatusWithMissingTopologyDescriptionFailsOnlyAffectedGroup() {
+ StreamsGroupDescribeResponseData.DescribedGroup describedGroup =
newDescribedGroup()
+
.setTopologyDescriptionStatus(StreamsGroupTopologyDescriptionStatus.AVAILABLE.id())
+ .setTopologyDescription(null);
+
+ DescribeStreamsGroupsHandler handler = new
DescribeStreamsGroupsHandler(false, true, logContext);
+ CoordinatorKey key = CoordinatorKey.byGroupId(groupId);
+ AdminApiHandler.ApiResult<CoordinatorKey, StreamsGroupDescription>
result = handler.handleResponse(
+ coordinator,
+ Set.of(key),
+ new StreamsGroupDescribeResponse(new
StreamsGroupDescribeResponseData()
+ .setGroups(List.of(describedGroup))));
+
+ assertTrue(result.completedKeys.isEmpty());
+ assertEquals(Set.of(key), result.failedKeys.keySet());
+ assertInstanceOf(IllegalStateException.class,
result.failedKeys.get(key));
+ }
+
+ @Test
+ public void testUnknownTopologyNodeTypeFailsOnlyAffectedGroup() {
+ // A future broker version may add a topology node type that this
client does not know about.
+ StreamsGroupDescribeResponseData.TopologyDescriptionNode unknownNode =
+ new StreamsGroupDescribeResponseData.TopologyDescriptionNode()
+ .setName("mystery")
+ .setNodeType((byte) 99)
+ .setSuccessors(List.of());
+ StreamsGroupDescribeResponseData.TopologyDescription wire =
+ new StreamsGroupDescribeResponseData.TopologyDescription()
+ .setSubtopologies(List.of(
+ new
StreamsGroupDescribeResponseData.TopologyDescriptionSubtopology()
+ .setSubtopologyId("0")
+ .setNodes(List.of(unknownNode))))
+ .setGlobalStores(List.of());
+
+ assertGroupFailed(wire);
+ }
+
+ @Test
+ public void testMalformedGlobalStoreFailsOnlyAffectedGroup() {
+ // A global store must be a source/processor pair; here the "source"
is itself a sink node.
+ StreamsGroupDescribeResponseData.TopologyDescriptionNode notASource =
+ new StreamsGroupDescribeResponseData.TopologyDescriptionNode()
+ .setName("global-sink")
+ .setNodeType(DescribeStreamsGroupsHandler.NODE_TYPE_SINK)
+ .setSinkTopic("output")
+ .setSuccessors(List.of());
+ StreamsGroupDescribeResponseData.TopologyDescriptionNode processor =
+ new StreamsGroupDescribeResponseData.TopologyDescriptionNode()
+ .setName("global-processor")
+ .setNodeType(DescribeStreamsGroupsHandler.NODE_TYPE_PROCESSOR)
+ .setStores(List.of("global-store"))
+ .setSuccessors(List.of());
+ StreamsGroupDescribeResponseData.TopologyDescription wire =
+ new StreamsGroupDescribeResponseData.TopologyDescription()
+ .setSubtopologies(List.of())
+ .setGlobalStores(List.of(
+ new
StreamsGroupDescribeResponseData.TopologyDescriptionGlobalStore()
+ .setSource(notASource)
+ .setProcessor(processor)));
+
+ assertGroupFailed(wire);
+ }
+
+ private void
assertGroupFailed(StreamsGroupDescribeResponseData.TopologyDescription wire) {
+ StreamsGroupDescribeResponseData.DescribedGroup describedGroup =
newDescribedGroup()
+
.setTopologyDescriptionStatus(StreamsGroupTopologyDescriptionStatus.AVAILABLE.id())
+ .setTopologyDescription(wire);
+
+ DescribeStreamsGroupsHandler handler = new
DescribeStreamsGroupsHandler(false, true, logContext);
+ CoordinatorKey key = CoordinatorKey.byGroupId(groupId);
+ AdminApiHandler.ApiResult<CoordinatorKey, StreamsGroupDescription>
result = handler.handleResponse(
+ coordinator,
+ Set.of(key),
+ new StreamsGroupDescribeResponse(new
StreamsGroupDescribeResponseData()
+ .setGroups(List.of(describedGroup))));
+
+ assertTrue(result.completedKeys.isEmpty());
+ assertEquals(Set.of(key), result.failedKeys.keySet());
+ assertInstanceOf(IllegalStateException.class,
result.failedKeys.get(key));
+ }
+
+ private StreamsGroupDescribeResponseData.DescribedGroup
newDescribedGroup() {
+ return new StreamsGroupDescribeResponseData.DescribedGroup()
+ .setGroupId(groupId)
+ .setGroupState("Stable")
+ .setGroupEpoch(1)
+ .setAssignmentEpoch(1)
+ .setTopology(new
StreamsGroupDescribeResponseData.Topology().setEpoch(1).setSubtopologies(List.of()));
+ }
+
+ private StreamsGroupDescription
describe(StreamsGroupDescribeResponseData.DescribedGroup describedGroup) {
+ DescribeStreamsGroupsHandler handler = new
DescribeStreamsGroupsHandler(false, true, logContext);
+ CoordinatorKey key = CoordinatorKey.byGroupId(groupId);
+ AdminApiHandler.ApiResult<CoordinatorKey, StreamsGroupDescription>
result = handler.handleResponse(
+ coordinator,
+ Set.of(key),
+ new StreamsGroupDescribeResponse(new
StreamsGroupDescribeResponseData()
+ .setGroups(List.of(describedGroup))));
+ assertTrue(result.failedKeys.isEmpty(), () -> "Unexpected failures: "
+ result.failedKeys);
+ return result.completedKeys.get(key);
+ }
+}
diff --git
a/clients/src/testFixtures/java/org/apache/kafka/clients/admin/MockAdminClient.java
b/clients/src/testFixtures/java/org/apache/kafka/clients/admin/MockAdminClient.java
index bb460855df0..f2f6e4a769b 100644
---
a/clients/src/testFixtures/java/org/apache/kafka/clients/admin/MockAdminClient.java
+++
b/clients/src/testFixtures/java/org/apache/kafka/clients/admin/MockAdminClient.java
@@ -39,6 +39,7 @@ import org.apache.kafka.common.acl.AclBindingFilter;
import org.apache.kafka.common.acl.AclOperation;
import org.apache.kafka.common.config.ConfigResource;
import org.apache.kafka.common.errors.DelegationTokenNotFoundException;
+import org.apache.kafka.common.errors.GroupIdNotFoundException;
import org.apache.kafka.common.errors.InvalidPrincipalTypeException;
import org.apache.kafka.common.errors.InvalidReplicationFactorException;
import org.apache.kafka.common.errors.InvalidRequestException;
@@ -100,6 +101,7 @@ public class MockAdminClient extends AdminClient {
private final Map<String, Map<String, String>> groupConfigs;
private final Map<String, String> defaultGroupConfigs;
private final List<KafkaMetric> addedMetrics = new ArrayList<>();
+ private final Map<String, StreamsGroupDescription>
streamsGroupDescriptions = new HashMap<>();
private Node controller;
private int timeoutNextRequests = 0;
@@ -1470,11 +1472,48 @@ public class MockAdminClient extends AdminClient {
throw new UnsupportedOperationException("Not implemented yet");
}
+ /**
+ * Registers a {@link StreamsGroupDescription} to be returned by {@link
#describeStreamsGroups}.
+ */
+ public synchronized void
addStreamsGroupDescription(StreamsGroupDescription description) {
+ streamsGroupDescriptions.put(description.groupId(), description);
+ }
+
@Override
public synchronized DescribeStreamsGroupsResult
describeStreamsGroups(Collection<String> groupIds, DescribeStreamsGroupsOptions
options) {
- throw new UnsupportedOperationException("Not implemented yet");
+ Map<String, KafkaFuture<StreamsGroupDescription>> futures = new
HashMap<>();
+ for (String groupId : groupIds) {
+ KafkaFutureImpl<StreamsGroupDescription> future = new
KafkaFutureImpl<>();
+ StreamsGroupDescription description =
streamsGroupDescriptions.get(groupId);
+ if (description != null) {
+ if (!options.includeTopologyDescription()) {
+ description = withoutTopologyDescription(description);
+ }
+ future.complete(description);
+ } else {
+ future.completeExceptionally(new
GroupIdNotFoundException("Group " + groupId + " not found."));
+ }
+ futures.put(groupId, future);
+ }
+ return new DescribeStreamsGroupsResult(futures);
}
-
+
+ private static StreamsGroupDescription
withoutTopologyDescription(StreamsGroupDescription description) {
+ return new StreamsGroupDescription(
+ description.groupId(),
+ description.groupEpoch(),
+ description.targetAssignmentEpoch(),
+ description.topologyEpoch(),
+ description.subtopologies(),
+ description.members(),
+ description.groupState(),
+ description.coordinator(),
+ description.authorizedOperations(),
+ Optional.empty(),
+ StreamsGroupTopologyDescriptionStatus.NOT_REQUESTED
+ );
+ }
+
@Override
public synchronized DescribeClassicGroupsResult
describeClassicGroups(Collection<String> groupIds, DescribeClassicGroupsOptions
options) {
throw new UnsupportedOperationException("Not implemented yet");
diff --git a/docs/streams/developer-guide/kafka-streams-group-sh.md
b/docs/streams/developer-guide/kafka-streams-group-sh.md
index e11f6b178b4..fc678c889a4 100644
--- a/docs/streams/developer-guide/kafka-streams-group-sh.md
+++ b/docs/streams/developer-guide/kafka-streams-group-sh.md
@@ -41,6 +41,7 @@ A **Streams group** is a broker‑coordinated group type for
Kafka Streams that
* Group state, group epoch, target assignment epoch (with `--state`,
`--verbose` for additional details).
* Per‑member info such as epochs, current vs target assignments, and
whether a member still uses the classic protocol (with `--members` and
`--verbose`).
* Input‑topic offsets and lag (with `--offsets`), to understand how far
behind processing is.
+ * The processing topology, as recorded by the broker's topology
description plugin (with `--topology`), in a format that mirrors
`Topology#describe()`.
* **Reset input‑topic offsets** for a Streams group to control reprocessing
boundaries using precise specifiers (earliest, latest, to‑offset, to‑datetime,
by‑duration, shift‑by, from‑file). Requires `--dry-run` or `--execute` and
inactive instances.
* **Delete offsets** for input topics to force re‑consumption on next start.
* **Delete a Streams group** to clean up broker‑side Streams metadata
(offsets, topology, assignments). Optionally delete all, or a subset of,
**internal topics** at the same time using `--internal-topics`.
@@ -83,6 +84,10 @@ Inspecting group's state, members, and lag
# Describe a group: input-topic offsets and lag
kafka-streams-groups.sh --bootstrap-server localhost:9092 \
--describe --group my-streams-app --offsets
+
+ # Describe a group: processing topology
+ kafka-streams-groups.sh --bootstrap-server localhost:9092 \
+ --describe --group my-streams-app --topology
## Reset input-topic offsets (preview, then apply) {#reset-offsets}
@@ -140,7 +145,7 @@ Delete broker-side Streams metadata for a group and
optionally remove a subset o
* `--list`: List Streams groups. Use `--state` to display/filter by state.
* `--describe`: Describe a group selected by `--group`. Combine with:
- * `--state` (group state and epochs), `--members` (members and
assignments), `--offsets` (input and repartition topics offsets/lag).
+ * `--state` (group state and epochs), `--members` (members and
assignments), `--offsets` (input and repartition topics offsets/lag),
`--topology` (processing topology recorded by the broker's topology description
plugin).
* `--verbose` for additional details (e.g., leader epochs where
applicable).
* `--reset-offsets`: Reset input-topic offsets (one group at a time;
instances should be inactive). Choose exactly one specifier:
* `--to-earliest`, `--to-latest`, `--to-current`, `--to-offset <n>`
diff --git
a/tools/src/main/java/org/apache/kafka/tools/streams/StreamsGroupCommand.java
b/tools/src/main/java/org/apache/kafka/tools/streams/StreamsGroupCommand.java
index 92d03758fd0..083e71e3019 100644
---
a/tools/src/main/java/org/apache/kafka/tools/streams/StreamsGroupCommand.java
+++
b/tools/src/main/java/org/apache/kafka/tools/streams/StreamsGroupCommand.java
@@ -109,7 +109,7 @@ public class StreamsGroupCommand {
if (numberOfActions != 1)
throw new IllegalArgumentException("Command must include
exactly one action: --list, --describe, --delete, --reset-offsets, or
--delete-offsets.");
- run(opts);
+ exitCode = run(opts);
} catch (IllegalArgumentException | OptionException e) {
System.err.println(e.getMessage());
if (opts != null) {
@@ -128,12 +128,12 @@ public class StreamsGroupCommand {
return exitCode;
}
- public static void run(StreamsGroupCommandOptions opts) throws
ExecutionException, InterruptedException {
+ public static int run(StreamsGroupCommandOptions opts) throws
ExecutionException, InterruptedException {
try (StreamsGroupService streamsGroupService = new
StreamsGroupService(opts, Map.of())) {
if (opts.options.has(opts.listOpt)) {
streamsGroupService.listGroups();
} else if (opts.options.has(opts.describeOpt)) {
- streamsGroupService.describeGroups();
+ return streamsGroupService.describeGroups();
} else if (opts.options.has(opts.resetOffsetsOpt)) {
Map<String, Map<TopicPartition, OffsetAndMetadata>>
offsetsToReset = streamsGroupService.resetOffsets();
if (opts.options.has(opts.exportOpt)) {
@@ -149,6 +149,7 @@ public class StreamsGroupCommand {
throw new IllegalArgumentException("Unknown action!");
}
}
+ return 0;
}
static void printOffsetsToReset(Map<String, Map<TopicPartition,
OffsetAndMetadata>> groupAssignmentsToReset) {
@@ -262,15 +263,21 @@ public class StreamsGroupCommand {
}
}
- public void describeGroups() throws ExecutionException,
InterruptedException {
+ public int describeGroups() throws ExecutionException,
InterruptedException {
List<String> groupIds = opts.options.has(opts.allGroupsOpt)
? new ArrayList<>(listStreamsGroups())
: new ArrayList<>(opts.options.valuesOf(opts.groupOpt));
+ int exitCode = 0;
if (!groupIds.isEmpty()) {
+ boolean topology = opts.options.has(opts.topologyOpt);
for (String groupId : groupIds) {
- StreamsGroupDescription description =
getDescribeGroup(groupId);
+ StreamsGroupDescription description =
getDescribeGroup(groupId, topology);
boolean verbose = opts.options.has(opts.verboseOpt);
- if (opts.options.has(opts.membersOpt)) {
+ if (topology) {
+ if (!printTopology(description)) {
+ exitCode = 1;
+ }
+ } else if (opts.options.has(opts.membersOpt)) {
printMembers(description, verbose);
} else if (opts.options.has(opts.stateOpt)) {
printStates(description, verbose);
@@ -279,16 +286,44 @@ public class StreamsGroupCommand {
}
}
}
+ return exitCode;
}
StreamsGroupDescription getDescribeGroup(String group) throws
ExecutionException, InterruptedException {
+ return getDescribeGroup(group, false);
+ }
+
+ StreamsGroupDescription getDescribeGroup(String group, boolean
includeTopologyDescription) throws ExecutionException, InterruptedException {
DescribeStreamsGroupsResult result =
adminClient.describeStreamsGroups(
List.of(group),
- withTimeoutMs(new DescribeStreamsGroupsOptions()));
+ withTimeoutMs(new
DescribeStreamsGroupsOptions().includeTopologyDescription(includeTopologyDescription)));
Map<String, StreamsGroupDescription> descriptionMap =
result.all().get();
return descriptionMap.get(group);
}
+ /**
+ * Prints the topology description for the given group. Returns {@code
true} if a description was available and
+ * printed, {@code false} otherwise (so the caller can surface a
non-zero exit code).
+ */
+ private boolean printTopology(StreamsGroupDescription description) {
+ switch (description.topologyDescriptionStatus()) {
+ case AVAILABLE:
+
System.out.println(TopologyDescriptionFormatter.format(description.topologyDescription().orElseThrow()));
+ return true;
+ case NOT_STORED:
+ printError("No topology description is stored for streams
group '" + description.groupId() + "'.", Optional.empty());
+ return false;
+ case ERROR:
+ printError("The broker failed to fetch the topology
description for streams group '" + description.groupId()
+ + "'. See the broker logs for details.",
Optional.empty());
+ return false;
+ default:
+ printError("No topology description is available for
streams group '" + description.groupId()
+ + "' (status: " +
description.topologyDescriptionStatus() + ").", Optional.empty());
+ return false;
+ }
+ }
+
private void printMembers(StreamsGroupDescription description, boolean
verbose) {
final int groupLen = Math.max(15, description.groupId().length());
int maxMemberIdLen = 15, maxHostLen = 15, maxClientIdLen = 15;
diff --git
a/tools/src/main/java/org/apache/kafka/tools/streams/StreamsGroupCommandOptions.java
b/tools/src/main/java/org/apache/kafka/tools/streams/StreamsGroupCommandOptions.java
index 66546961164..8d2d9fb18cb 100644
---
a/tools/src/main/java/org/apache/kafka/tools/streams/StreamsGroupCommandOptions.java
+++
b/tools/src/main/java/org/apache/kafka/tools/streams/StreamsGroupCommandOptions.java
@@ -49,6 +49,8 @@ public class StreamsGroupCommandOptions extends
CommandDefaultOptions {
private static final String MEMBERS_DOC = "Describe members of the group.
This option may be used with the --describe option only.";
private static final String OFFSETS_DOC = "Describe the group and list all
topic partitions in the group along with their offset information." +
"This is the default sub-action and may be used with the --describe
option only.";
+ private static final String TOPOLOGY_DOC = "Describe the topology of the
streams group, as recorded by the broker's topology description plugin. " +
+ "This option may be used with the '--describe' option only.";
private static final String RESET_OFFSETS_DOC = "Reset offsets of streams
group. The instances should be inactive." + NL +
"Has 2 execution options: --dry-run to plan which offsets to reset,
and --execute to update the offsets." + NL +
"If you use --execute, all internal topics linked to the group will
also be deleted." + NL +
@@ -91,6 +93,7 @@ public class StreamsGroupCommandOptions extends
CommandDefaultOptions {
final OptionSpec<String> stateOpt;
final OptionSpec<Void> membersOpt;
final OptionSpec<Void> offsetsOpt;
+ final OptionSpec<Void> topologyOpt;
final OptionSpec<Void> resetOffsetsOpt;
final OptionSpec<Long> resetToOffsetOpt;
final OptionSpec<String> resetFromFileOpt;
@@ -158,6 +161,8 @@ public class StreamsGroupCommandOptions extends
CommandDefaultOptions {
.availableIf(describeOpt);
offsetsOpt = parser.accepts("offsets", OFFSETS_DOC)
.availableIf(describeOpt);
+ topologyOpt = parser.accepts("topology", TOPOLOGY_DOC)
+ .availableIf(describeOpt);
resetOffsetsOpt = parser.accepts("reset-offsets", RESET_OFFSETS_DOC);
resetToOffsetOpt = parser.accepts("to-offset", RESET_TO_OFFSET_DOC)
.withRequiredArg()
@@ -235,7 +240,7 @@ public class StreamsGroupCommandOptions extends
CommandDefaultOptions {
if ((options.has(dryRunOpt) || options.has(executeOpt)) &&
!options.has(resetOffsetsOpt))
CommandLineUtils.printUsageAndExit(parser, "Only Option " +
resetOffsetsOpt + " accepts " + executeOpt + " or " + dryRunOpt);
- CommandLineUtils.checkInvalidArgs(parser, options, listOpt,
membersOpt, offsetsOpt);
+ CommandLineUtils.checkInvalidArgs(parser, options, listOpt,
membersOpt, offsetsOpt, topologyOpt);
CommandLineUtils.checkInvalidArgs(parser, options, groupOpt,
minus(allGroupSelectionScopeOpts, groupOpt));
CommandLineUtils.checkInvalidArgs(parser, options, groupOpt,
minus(allStreamsGroupLevelOpts, describeOpt, deleteOpt, resetOffsetsOpt));
CommandLineUtils.checkInvalidArgs(parser, options, inputTopicOpt,
minus(allStreamsGroupLevelOpts, resetOffsetsOpt));
@@ -247,7 +252,7 @@ public class StreamsGroupCommandOptions extends
CommandDefaultOptions {
if (!options.has(groupOpt) && !options.has(allGroupsOpt))
CommandLineUtils.printUsageAndExit(parser,
"Option " + describeOpt + " takes one of these options: " +
allGroupSelectionScopeOpts.stream().map(Object::toString).sorted().collect(Collectors.joining(",
")));
- List<OptionSpec<?>> mutuallyExclusiveOpts = List.of(membersOpt,
offsetsOpt, stateOpt);
+ List<OptionSpec<?>> mutuallyExclusiveOpts = List.of(membersOpt,
offsetsOpt, stateOpt, topologyOpt);
if (mutuallyExclusiveOpts.stream().mapToInt(o -> options.has(o) ? 1 :
0).sum() > 1) {
CommandLineUtils.printUsageAndExit(parser,
"Option " + describeOpt + " takes at most one of these
options: " +
mutuallyExclusiveOpts.stream().map(Object::toString).sorted().collect(Collectors.joining(",
")));
diff --git
a/tools/src/main/java/org/apache/kafka/tools/streams/TopologyDescriptionFormatter.java
b/tools/src/main/java/org/apache/kafka/tools/streams/TopologyDescriptionFormatter.java
new file mode 100644
index 00000000000..970d0a6af23
--- /dev/null
+++
b/tools/src/main/java/org/apache/kafka/tools/streams/TopologyDescriptionFormatter.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.tools.streams;
+
+import org.apache.kafka.clients.admin.StreamsGroupTopologyDescription;
+import
org.apache.kafka.clients.admin.StreamsGroupTopologyDescription.GlobalStore;
+import org.apache.kafka.clients.admin.StreamsGroupTopologyDescription.Node;
+import
org.apache.kafka.clients.admin.StreamsGroupTopologyDescription.Processor;
+import org.apache.kafka.clients.admin.StreamsGroupTopologyDescription.Sink;
+import org.apache.kafka.clients.admin.StreamsGroupTopologyDescription.Source;
+import
org.apache.kafka.clients.admin.StreamsGroupTopologyDescription.Subtopology;
+
+import java.util.Collection;
+import java.util.TreeSet;
+
+/**
+ * Formats a {@link StreamsGroupTopologyDescription} as human-readable text,
mirroring the output of
+ * {@code org.apache.kafka.streams.Topology#describe()} so that users see a
familiar representation. Node names, topics,
+ * stores and the successor/predecessor relations are sorted for stable,
readable output.
+ */
+public final class TopologyDescriptionFormatter {
+
+ private TopologyDescriptionFormatter() {
+ }
+
+ public static String format(final StreamsGroupTopologyDescription
topology) {
+ final StringBuilder sb = new StringBuilder();
+ sb.append("Topologies:\n");
+ for (final Subtopology subtopology : topology.subtopologies()) {
+ sb.append(" ");
+ appendSubtopology(sb, subtopology);
+ }
+ int globalStoreId = topology.subtopologies().size();
+ for (final GlobalStore globalStore : topology.globalStores()) {
+ sb.append(" ");
+ appendGlobalStore(sb, globalStore, globalStoreId++);
+ }
+ return sb.toString();
+ }
+
+ private static void appendSubtopology(final StringBuilder sb, final
Subtopology subtopology) {
+ sb.append("Sub-topology: ").append(subtopology.id()).append('\n');
+ for (final Node node : subtopology.nodes()) {
+ sb.append(" ");
+ appendNode(sb, node);
+ sb.append('\n');
+ }
+ sb.append('\n');
+ }
+
+ private static void appendGlobalStore(final StringBuilder sb, final
GlobalStore globalStore, final int id) {
+ sb.append("Sub-topology: ").append(id).append(" for global store (will
not generate tasks)\n");
+ sb.append(" ");
+ appendNode(sb, globalStore.source());
+ sb.append('\n');
+ sb.append(" ");
+ appendNode(sb, globalStore.processor());
+ sb.append('\n');
+ sb.append('\n');
+ }
+
+ private static void appendNode(final StringBuilder sb, final Node node) {
+ if (node instanceof Source) {
+ final Source source = (Source) node;
+ sb.append("Source: ").append(source.name())
+ .append(" (topics:
").append(sorted(source.topics())).append(")")
+ .append("\n --> ").append(nodeNames(source.successors()));
+ } else if (node instanceof Processor) {
+ final Processor processor = (Processor) node;
+ sb.append("Processor: ").append(processor.name())
+ .append(" (stores:
").append(sorted(processor.stores())).append(")")
+ .append("\n -->
").append(nodeNames(processor.successors()))
+ .append("\n <--
").append(nodeNames(processor.predecessors()));
+ } else if (node instanceof Sink) {
+ final Sink sink = (Sink) node;
+ sb.append("Sink: ").append(sink.name())
+ .append(" (topic:
").append(sink.topic().orElse(null)).append(")")
+ .append("\n <-- ").append(nodeNames(sink.predecessors()));
+ } else {
+ throw new IllegalStateException("Unknown topology node type: " +
node.getClass().getName());
+ }
+ }
+
+ private static TreeSet<String> sorted(final Collection<String> values) {
+ return new TreeSet<>(values);
+ }
+
+ private static String nodeNames(final Collection<String> names) {
+ if (names.isEmpty()) {
+ return "none";
+ }
+ return String.join(", ", sorted(names));
+ }
+}
diff --git
a/tools/src/test/java/org/apache/kafka/tools/streams/StreamsGroupCommandTest.java
b/tools/src/test/java/org/apache/kafka/tools/streams/StreamsGroupCommandTest.java
index 20f6ed26e60..55e2dde048e 100644
---
a/tools/src/test/java/org/apache/kafka/tools/streams/StreamsGroupCommandTest.java
+++
b/tools/src/test/java/org/apache/kafka/tools/streams/StreamsGroupCommandTest.java
@@ -37,6 +37,8 @@ import org.apache.kafka.clients.admin.StreamsGroupDescription;
import org.apache.kafka.clients.admin.StreamsGroupMemberAssignment;
import org.apache.kafka.clients.admin.StreamsGroupMemberDescription;
import org.apache.kafka.clients.admin.StreamsGroupSubtopologyDescription;
+import org.apache.kafka.clients.admin.StreamsGroupTopologyDescription;
+import org.apache.kafka.clients.admin.StreamsGroupTopologyDescriptionStatus;
import org.apache.kafka.clients.admin.TopicDescription;
import org.apache.kafka.clients.consumer.OffsetAndMetadata;
import org.apache.kafka.common.GroupState;
@@ -48,8 +50,10 @@ import org.apache.kafka.common.TopicPartitionInfo;
import org.apache.kafka.common.errors.UnknownTopicOrPartitionException;
import org.apache.kafka.common.internals.KafkaFutureImpl;
import org.apache.kafka.test.TestUtils;
+import org.apache.kafka.tools.ToolsTestUtils;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import java.util.ArrayList;
@@ -190,6 +194,64 @@ public class StreamsGroupCommandTest {
service.close();
}
+ @Test
+ public void testDescribeStreamsGroupsWithTopologyAvailable() throws
Exception {
+ String group = "foo-group";
+ StreamsGroupTopologyDescription topology = new
StreamsGroupTopologyDescription(
+ List.of(new StreamsGroupTopologyDescription.Subtopology("0",
List.of(
+ new StreamsGroupTopologyDescription.Source("source",
Set.of("input"), Set.of("sink"), Set.of()),
+ new StreamsGroupTopologyDescription.Sink("sink",
Optional.of("output"), Set.of(), Set.of("source"))))),
+ List.of());
+ StreamsGroupDescription exp = new StreamsGroupDescription(
+ group, 0, 0, 0, List.of(), List.of(), GroupState.STABLE, new
Node(0, "bar", 0), null,
+ Optional.of(topology),
StreamsGroupTopologyDescriptionStatus.AVAILABLE);
+
+ Admin admin = mock(KafkaAdminClient.class);
+ DescribeStreamsGroupsResult result =
mock(DescribeStreamsGroupsResult.class);
+
when(result.all()).thenReturn(KafkaFuture.completedFuture(Map.of(group, exp)));
+ ArgumentCaptor<DescribeStreamsGroupsOptions> optionsCaptor =
ArgumentCaptor.forClass(DescribeStreamsGroupsOptions.class);
+ when(admin.describeStreamsGroups(anyCollection(),
optionsCaptor.capture())).thenReturn(result);
+
+ StreamsGroupCommandOptions opts = new StreamsGroupCommandOptions(
+ new String[]{"--bootstrap-server", BOOTSTRAP_SERVERS, "--group",
group, "--describe", "--topology"});
+ StreamsGroupCommand.StreamsGroupService service = new
StreamsGroupCommand.StreamsGroupService(opts, admin);
+
+ String output = ToolsTestUtils.grabConsoleOutput(() -> {
+ try {
+ assertEquals(0, service.describeGroups());
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ });
+
+ assertTrue(optionsCaptor.getValue().includeTopologyDescription(),
"Topology description should be requested.");
+ assertTrue(output.contains("Sub-topology: 0"), "Unexpected output: " +
output);
+ assertTrue(output.contains("Source: source (topics: [input])"),
"Unexpected output: " + output);
+ assertTrue(output.contains("Sink: sink (topic: output)"), "Unexpected
output: " + output);
+ service.close();
+ }
+
+ @Test
+ public void testDescribeStreamsGroupsWithTopologyNotStored() throws
Exception {
+ String group = "foo-group";
+ StreamsGroupDescription exp = new StreamsGroupDescription(
+ group, 0, 0, 0, List.of(), List.of(), GroupState.STABLE, new
Node(0, "bar", 0), null,
+ Optional.empty(),
StreamsGroupTopologyDescriptionStatus.NOT_STORED);
+
+ Admin admin = mock(KafkaAdminClient.class);
+ DescribeStreamsGroupsResult result =
mock(DescribeStreamsGroupsResult.class);
+
when(result.all()).thenReturn(KafkaFuture.completedFuture(Map.of(group, exp)));
+ when(admin.describeStreamsGroups(anyCollection(),
any(DescribeStreamsGroupsOptions.class))).thenReturn(result);
+
+ StreamsGroupCommandOptions opts = new StreamsGroupCommandOptions(
+ new String[]{"--bootstrap-server", BOOTSTRAP_SERVERS, "--group",
group, "--describe", "--topology"});
+ StreamsGroupCommand.StreamsGroupService service = new
StreamsGroupCommand.StreamsGroupService(opts, admin);
+
+ // A missing topology description must surface a non-zero exit code.
+ assertEquals(1, service.describeGroups());
+ service.close();
+ }
+
@Test
public void testDescribeStreamsGroupsGetOffsets() throws Exception {
String groupId = "group1";
diff --git
a/tools/src/test/java/org/apache/kafka/tools/streams/TopologyDescriptionFormatterTest.java
b/tools/src/test/java/org/apache/kafka/tools/streams/TopologyDescriptionFormatterTest.java
new file mode 100644
index 00000000000..b6503382c33
--- /dev/null
+++
b/tools/src/test/java/org/apache/kafka/tools/streams/TopologyDescriptionFormatterTest.java
@@ -0,0 +1,167 @@
+/*
+ * 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.tools.streams;
+
+import org.apache.kafka.clients.admin.StreamsGroupTopologyDescription;
+import
org.apache.kafka.clients.admin.StreamsGroupTopologyDescription.GlobalStore;
+import org.apache.kafka.clients.admin.StreamsGroupTopologyDescription.Node;
+import
org.apache.kafka.clients.admin.StreamsGroupTopologyDescription.Processor;
+import org.apache.kafka.clients.admin.StreamsGroupTopologyDescription.Sink;
+import org.apache.kafka.clients.admin.StreamsGroupTopologyDescription.Source;
+import
org.apache.kafka.clients.admin.StreamsGroupTopologyDescription.Subtopology;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class TopologyDescriptionFormatterTest {
+
+ @Test
+ public void testFormatSimpleSubtopology() {
+ Source source = new Source("source", Set.of("input"),
Set.of("processor"), Set.of());
+ Processor processor = new Processor("processor", Set.of("store"),
Set.of("sink"), Set.of("source"));
+ Sink sink = new Sink("sink", Optional.of("output"), Set.of(),
Set.of("processor"));
+ StreamsGroupTopologyDescription topology = new
StreamsGroupTopologyDescription(
+ List.of(new Subtopology("0", List.<Node>of(source, processor,
sink))),
+ List.of());
+
+ String expected = "Topologies:\n" +
+ " Sub-topology: 0\n" +
+ " Source: source (topics: [input])\n" +
+ " --> processor\n" +
+ " Processor: processor (stores: [store])\n" +
+ " --> sink\n" +
+ " <-- source\n" +
+ " Sink: sink (topic: output)\n" +
+ " <-- processor\n" +
+ "\n";
+
+ assertEquals(expected, TopologyDescriptionFormatter.format(topology));
+ }
+
+ @Test
+ public void testFormatGlobalStore() {
+ Source source = new Source("global-source", Set.of("global-topic"),
Set.of("global-processor"), Set.of());
+ Processor processor = new Processor("global-processor",
Set.of("global-store"), Set.of(), Set.of("global-source"));
+ StreamsGroupTopologyDescription topology = new
StreamsGroupTopologyDescription(
+ List.of(),
+ List.of(new GlobalStore(source, processor)));
+
+ String expected = "Topologies:\n" +
+ " Sub-topology: 0 for global store (will not generate tasks)\n" +
+ " Source: global-source (topics: [global-topic])\n" +
+ " --> global-processor\n" +
+ " Processor: global-processor (stores: [global-store])\n" +
+ " --> none\n" +
+ " <-- global-source\n" +
+ "\n";
+
+ assertEquals(expected, TopologyDescriptionFormatter.format(topology));
+ }
+
+ @Test
+ public void testFormatMultipleGlobalStoresAreSeparatedByBlankLine() {
+ Source source0 = new Source("global-source-0", Set.of("topic-0"),
Set.of("global-processor-0"), Set.of());
+ Processor processor0 = new Processor("global-processor-0",
Set.of("store-0"), Set.of(), Set.of("global-source-0"));
+ Source source1 = new Source("global-source-1", Set.of("topic-1"),
Set.of("global-processor-1"), Set.of());
+ Processor processor1 = new Processor("global-processor-1",
Set.of("store-1"), Set.of(), Set.of("global-source-1"));
+ StreamsGroupTopologyDescription topology = new
StreamsGroupTopologyDescription(
+ List.of(),
+ List.of(new GlobalStore(source0, processor0), new
GlobalStore(source1, processor1)));
+
+ assertEquals("Topologies:\n" +
+ " Sub-topology: 0 for global store (will not generate tasks)\n" +
+ " Source: global-source-0 (topics: [topic-0])\n" +
+ " --> global-processor-0\n" +
+ " Processor: global-processor-0 (stores: [store-0])\n" +
+ " --> none\n" +
+ " <-- global-source-0\n" +
+ "\n" +
+ " Sub-topology: 1 for global store (will not generate tasks)\n" +
+ " Source: global-source-1 (topics: [topic-1])\n" +
+ " --> global-processor-1\n" +
+ " Processor: global-processor-1 (stores: [store-1])\n" +
+ " --> none\n" +
+ " <-- global-source-1\n" +
+ "\n", TopologyDescriptionFormatter.format(topology));
+ }
+
+ @Test
+ public void testFormatMultipleSubtopologiesAreIndentedConsistently() {
+ Source source0 = new Source("source-0", Set.of("input-0"), Set.of(),
Set.of());
+ Source source1 = new Source("source-1", Set.of("input-1"), Set.of(),
Set.of());
+ StreamsGroupTopologyDescription topology = new
StreamsGroupTopologyDescription(
+ List.of(
+ new Subtopology("0", List.<Node>of(source0)),
+ new Subtopology("1", List.<Node>of(source1))),
+ List.of());
+
+ assertEquals("Topologies:\n" +
+ " Sub-topology: 0\n" +
+ " Source: source-0 (topics: [input-0])\n" +
+ " --> none\n" +
+ "\n" +
+ " Sub-topology: 1\n" +
+ " Source: source-1 (topics: [input-1])\n" +
+ " --> none\n" +
+ "\n", TopologyDescriptionFormatter.format(topology));
+ }
+
+ @Test
+ public void testFormatRendersEmptySuccessorsAndPredecessorsAsNone() {
+ Processor processor = new Processor("processor", Set.of("store"),
Set.of(), Set.of());
+ StreamsGroupTopologyDescription topology = new
StreamsGroupTopologyDescription(
+ List.of(new Subtopology("0", List.<Node>of(processor))),
+ List.of());
+
+ assertEquals("Topologies:\n" +
+ " Sub-topology: 0\n" +
+ " Processor: processor (stores: [store])\n" +
+ " --> none\n" +
+ " <-- none\n" +
+ "\n", TopologyDescriptionFormatter.format(topology));
+ }
+
+ @Test
+ public void testFormatEmptyTopologyHasNoTrailingSpace() {
+ StreamsGroupTopologyDescription topology = new
StreamsGroupTopologyDescription(List.of(), List.of());
+ assertEquals("Topologies:\n",
TopologyDescriptionFormatter.format(topology));
+ }
+
+ @Test
+ public void testFormatSinkWithoutTopicAndMultipleSuccessors() {
+ Source source = new Source("source", Set.of("t2", "t1"), Set.of("p2",
"p1"), Set.of());
+ Sink sink = new Sink("sink", Optional.empty(), Set.of(),
Set.of("source"));
+ StreamsGroupTopologyDescription topology = new
StreamsGroupTopologyDescription(
+ List.of(new Subtopology("0", List.<Node>of(source, sink))),
+ List.of());
+
+ String formatted = TopologyDescriptionFormatter.format(topology);
+
+ assertEquals("Topologies:\n" +
+ " Sub-topology: 0\n" +
+ " Source: source (topics: [t1, t2])\n" +
+ " --> p1, p2\n" +
+ " Sink: sink (topic: null)\n" +
+ " <-- source\n" +
+ "\n", formatted);
+ }
+}