lucasbru commented on code in PR #23855:
URL: https://github.com/apache/kafka/pull/23855#discussion_r4371456652


##########
group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsTopology.java:
##########
@@ -128,6 +131,178 @@ public static StreamsTopology 
fromHeartbeatRequest(StreamsGroupHeartbeatRequestD
         return new StreamsTopology(topology.epoch(), subtopologyMap);
     }
 
+    /**
+     * Checks whether the request describes this topology without converting 
or modifying it.
+     * Subtopologies are matched by ID. Copartition groups and their topic 
indexes are compared
+     * without regard to order, but duplicate occurrences must still match. 
All other lists
+     * retain their order-sensitive comparison, including the topic lists 
referenced by indexes.
+     *
+     * @param topology The topology supplied in the heartbeat request.
+     * @return Whether the request matches this topology.
+     */
+    public boolean matches(StreamsGroupHeartbeatRequestData.Topology topology) 
{
+        if (topologyEpoch != topology.epoch() || subtopologies.size() != 
topology.subtopologies().size()) {
+            return false;
+        }
+        for (int i = 0; i < topology.subtopologies().size(); i++) {
+            StreamsGroupHeartbeatRequestData.Subtopology requested = 
topology.subtopologies().get(i);
+            Subtopology current = subtopologies.get(requested.subtopologyId());
+            if (current == null || !matchesSubtopology(current, requested)) {
+                return false;
+            }
+
+            // Do not let repeated IDs match one stored subtopology while 
omitting another.
+            for (int j = 0; j < i; j++) {
+                if 
(requested.subtopologyId().equals(topology.subtopologies().get(j).subtopologyId()))
 {
+                    return false;
+                }
+            }
+        }
+        return true;
+    }
+
+    // Keep these comparisons in sync with the generated fields guarded by 
StreamsTopologyTest.
+    private static boolean matchesSubtopology(Subtopology current, 
StreamsGroupHeartbeatRequestData.Subtopology requested) {
+        if (!current.subtopologyId().equals(requested.subtopologyId())) {
+            return false;
+        }
+
+        return current.sourceTopics().equals(requested.sourceTopics())
+            && current.sourceTopicRegex().equals(requested.sourceTopicRegex())
+            && 
current.repartitionSinkTopics().equals(requested.repartitionSinkTopics())
+            && matchesTopicInfos(current.repartitionSourceTopics(), 
requested.repartitionSourceTopics())
+            && matchesTopicInfos(current.stateChangelogTopics(), 
requested.stateChangelogTopics())
+            && matchesCopartitionGroups(current.copartitionGroups(), 
requested.copartitionGroups());
+    }
+
+    private static boolean matchesTopicInfos(List<TopicInfo> current, 
List<StreamsGroupHeartbeatRequestData.TopicInfo> requested) {
+        if (current.size() != requested.size()) {
+            return false;
+        }
+        for (int i = 0; i < current.size(); i++) {
+            TopicInfo fromCurrent = current.get(i);
+            StreamsGroupHeartbeatRequestData.TopicInfo fromRequested = 
requested.get(i);
+            if (!fromCurrent.name().equals(fromRequested.name())
+                || fromCurrent.partitions() != fromRequested.partitions()
+                || fromCurrent.replicationFactor() != 
fromRequested.replicationFactor()
+                || !matchesTopicConfigs(fromCurrent.topicConfigs(), 
fromRequested.topicConfigs())) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static boolean matchesTopicConfigs(
+        List<StreamsGroupTopologyValue.TopicConfig> current,
+        List<StreamsGroupHeartbeatRequestData.KeyValue> requested
+    ) {
+        // Preserve the null handling of convertToTopicInfo for directly 
constructed requests.
+        if (current == null || requested == null) {
+            return current == null && requested == null;
+        }
+        if (current.size() != requested.size()) {
+            return false;
+        }
+        for (int i = 0; i < current.size(); i++) {
+            if (!Objects.equals(current.get(i).key(), requested.get(i).key())
+                || !Objects.equals(current.get(i).value(), 
requested.get(i).value())) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static boolean matchesCopartitionGroups(

Review Comment:
   Thanks for doing this, and sorry, my "without allocating" comment pushed in 
this direction. When the order differs, `sameTopicIndexes` is O(k²) and 
`matchesCopartitionGroups` O(G²), and nothing bounds those sizes in the 
request. A client with access to the group can initialize it with a large index 
list (~100KB already gives ~10⁹ iterations) and then rejoin with a permutation, 
which keeps the coordinator thread busy on every such heartbeat.
   
   I think sorting is both simpler and safe here: in `fromRecord`, 
`duplicate()` the subtopology and sort its copartition groups (indexes within 
each group, then the groups, with the old allocation-free `compareTopicIndexes` 
loop). This only runs on initialization and load, and it needs to be a copy 
because the handler passes the same `recordValue` it is about to write, and the 
lists are unmodifiable or shared with the request. `fromHeartbeatRequest` 
should then delegate to `fromRecord` again. In `matches`, sort copies of just 
the request's copartition groups and compare them with the stored ones. That's 
O(k log k), records stay as the client sent them, and the counting code goes 
away.



##########
group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsTopology.java:
##########
@@ -128,6 +131,178 @@ public static StreamsTopology 
fromHeartbeatRequest(StreamsGroupHeartbeatRequestD
         return new StreamsTopology(topology.epoch(), subtopologyMap);
     }
 
+    /**
+     * Checks whether the request describes this topology without converting 
or modifying it.
+     * Subtopologies are matched by ID. Copartition groups and their topic 
indexes are compared
+     * without regard to order, but duplicate occurrences must still match. 
All other lists
+     * retain their order-sensitive comparison, including the topic lists 
referenced by indexes.
+     *
+     * @param topology The topology supplied in the heartbeat request.
+     * @return Whether the request matches this topology.
+     */
+    public boolean matches(StreamsGroupHeartbeatRequestData.Topology topology) 
{
+        if (topologyEpoch != topology.epoch() || subtopologies.size() != 
topology.subtopologies().size()) {
+            return false;
+        }
+        for (int i = 0; i < topology.subtopologies().size(); i++) {
+            StreamsGroupHeartbeatRequestData.Subtopology requested = 
topology.subtopologies().get(i);
+            Subtopology current = subtopologies.get(requested.subtopologyId());
+            if (current == null || !matchesSubtopology(current, requested)) {
+                return false;
+            }
+
+            // Do not let repeated IDs match one stored subtopology while 
omitting another.

Review Comment:
   This is O(S²) in the number of subtopologies, with S only bounded by the 
record size (~1e5 minimal subtopologies). Could we track the seen IDs in a 
`HashSet` instead?



##########
group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsTopology.java:
##########
@@ -128,6 +131,178 @@ public static StreamsTopology 
fromHeartbeatRequest(StreamsGroupHeartbeatRequestD
         return new StreamsTopology(topology.epoch(), subtopologyMap);
     }
 
+    /**
+     * Checks whether the request describes this topology without converting 
or modifying it.
+     * Subtopologies are matched by ID. Copartition groups and their topic 
indexes are compared
+     * without regard to order, but duplicate occurrences must still match. 
All other lists
+     * retain their order-sensitive comparison, including the topic lists 
referenced by indexes.
+     *
+     * @param topology The topology supplied in the heartbeat request.
+     * @return Whether the request matches this topology.
+     */
+    public boolean matches(StreamsGroupHeartbeatRequestData.Topology topology) 
{
+        if (topologyEpoch != topology.epoch() || subtopologies.size() != 
topology.subtopologies().size()) {
+            return false;
+        }
+        for (int i = 0; i < topology.subtopologies().size(); i++) {
+            StreamsGroupHeartbeatRequestData.Subtopology requested = 
topology.subtopologies().get(i);
+            Subtopology current = subtopologies.get(requested.subtopologyId());
+            if (current == null || !matchesSubtopology(current, requested)) {
+                return false;
+            }
+
+            // Do not let repeated IDs match one stored subtopology while 
omitting another.
+            for (int j = 0; j < i; j++) {
+                if 
(requested.subtopologyId().equals(topology.subtopologies().get(j).subtopologyId()))
 {
+                    return false;
+                }
+            }
+        }
+        return true;
+    }
+
+    // Keep these comparisons in sync with the generated fields guarded by 
StreamsTopologyTest.
+    private static boolean matchesSubtopology(Subtopology current, 
StreamsGroupHeartbeatRequestData.Subtopology requested) {
+        if (!current.subtopologyId().equals(requested.subtopologyId())) {

Review Comment:
   nit: `current` was looked up by `requested.subtopologyId()` in a map keyed 
by subtopology ID, so this check is always true. I'd drop it.



##########
group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsTopologyTest.java:
##########
@@ -122,6 +128,142 @@ public void 
fromHeartbeatRequestShouldCreateCorrectTopology() {
         assertEquals(mkSubtopology2(), 
topology.subtopologies().get(SUBTOPOLOGY_ID_2));
     }
 
+    @ParameterizedTest
+    @ValueSource(strings = {"source", "regex", "repartition"})
+    public void copartitionOrderShouldNotAffectMatching(String topicType) {
+        // request has - [[0], [0, 1], [2, 3]]
+        // replayed has - [[3, 2], [1, 0], [0]]
+        StreamsGroupHeartbeatRequestData.Topology request = 
topologyWithCopartitionGroups(topicType);
+        StreamsGroupHeartbeatRequestData.Topology originalRequest = 
request.duplicate();
+
+        StreamsGroupTopologyValue record = 
StreamsCoordinatorRecordHelpers.convertToStreamsGroupTopologyRecord(request).duplicate();
+        List<StreamsGroupTopologyValue.CopartitionGroup> groups = 
record.subtopologies().get(0).copartitionGroups();
+        Collections.reverse(groups);
+        for (StreamsGroupTopologyValue.CopartitionGroup group : groups) {
+            Collections.reverse(group.sourceTopics());
+            Collections.reverse(group.sourceTopicRegex());
+            Collections.reverse(group.repartitionSourceTopics());
+        }
+        StreamsGroupTopologyValue originalRecord = record.duplicate();
+
+        StreamsTopology replayed = StreamsTopology.fromRecord(record);
+        StreamsTopology received = 
StreamsTopology.fromHeartbeatRequest(request);
+
+        assertTrue(replayed.matches(request));
+        assertTrue(received.matches(request));
+        assertNotEquals(received, replayed);
+        assertEquals(originalRecord, record);
+        assertEquals(originalRequest, request);
+    }
+
+    @Test
+    public void mixedCopartitionGroupsShouldMatchRegardlessOfOrder() {
+        // request has 'source - [1] / repartition - [3, 2]' and 'source - 
[1], repartition - [0]'
+        // reordered has 'source - [1], repartition - [0' and 'source - [1], 
repartition = [2, 3]'
+        StreamsGroupHeartbeatRequestData.Topology request = 
topologyWithCopartitionGroups("source");
+        request.subtopologies().get(0).setCopartitionGroups(List.of(
+            new StreamsGroupHeartbeatRequestData.CopartitionGroup()
+                .setSourceTopics(List.of((short) 1))
+                .setRepartitionSourceTopics(List.of((short) 3, (short) 2)),
+            new StreamsGroupHeartbeatRequestData.CopartitionGroup()
+                .setSourceTopics(List.of((short) 1))
+                .setRepartitionSourceTopics(List.of((short) 0))
+        ));
+
+        StreamsGroupTopologyValue record = 
StreamsCoordinatorRecordHelpers.convertToStreamsGroupTopologyRecord(request);
+        StreamsTopology replayed = StreamsTopology.fromRecord(record);
+
+        StreamsGroupHeartbeatRequestData.Topology reorderedRequest = 
request.duplicate();
+        
Collections.reverse(reorderedRequest.subtopologies().get(0).copartitionGroups());
+        reorderedRequest.subtopologies().get(0).copartitionGroups().get(1)
+            .setRepartitionSourceTopics(List.of((short) 2, (short) 3));
+
+        assertTrue(replayed.matches(reorderedRequest));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"source", "regex", "repartition"})
+    public void differentCopartitionConstraintsShouldNotMatch(String 
topicType) {

Review Comment:
   Since `matches` now compares every field by hand, could we add one 
parameterized test that changes a single non-copartition field at a time and 
asserts `matches` is false: source topics, sink topics, a changelog/repartition 
`TopicInfo` field (partitions, replication factor, configs), a missing 
subtopology, a duplicate subtopology ID? Otherwise a wrong comparison of an 
existing field would go unnoticed, and the field-count test only covers new 
fields.



-- 
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