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 9391bdd4f41 KAFKA-20625: Add 
StreamsGroupTopologyDescriptionRequestManager and Streams client topology push 
[1/N] (#22639)
9391bdd4f41 is described below

commit 9391bdd4f4179f06a77191d5ce05b42d4a43f466
Author: Lucy Liu <[email protected]>
AuthorDate: Tue Jun 23 16:46:54 2026 -0500

    KAFKA-20625: Add StreamsGroupTopologyDescriptionRequestManager and Streams 
client topology push [1/N] (#22639)
    
    ## Summary
    Adds client-side logic to convert a Kafka Streams topology to wire
    format and stash it on `StreamsRebalanceData` at `StreamThread` startup.
    
    ## File changes
    - **`StreamsRebalanceData`**: three new fields
    `wireTopologyDescription`, `memberId`, `topologyPushRequired` with
    setters/getters and a unit test.
    - **`StreamsConfig`**: new client config
    `topology.description.push.enabled` (boolean, default `true`).
    - **`TopologyDescriptionConverter`**: new internal helper that converts
    `TopologyDescription` to
    `StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription`,
    with 4 unit tests covering multi-subtopology, branching, stateful
    processor + global store, and dynamic sink topic.
    - **`StreamThread.initStreamsRebalanceData`**: calls the converter at
    startup and stashes the result on `StreamsRebalanceData` when the config
    is enabled.
    
    Reviewers: Lucas Brutschy <[email protected]>
---
 .../consumer/internals/StreamsRebalanceData.java   |  20 ++
 .../internals/StreamsRebalanceDataTest.java        |  27 ++
 .../org/apache/kafka/streams/StreamsConfig.java    |  13 +-
 .../internals/InternalTopologyBuilder.java         |  12 +
 .../streams/processor/internals/StreamThread.java  |  14 +-
 .../internals/TopologyDescriptionConverter.java    | 109 ++++++
 .../processor/internals/StreamThreadTest.java      |   4 +
 .../TopologyDescriptionConverterTest.java          | 366 +++++++++++++++++++++
 8 files changed, 563 insertions(+), 2 deletions(-)

diff --git 
a/clients/src/main/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceData.java
 
b/clients/src/main/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceData.java
index 286bcdb540c..e1b59a964b2 100644
--- 
a/clients/src/main/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceData.java
+++ 
b/clients/src/main/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceData.java
@@ -18,6 +18,7 @@ package org.apache.kafka.clients.consumer.internals;
 
 import org.apache.kafka.common.TopicPartition;
 import org.apache.kafka.common.message.StreamsGroupHeartbeatResponseData;
+import 
org.apache.kafka.common.message.StreamsGroupTopologyDescriptionUpdateRequestData;
 
 import java.util.ArrayList;
 import java.util.Collection;
@@ -354,6 +355,10 @@ public class StreamsRebalanceData {
 
     private final AtomicLong acceptableRecoveryLag = new AtomicLong(-1);
 
+    private final 
AtomicReference<StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription>
 wireTopologyDescription = new AtomicReference<>(null);
+
+    private final AtomicBoolean topologyPushRequired = new 
AtomicBoolean(false);
+
     public StreamsRebalanceData(final UUID processId,
                                 final Optional<HostInfo> endpoint,
                                 final Optional<String> rackId,
@@ -474,4 +479,19 @@ public class StreamsRebalanceData {
         return acceptableRecoveryLag.get();
     }
 
+    public void setWireTopologyDescription(final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription 
wireDescription) {
+        wireTopologyDescription.set(wireDescription);
+    }
+
+    public 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription 
wireTopologyDescription() {
+        return wireTopologyDescription.get();
+    }
+
+    public void setTopologyPushRequired(final boolean topologyPushRequired) {
+        this.topologyPushRequired.set(topologyPushRequired);
+    }
+
+    public boolean topologyPushRequired() {
+        return topologyPushRequired.get();
+    }
 }
diff --git 
a/clients/src/test/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceDataTest.java
 
b/clients/src/test/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceDataTest.java
index 92126a224d7..ea56a1fe6fa 100644
--- 
a/clients/src/test/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceDataTest.java
+++ 
b/clients/src/test/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceDataTest.java
@@ -16,6 +16,8 @@
  */
 package org.apache.kafka.clients.consumer.internals;
 
+import 
org.apache.kafka.common.message.StreamsGroupTopologyDescriptionUpdateRequestData;
+
 import org.junit.jupiter.api.Test;
 
 import java.util.ArrayList;
@@ -32,6 +34,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -532,4 +536,27 @@ public class StreamsRebalanceDataTest {
         assertEquals(1000, streamsRebalanceData.heartbeatIntervalMs());
     }
 
+    @Test
+    public void streamsRebalanceDataShouldDefaultAndUpdateTopologyPushFields() 
{
+        final StreamsRebalanceData streamsRebalanceData = new 
StreamsRebalanceData(
+                UUID.randomUUID(),
+                Optional.of(new StreamsRebalanceData.HostInfo("localhost", 
9090)),
+                Optional.empty(),
+                Map.of(),
+                Map.of("clientTag1", "clientTagValue1"),
+                Map::of
+        );
+
+        assertNull(streamsRebalanceData.wireTopologyDescription());
+        assertFalse(streamsRebalanceData.topologyPushRequired());
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription wire =
+                new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription();
+        streamsRebalanceData.setWireTopologyDescription(wire);
+        streamsRebalanceData.setTopologyPushRequired(true);
+
+        assertSame(wire, streamsRebalanceData.wireTopologyDescription());
+        assertTrue(streamsRebalanceData.topologyPushRequired());
+    }
+
 }
diff --git a/streams/src/main/java/org/apache/kafka/streams/StreamsConfig.java 
b/streams/src/main/java/org/apache/kafka/streams/StreamsConfig.java
index d6099f4cec8..54dcb726c47 100644
--- a/streams/src/main/java/org/apache/kafka/streams/StreamsConfig.java
+++ b/streams/src/main/java/org/apache/kafka/streams/StreamsConfig.java
@@ -912,6 +912,12 @@ public class StreamsConfig extends AbstractConfig {
             "Whether to use the configured <code>" + 
PROCESSING_EXCEPTION_HANDLER_CLASS_CONFIG + "</code> during global store/KTable 
processing. " +
                     "Disabled by default. This config will be removed in Kafka 
Streams 5.0, where global exception handling will be enabled by default";
 
+    /** {@code topology.description.push.enabled} */
+    public static final String TOPOLOGY_DESCRIPTION_PUSH_ENABLED_CONFIG = 
"topology.description.push.enabled";
+    private static final String TOPOLOGY_DESCRIPTION_PUSH_ENABLED_DOC = 
"Controls whether the Kafka Streams client sends topology descriptions to the 
broker when requested. " +
+        "When set to false, the client will not prepare or push topology 
descriptions. " +
+        "Enabled by default.";
+
     static {
         CONFIG = new ConfigDef()
 
@@ -1359,7 +1365,12 @@ public class StreamsConfig extends AbstractConfig {
                     Type.LONG,
                     null,
                     Importance.LOW,
-                    WINDOW_SIZE_MS_DOC);
+                    WINDOW_SIZE_MS_DOC)
+            .define(TOPOLOGY_DESCRIPTION_PUSH_ENABLED_CONFIG,
+                    Type.BOOLEAN,
+                    true,
+                    Importance.MEDIUM,
+                    TOPOLOGY_DESCRIPTION_PUSH_ENABLED_DOC);
     }
 
     // this is the list of configs for underlying clients
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/processor/internals/InternalTopologyBuilder.java
 
b/streams/src/main/java/org/apache/kafka/streams/processor/internals/InternalTopologyBuilder.java
index a19bbcc50eb..5b8e2642dcb 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/processor/internals/InternalTopologyBuilder.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/processor/internals/InternalTopologyBuilder.java
@@ -1457,6 +1457,18 @@ public class InternalTopologyBuilder {
         return decorateTopic(topic);
     }
 
+    /**
+     * If {@code topic} is an internally-managed topic in this topology (a 
repartition topic,
+     * a changelog topic, etc.), return the decorated name with the 
applicationId prefix.
+     * Otherwise return the topic name unchanged.
+     */
+    public String maybeDecorateInternalTopic(final String topic) {
+        if (topic != null && 
internalTopicNamesWithProperties.containsKey(topic)) {
+            return decorateTopic(topic);
+        }
+        return topic;
+    }
+
     @SuppressWarnings("deprecation")
     private String decorateTopic(final String topic) {
         if (applicationId == null) {
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamThread.java
 
b/streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamThread.java
index 0433d7fb625..fb84041fe41 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamThread.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamThread.java
@@ -58,6 +58,7 @@ import org.apache.kafka.streams.StreamsConfig;
 import org.apache.kafka.streams.StreamsConfig.InternalConfig;
 import org.apache.kafka.streams.TaskMetadata;
 import org.apache.kafka.streams.ThreadMetadata;
+import org.apache.kafka.streams.TopologyDescription;
 import org.apache.kafka.streams.errors.MissingSourceTopicException;
 import org.apache.kafka.streams.errors.StreamsException;
 import org.apache.kafka.streams.errors.TaskCorruptedException;
@@ -706,7 +707,7 @@ public class StreamThread extends Thread implements 
ProcessingThread {
 
         final Map<String, StreamsRebalanceData.Subtopology> subtopologies = 
initBrokerTopology(config, internalTopologyBuilder);
 
-        return new StreamsRebalanceData(
+        final StreamsRebalanceData streamsRebalanceData = new 
StreamsRebalanceData(
             processId,
             endpoint,
             rackId,
@@ -714,6 +715,17 @@ public class StreamThread extends Thread implements 
ProcessingThread {
             config.getClientTags(),
             taskOffsetSum
         );
+
+        if 
(config.getBoolean(StreamsConfig.TOPOLOGY_DESCRIPTION_PUSH_ENABLED_CONFIG)) {
+            final TopologyDescription description = 
internalTopologyBuilder.describe();
+            streamsRebalanceData.setWireTopologyDescription(
+                TopologyDescriptionConverter.toWire(
+                    description,
+                    internalTopologyBuilder::maybeDecorateInternalTopic)
+            );
+        }
+
+        return streamsRebalanceData;
     }
 
     private static Map<String, StreamsRebalanceData.Subtopology> 
initBrokerTopology(final StreamsConfig config,
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/processor/internals/TopologyDescriptionConverter.java
 
b/streams/src/main/java/org/apache/kafka/streams/processor/internals/TopologyDescriptionConverter.java
new file mode 100644
index 00000000000..5c71ad725df
--- /dev/null
+++ 
b/streams/src/main/java/org/apache/kafka/streams/processor/internals/TopologyDescriptionConverter.java
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.processor.internals;
+
+import 
org.apache.kafka.common.message.StreamsGroupTopologyDescriptionUpdateRequestData;
+import org.apache.kafka.streams.TopologyDescription;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+public final class TopologyDescriptionConverter {
+
+    private static final byte NODE_TYPE_SOURCE = 1;
+    private static final byte NODE_TYPE_PROCESSOR = 2;
+    private static final byte NODE_TYPE_SINK = 3;
+
+    private TopologyDescriptionConverter() {}
+
+    public static 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription 
toWire(final TopologyDescription description,
+                                                                               
               final Function<String, String> topicNameDecorator) {
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription wire =
+            new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription();
+
+        wire.setSubtopologies(description.subtopologies().stream()
+            
.sorted(Comparator.comparingInt(TopologyDescription.Subtopology::id))
+            .map(s -> toWireSubtopology(s, topicNameDecorator))
+            .collect(Collectors.toList()));
+
+        wire.setGlobalStores(description.globalStores().stream()
+            
.sorted(Comparator.comparingInt(TopologyDescription.GlobalStore::id))
+            .map(g -> toWireGlobalStore(g, topicNameDecorator))
+            .collect(Collectors.toList()));
+
+        return wire;
+    }
+
+    private static 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionSubtopology 
toWireSubtopology(
+            final TopologyDescription.Subtopology subtopology,
+            final Function<String, String> topicNameDecorator) {
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionSubtopology 
wire =
+                new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionSubtopology();
+        wire.setSubtopologyId(String.valueOf(subtopology.id()));
+        wire.setNodes(subtopology.nodes().stream()
+                .sorted(Comparator.comparing(TopologyDescription.Node::name))
+                .map(n -> toWireNode(n, topicNameDecorator))
+                .collect(Collectors.toList()));
+        return wire;
+    }
+
+    private static 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
toWireNode(
+            final TopologyDescription.Node node,
+            final Function<String, String> topicNameDecorator) {
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode wire =
+                new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode();
+        wire.setName(node.name());
+        wire.setSuccessors(node.successors().stream()
+                .map(TopologyDescription.Node::name)
+                .sorted()
+                .collect(Collectors.toList()));
+
+        if (node instanceof TopologyDescription.Source) {
+            final TopologyDescription.Source source = 
(TopologyDescription.Source) node;
+            wire.setNodeType(NODE_TYPE_SOURCE);
+            wire.setSourceTopics(source.topicSet() == null
+                    ? new ArrayList<>()
+                    : source.topicSet().stream()
+                        .map(topicNameDecorator)
+                        .sorted()
+                        .collect(Collectors.toList()));
+        } else if (node instanceof TopologyDescription.Sink) {
+            final TopologyDescription.Sink sink = (TopologyDescription.Sink) 
node;
+            wire.setNodeType(NODE_TYPE_SINK);
+            wire.setSinkTopic(topicNameDecorator.apply(sink.topic()));
+        } else if (node instanceof TopologyDescription.Processor) {
+            final TopologyDescription.Processor processor = 
(TopologyDescription.Processor) node;
+            wire.setNodeType(NODE_TYPE_PROCESSOR);
+            
wire.setStores(processor.stores().stream().sorted().collect(Collectors.toList()));
+        } else {
+            throw new IllegalStateException("Unknown node type: " + 
node.getClass().getName());
+        }
+        return wire;
+    }
+
+    private static 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionGlobalStore 
toWireGlobalStore(
+            final TopologyDescription.GlobalStore globalStore,
+            final Function<String, String> topicNameDecorator) {
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionGlobalStore 
wire =
+                new 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionGlobalStore();
+        wire.setSource(toWireNode(globalStore.source(), topicNameDecorator));
+        wire.setProcessor(toWireNode(globalStore.processor(), 
topicNameDecorator));
+        return wire;
+    }
+}
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/processor/internals/StreamThreadTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/processor/internals/StreamThreadTest.java
index dbff3d6933f..22415ea0225 100644
--- 
a/streams/src/test/java/org/apache/kafka/streams/processor/internals/StreamThreadTest.java
+++ 
b/streams/src/test/java/org/apache/kafka/streams/processor/internals/StreamThreadTest.java
@@ -3854,6 +3854,10 @@ public class StreamThreadTest {
             )
         ));
         
when(topologyBuilder.copartitionGroups()).thenReturn(Set.of(Set.of("source1")));
+        final InternalTopologyBuilder.TopologyDescription mockDescription = 
mock(InternalTopologyBuilder.TopologyDescription.class);
+        
when(mockDescription.subtopologies()).thenReturn(Collections.emptySet());
+        
when(mockDescription.globalStores()).thenReturn(Collections.emptySet());
+        when(topologyBuilder.describe()).thenReturn(mockDescription);
 
         final StreamsMetricsImpl streamsMetrics = new StreamsMetricsImpl(
             metrics,
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/processor/internals/TopologyDescriptionConverterTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/processor/internals/TopologyDescriptionConverterTest.java
new file mode 100644
index 00000000000..c65b7481cb1
--- /dev/null
+++ 
b/streams/src/test/java/org/apache/kafka/streams/processor/internals/TopologyDescriptionConverterTest.java
@@ -0,0 +1,366 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.processor.internals;
+
+import 
org.apache.kafka.common.message.StreamsGroupTopologyDescriptionUpdateRequestData;
+import org.apache.kafka.streams.TopologyDescription;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Set;
+import java.util.function.Function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class TopologyDescriptionConverterTest {
+
+    /**
+     * Test the situation when the topology has multiple subtopologies, 
including one with two
+     * source nodes feeding the same processor: subtopologies should be sorted 
by id and each node
+     * should be emitted with its correct type, topics, and successors.
+     */
+    @Test
+    public void shouldConvertMultipleSubtopologiesWithMultiSourceProcessor() {
+        final TopologyDescription.Source source0 = 
mock(TopologyDescription.Source.class);
+        final TopologyDescription.Sink sink0 = 
mock(TopologyDescription.Sink.class);
+        when(source0.name()).thenReturn("source-0");
+        when(source0.topicSet()).thenReturn(Set.of("topic-0-in"));
+        when(source0.successors()).thenReturn(Set.of(sink0));
+        when(sink0.name()).thenReturn("sink-0");
+        when(sink0.topic()).thenReturn("topic-0-out");
+        when(sink0.successors()).thenReturn(Set.of());
+
+        final TopologyDescription.Subtopology subtopology0 = 
mock(TopologyDescription.Subtopology.class);
+        when(subtopology0.id()).thenReturn(0);
+        when(subtopology0.nodes()).thenReturn(Set.of(source0, sink0));
+
+        final TopologyDescription.Source source1a = 
mock(TopologyDescription.Source.class);
+        final TopologyDescription.Source source1b = 
mock(TopologyDescription.Source.class);
+        final TopologyDescription.Processor processor1 = 
mock(TopologyDescription.Processor.class);
+        final TopologyDescription.Sink sink1 = 
mock(TopologyDescription.Sink.class);
+
+        when(source1a.name()).thenReturn("source-1-a");
+        when(source1a.topicSet()).thenReturn(Set.of("topic-1-z", "topic-1-a"));
+        when(source1a.successors()).thenReturn(Set.of(processor1));
+
+        when(source1b.name()).thenReturn("source-1-b");
+        when(source1b.topicSet()).thenReturn(Set.of("topic-1-m"));
+        when(source1b.successors()).thenReturn(Set.of(processor1));
+
+        when(processor1.name()).thenReturn("processor-1");
+        when(processor1.stores()).thenReturn(Set.of());
+        when(processor1.successors()).thenReturn(Set.of(sink1));
+
+        when(sink1.name()).thenReturn("sink-1");
+        when(sink1.topic()).thenReturn("topic-1-out");
+        when(sink1.successors()).thenReturn(Set.of());
+
+        final TopologyDescription.Subtopology subtopology1 = 
mock(TopologyDescription.Subtopology.class);
+        when(subtopology1.id()).thenReturn(1);
+        when(subtopology1.nodes()).thenReturn(Set.of(source1a, source1b, 
processor1, sink1));
+
+        final TopologyDescription description = 
mock(TopologyDescription.class);
+        when(description.subtopologies()).thenReturn(Set.of(subtopology1, 
subtopology0));
+        when(description.globalStores()).thenReturn(Set.of());
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription wire =
+            TopologyDescriptionConverter.toWire(description, 
Function.identity());
+
+        assertTrue(wire.globalStores().isEmpty());
+        assertEquals(2, wire.subtopologies().size());
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionSubtopology 
wireSub0 = wire.subtopologies().get(0);
+        assertEquals("0", wireSub0.subtopologyId());
+        assertEquals(2, wireSub0.nodes().size());
+        assertEquals("sink-0", wireSub0.nodes().get(0).name());
+        assertEquals("source-0", wireSub0.nodes().get(1).name());
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionSubtopology 
wireSub1 = wire.subtopologies().get(1);
+        assertEquals("1", wireSub1.subtopologyId());
+        assertEquals(4, wireSub1.nodes().size());
+
+        final 
List<StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode> 
nodes = wireSub1.nodes();
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
wireProcessor1 = nodes.get(0);
+        assertEquals("processor-1", wireProcessor1.name());
+        assertEquals((byte) 2, wireProcessor1.nodeType());
+        assertEquals(List.of(), wireProcessor1.stores());
+        assertEquals(List.of("sink-1"), wireProcessor1.successors());
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
wireSink1 = nodes.get(1);
+        assertEquals("sink-1", wireSink1.name());
+        assertEquals((byte) 3, wireSink1.nodeType());
+        assertEquals("topic-1-out", wireSink1.sinkTopic());
+        assertEquals(List.of(), wireSink1.successors());
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
wireSource1a = nodes.get(2);
+        assertEquals("source-1-a", wireSource1a.name());
+        assertEquals((byte) 1, wireSource1a.nodeType());
+        assertEquals(List.of("topic-1-a", "topic-1-z"), 
wireSource1a.sourceTopics());
+        assertEquals(List.of("processor-1"), wireSource1a.successors());
+        assertNull(wireSource1a.sinkTopic());
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
wireSource1b = nodes.get(3);
+        assertEquals("source-1-b", wireSource1b.name());
+        assertEquals((byte) 1, wireSource1b.nodeType());
+        assertEquals(List.of("topic-1-m"), wireSource1b.sourceTopics());
+        assertEquals(List.of("processor-1"), wireSource1b.successors());
+    }
+
+    /**
+     * Test the situation when a single source node branches into multiple 
processors: the source's
+     * successors list should contain all branch processors sorted 
alphabetically.
+     */
+    @Test
+    public void shouldConvertBranchTopology() {
+        final TopologyDescription.Source source = 
mock(TopologyDescription.Source.class);
+        final TopologyDescription.Processor processor1 = 
mock(TopologyDescription.Processor.class);
+        final TopologyDescription.Processor processor2 = 
mock(TopologyDescription.Processor.class);
+        final TopologyDescription.Sink sink = 
mock(TopologyDescription.Sink.class);
+
+        when(source.name()).thenReturn("source");
+        when(source.topicSet()).thenReturn(Set.of("input-topic"));
+        when(source.successors()).thenReturn(Set.of(processor1, processor2));
+
+        when(processor1.name()).thenReturn("processor-1");
+        when(processor1.stores()).thenReturn(Set.of());
+        when(processor1.successors()).thenReturn(Set.of(sink));
+
+        when(processor2.name()).thenReturn("processor-2");
+        when(processor2.stores()).thenReturn(Set.of());
+        when(processor2.successors()).thenReturn(Set.of(sink));
+
+        when(sink.name()).thenReturn("sink");
+        when(sink.topic()).thenReturn("output-topic");
+        when(sink.successors()).thenReturn(Set.of());
+
+        final TopologyDescription.Subtopology subtopology = 
mock(TopologyDescription.Subtopology.class);
+        when(subtopology.id()).thenReturn(0);
+        when(subtopology.nodes()).thenReturn(Set.of(source, processor1, 
processor2, sink));
+
+        final TopologyDescription description = 
mock(TopologyDescription.class);
+        when(description.subtopologies()).thenReturn(Set.of(subtopology));
+        when(description.globalStores()).thenReturn(Set.of());
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription wire =
+            TopologyDescriptionConverter.toWire(description, 
Function.identity());
+
+        assertTrue(wire.globalStores().isEmpty());
+        assertEquals(1, wire.subtopologies().size());
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionSubtopology 
wireSub = wire.subtopologies().get(0);
+        assertEquals("0", wireSub.subtopologyId());
+        assertEquals(4, wireSub.nodes().size());
+
+        final 
List<StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode> 
nodes = wireSub.nodes();
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
wireProcessor1 = nodes.get(0);
+        assertEquals("processor-1", wireProcessor1.name());
+        assertEquals((byte) 2, wireProcessor1.nodeType());
+        assertEquals(List.of(), wireProcessor1.stores());
+        assertEquals(List.of("sink"), wireProcessor1.successors());
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
wireProcessor2 = nodes.get(1);
+        assertEquals("processor-2", wireProcessor2.name());
+        assertEquals((byte) 2, wireProcessor2.nodeType());
+        assertEquals(List.of(), wireProcessor2.stores());
+        assertEquals(List.of("sink"), wireProcessor2.successors());
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
wireSink = nodes.get(2);
+        assertEquals("sink", wireSink.name());
+        assertEquals((byte) 3, wireSink.nodeType());
+        assertEquals("output-topic", wireSink.sinkTopic());
+        assertEquals(List.of(), wireSink.successors());
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
wireSource = nodes.get(3);
+        assertEquals("source", wireSource.name());
+        assertEquals((byte) 1, wireSource.nodeType());
+        assertEquals(List.of("input-topic"), wireSource.sourceTopics());
+        assertEquals(List.of("processor-1", "processor-2"), 
wireSource.successors());
+        assertNull(wireSource.sinkTopic());
+    }
+
+    /**
+     * Test the situation when the topology contains a stateful processor and 
a global store: state
+     * store names should appear on the processor, and the global store should 
be emitted under
+     * globalStores rather than subtopologies.
+     */
+    @Test
+    public void shouldConvertTopologyWithStatefulProcessorAndGlobalStore() {
+        final TopologyDescription.Source source = 
mock(TopologyDescription.Source.class);
+        final TopologyDescription.Processor processor = 
mock(TopologyDescription.Processor.class);
+        final TopologyDescription.Sink sink = 
mock(TopologyDescription.Sink.class);
+
+        when(source.name()).thenReturn("source");
+        when(source.topicSet()).thenReturn(Set.of("input-topic"));
+        when(source.successors()).thenReturn(Set.of(processor));
+
+        when(processor.name()).thenReturn("processor");
+        when(processor.stores()).thenReturn(Set.of("store-1", "store-2"));
+        when(processor.successors()).thenReturn(Set.of(sink));
+
+        when(sink.name()).thenReturn("sink");
+        when(sink.topic()).thenReturn("output-topic");
+        when(sink.successors()).thenReturn(Set.of());
+
+        final TopologyDescription.Source globalSource = 
mock(TopologyDescription.Source.class);
+        when(globalSource.name()).thenReturn("global-source");
+        when(globalSource.topicSet()).thenReturn(Set.of("global-topic"));
+        when(globalSource.successors()).thenReturn(Set.of());
+
+        final TopologyDescription.Processor globalProcessor = 
mock(TopologyDescription.Processor.class);
+        when(globalProcessor.name()).thenReturn("global-processor");
+        when(globalProcessor.stores()).thenReturn(Set.of("global-store"));
+        when(globalProcessor.successors()).thenReturn(Set.of());
+
+        final TopologyDescription.GlobalStore globalStore = 
mock(TopologyDescription.GlobalStore.class);
+        when(globalStore.id()).thenReturn(0);
+        when(globalStore.source()).thenReturn(globalSource);
+        when(globalStore.processor()).thenReturn(globalProcessor);
+
+        final TopologyDescription.Subtopology subtopology = 
mock(TopologyDescription.Subtopology.class);
+        when(subtopology.id()).thenReturn(0);
+        when(subtopology.nodes()).thenReturn(Set.of(source, processor, sink));
+
+        final TopologyDescription description = 
mock(TopologyDescription.class);
+        when(description.subtopologies()).thenReturn(Set.of(subtopology));
+        when(description.globalStores()).thenReturn(Set.of(globalStore));
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription wire =
+            TopologyDescriptionConverter.toWire(description, 
Function.identity());
+
+        assertEquals(1, wire.subtopologies().size());
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionSubtopology 
wireSub = wire.subtopologies().get(0);
+        assertEquals("0", wireSub.subtopologyId());
+        assertEquals(3, wireSub.nodes().size());
+
+        final 
List<StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode> 
nodes = wireSub.nodes();
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
wireProcessor = nodes.get(0);
+        assertEquals("processor", wireProcessor.name());
+        assertEquals((byte) 2, wireProcessor.nodeType());
+        assertEquals(List.of("store-1", "store-2"), wireProcessor.stores());
+        assertEquals(List.of("sink"), wireProcessor.successors());
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
wireSink = nodes.get(1);
+        assertEquals("sink", wireSink.name());
+        assertEquals((byte) 3, wireSink.nodeType());
+        assertEquals("output-topic", wireSink.sinkTopic());
+        assertEquals(List.of(), wireSink.successors());
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
wireSource = nodes.get(2);
+        assertEquals("source", wireSource.name());
+        assertEquals((byte) 1, wireSource.nodeType());
+        assertEquals(List.of("input-topic"), wireSource.sourceTopics());
+        assertEquals(List.of("processor"), wireSource.successors());
+        assertNull(wireSource.sinkTopic());
+
+        assertEquals(1, wire.globalStores().size());
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionGlobalStore 
wireGlobal = wire.globalStores().get(0);
+        assertEquals("global-source", wireGlobal.source().name());
+        assertEquals((byte) 1, wireGlobal.source().nodeType());
+        assertEquals(List.of("global-topic"), 
wireGlobal.source().sourceTopics());
+        assertEquals(List.of(), wireGlobal.source().successors());
+        assertEquals("global-processor", wireGlobal.processor().name());
+        assertEquals((byte) 2, wireGlobal.processor().nodeType());
+        assertEquals(List.of("global-store"), wireGlobal.processor().stores());
+        assertEquals(List.of(), wireGlobal.processor().successors());
+    }
+
+    /**
+     * Test the situation when a sink uses a TopicNameExtractor (dynamic 
topic): the wire
+     * SinkTopic field should be null since the schema has no field for the 
extractor itself.
+     */
+    @Test
+    public void shouldConvertSinkWithDynamicTopic() {
+        final TopologyDescription.Source source = 
mock(TopologyDescription.Source.class);
+        final TopologyDescription.Sink sink = 
mock(TopologyDescription.Sink.class);
+
+        when(source.name()).thenReturn("source");
+        when(source.topicSet()).thenReturn(Set.of("input-topic"));
+        when(source.successors()).thenReturn(Set.of(sink));
+
+        when(sink.name()).thenReturn("sink");
+        when(sink.topic()).thenReturn(null);
+        when(sink.successors()).thenReturn(Set.of());
+
+        final TopologyDescription.Subtopology subtopology = 
mock(TopologyDescription.Subtopology.class);
+        when(subtopology.id()).thenReturn(0);
+        when(subtopology.nodes()).thenReturn(Set.of(source, sink));
+
+        final TopologyDescription description = 
mock(TopologyDescription.class);
+        when(description.subtopologies()).thenReturn(Set.of(subtopology));
+        when(description.globalStores()).thenReturn(Set.of());
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription wire =
+            TopologyDescriptionConverter.toWire(description, 
Function.identity());
+
+        final 
List<StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode> 
nodes =
+            wire.subtopologies().get(0).nodes();
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
wireSink = nodes.get(0);
+        assertEquals("sink", wireSink.name());
+        assertEquals((byte) 3, wireSink.nodeType());
+        assertNull(wireSink.sinkTopic());
+    }
+
+    /**
+     * Test the situation when a topic-name decorator is supplied: internal 
topic names should be
+     * decorated with the applicationId prefix on the wire output, while 
external (user-provided)
+     * topic names should pass through unchanged.
+     */
+    @Test
+    public void shouldApplyTopicNameDecorator() {
+        final TopologyDescription.Source source = 
mock(TopologyDescription.Source.class);
+        final TopologyDescription.Sink sink = 
mock(TopologyDescription.Sink.class);
+        when(source.name()).thenReturn("source");
+        when(source.topicSet()).thenReturn(Set.of("external-input-topic", 
"repartition-topic"));
+        when(source.successors()).thenReturn(Set.of(sink));
+        when(sink.name()).thenReturn("sink");
+        when(sink.topic()).thenReturn("changelog-topic");
+        when(sink.successors()).thenReturn(Set.of());
+
+        final TopologyDescription.Subtopology subtopology = 
mock(TopologyDescription.Subtopology.class);
+        when(subtopology.id()).thenReturn(0);
+        when(subtopology.nodes()).thenReturn(Set.of(source, sink));
+
+        final TopologyDescription description = 
mock(TopologyDescription.class);
+        when(description.subtopologies()).thenReturn(Set.of(subtopology));
+        when(description.globalStores()).thenReturn(Set.of());
+
+        final Set<String> internalTopics = Set.of("repartition-topic", 
"changelog-topic");
+        final Function<String, String> topicNameDecorator = name ->
+            internalTopics.contains(name) ? "my-app-" + name : name;
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescription wire =
+            TopologyDescriptionConverter.toWire(description, 
topicNameDecorator);
+
+        final 
List<StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode> 
nodes =
+            wire.subtopologies().get(0).nodes();
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
wireSink = nodes.get(0);
+        assertEquals("my-app-changelog-topic", wireSink.sinkTopic());
+
+        final 
StreamsGroupTopologyDescriptionUpdateRequestData.TopologyDescriptionNode 
wireSource = nodes.get(1);
+        assertEquals(List.of("external-input-topic", 
"my-app-repartition-topic"), wireSource.sourceTopics());
+    }
+}


Reply via email to