Copilot commented on code in PR #22636:
URL: https://github.com/apache/kafka/pull/22636#discussion_r3458298087


##########
clients/src/main/java/org/apache/kafka/clients/admin/internals/DescribeStreamsGroupsHandler.java:
##########
@@ -189,6 +224,84 @@ private Map<String, 
StreamsGroupSubtopologyDescription.TopicInfo> convertTopicIn
         ));
     }
 
+    private Optional<StreamsGroupTopologyDescription> 
convertTopologyDescription(
+            final StreamsGroupTopologyDescriptionStatus status,
+            final StreamsGroupDescribeResponseData.TopologyDescription 
topologyDescription) {
+        // The description is present if and only if the status is AVAILABLE.
+        if (status != StreamsGroupTopologyDescriptionStatus.AVAILABLE || 
topologyDescription == null) {
+            return Optional.empty();
+        }

Review Comment:
   If the broker returns status AVAILABLE but omits the topology description 
field (null), this code returns Optional.empty() while still setting 
topologyDescriptionStatus=AVAILABLE. That violates the StreamsGroupDescription 
contract ("present iff AVAILABLE") and can lead to downstream 
NoSuchElementException (e.g., CLI uses orElseThrow()). Treat AVAILABLE+null as 
a malformed response and fail the group.



##########
tools/src/main/java/org/apache/kafka/tools/streams/TopologyDescriptionFormatter.java:
##########
@@ -0,0 +1,105 @@
+/*
+ * 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);
+        }
+        // The wire format does not carry global store ids, so number them 
sequentially after the subtopologies.
+        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');
+    }
+
+    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) {
+        return String.join(", ", sorted(names));
+    }

Review Comment:
   To actually mirror Kafka Streams' `Topology#describe()` output, empty 
successor/predecessor lists should be rendered as "none" (see 
streams/processor/internals/InternalTopologyBuilder#nodeNames), not as an empty 
string. The current implementation also leaves trailing whitespace on lines 
like "--> ".



##########
tools/src/test/java/org/apache/kafka/tools/streams/TopologyDescriptionFormatterTest.java:
##########
@@ -0,0 +1,126 @@
+/*
+ * 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" +
+            "      --> \n" +
+            "      <-- global-source\n";

Review Comment:
   This expected output should match the formatter's behavior for empty 
successor lists (Kafka Streams prints "none" rather than a blank after "-->" 
when there are no successors). Update the expectation to avoid asserting a 
trailing whitespace-only line.



##########
tools/src/test/java/org/apache/kafka/tools/streams/TopologyDescriptionFormatterTest.java:
##########
@@ -0,0 +1,126 @@
+/*
+ * 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" +
+            "      --> \n" +
+            "      <-- global-source\n";
+
+        assertEquals(expected, 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());
+
+        // Both subtopologies are prefixed with the same three-space 
indentation.
+        assertEquals("Topologies:\n" +
+            "   Sub-topology: 0\n" +
+            "    Source: source-0 (topics: [input-0])\n" +
+            "      --> \n" +
+            "\n" +

Review Comment:
   This expectation should use "--> none" for a source node with no successors 
to match `TopologyDescriptionFormatter` (and Kafka Streams' 
`Topology#describe()`) output.



##########
tools/src/test/java/org/apache/kafka/tools/streams/TopologyDescriptionFormatterTest.java:
##########
@@ -0,0 +1,126 @@
+/*
+ * 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" +
+            "      --> \n" +
+            "      <-- global-source\n";
+
+        assertEquals(expected, 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());
+
+        // Both subtopologies are prefixed with the same three-space 
indentation.
+        assertEquals("Topologies:\n" +
+            "   Sub-topology: 0\n" +
+            "    Source: source-0 (topics: [input-0])\n" +
+            "      --> \n" +
+            "\n" +
+            "   Sub-topology: 1\n" +
+            "    Source: source-1 (topics: [input-1])\n" +
+            "      --> \n" +
+            "\n", TopologyDescriptionFormatter.format(topology));

Review Comment:
   Same as above: for a source with no successors, Kafka Streams prints "--> 
none" rather than leaving the arrow line blank.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to