Repository navigation
KAFKA-21302: Sort copartition groups and indexes for consistent Streams topology comparison - #23855
chickenchickenlove wants to merge 3 commits into
Conversation
…ms topology comparison
There was a problem hiding this comment.
🟢 Approval recommended
Normalization preserves index targets and inputs, with focused regression coverage and no unresolved findings.
No open issues 🎉
What changed in this PR
Makes Streams topology comparison insensitive to copartition group and index ordering, preventing rejection of members with equivalent constraints.
Changes:
- Normalizes copartition constraints during record replay and heartbeat processing without changing referenced topic lists.
- Adds regression coverage for equality, input preservation, and successful member joins.
| File | Description |
|---|---|
| group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsTopologyTest.java | Tests normalization, equality, and input preservation. |
| group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTest.java | Verifies joins with reordered copartition constraints succeed. |
| group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsTopology.java | Normalizes copartition groups and indexes across both ingestion paths. |
Review effort: Balanced
| .setCopartitionGroups(List.of( | ||
| copartitionGroup(topicType, List.of((short) 0)), | ||
| copartitionGroup(topicType, List.of((short) 0, (short) 1)), | ||
| copartitionGroup(topicType, List.of((short) 2, (short) 3)) |
There was a problem hiding this comment.
Could we also cover a group that mixes source and repartition indexes, e.g. {source: [1], repartition: [0]}? That's the typical join case, and it's where the thenComparing tie-breaks actually matter.
| .setWarmupTasks(List.of())); | ||
|
|
||
| assertEquals(Errors.NONE.code(), result.response().data().errorCode()); | ||
| assertTrue(result.response().data().memberEpoch() > 0); |
There was a problem hiding this comment.
Maybe also assert that result.records() contains no new topology record, so we know the request was matched against the existing topology?
| // The first step sorts indexes within each group; the second sorts the groups. | ||
| // The referenced topic lists stay unchanged, so each index still identifies the same topic. | ||
| // Copy before sorting to avoid mutating a replayed record or the heartbeat request. | ||
| Subtopology copy = subtopology.duplicate(); |
There was a problem hiding this comment.
This deep-copies the whole subtopology, including all the TopicInfos and their configs, just to sort the copartition groups, and fromHeartbeatRequest now copies twice (convert + duplicate). Could we only copy the copartition groups? If we sort in convertToStreamsGroupTopologyRecord as suggested in the review body, the heartbeat path doesn't need a copy at all.
Also, convertToTopicInfo maps a null topicConfigs to null, and duplicate() would NPE on that. A deserialized request can't have null there since the field isn't nullable, so maybe that null branch is just dead code. But it's another reason not to duplicate fields we don't touch.
There was a problem hiding this comment.
Thinking about this more: since the join heartbeat can be hot on large clusters (e.g. during rejoin storms), could we avoid building a StreamsTopology from the request at all when the group already has one? maybeUpdateTopology already converts the full request on every topology-carrying heartbeat, and with the normalization we copy it a second time.
One option: only call fromHeartbeatRequest when initializing the topology, and otherwise compare the request directly against the stored topology with something like StreamsTopology#matches(StreamsGroupHeartbeatRequestData.Topology), matching copartition groups regardless of order. The stored topology is already normalized in fromRecord, and the groups are tiny, so this can be done without allocating. It would need a test that fails when a field is added to Subtopology, so the comparison doesn't silently miss it. I'd rather not override equals for this, since hashCode would also need to be made order-independent.
What do you think?
There was a problem hiding this comment.
Thank you for the thoughtful suggestion. I agree that, considering cases such as rejoin storms, optimizing this path with matches() is important for the broker.
Would you be open to first applying your initial suggestion to copy only the copartition groups, then landing and backporting the correctness fix before moving on to matches()? (I'm happy to take on the matches() optimization separately.)
My concern is that introducing a new comparison implementation covering the current schema could inadvertently introduce new correctness issues. With that in mind, I would prefer to keep this PR narrowly focused on handling the ordering differences between client requests and the broker’s stored topology, and complete the backports first(even client side!).
If you’re comfortable with that approach, could we address the matches() optimization in a separate ticket, with dedicated review and testing, including the schema field-coverage test you suggested?
There was a problem hiding this comment.
I think matches() would actually replace the normalization rather than add to it: with an order-insensitive comparison we don't need to sort or copy the copartition groups at all, and fromRecord/fromHeartbeatRequest stay as they were. The current version also lists the Subtopology fields explicitly, so the risk of missing a field is the same either way and needs the same reflection test. So I'd lean towards doing it in this PR. What do you think?
There was a problem hiding this comment.
Definitely agree.
I think this is an area where regressions could easily be introduced if new fields are added in the future.
| return copy; | ||
| } | ||
|
|
||
| private static int compareTopicIndexes(List<Short> first, List<Short> second) { |
There was a problem hiding this comment.
nit: this is a hand-rolled lexicographic compare. Not a big deal, but since the lists are sorted at this point, would something like converting to short[] and using Arrays.compare be simpler? Fine to keep as is if you prefer.
There was a problem hiding this comment.
Sorry, my nit made this slightly worse: toArray(Short[]::new) allocates two arrays on every comparison. The previous loop didn't allocate, so if we keep the normalization I'd go back to it.
|
Thanks for the fix! I think we should also fix this on the client. It might also be worth sorting in |
|
@lucasbru I agree that we should fix the client side as well. Given the backport implications, would it make sense to track that change in a separate ticket and PR? AFAIK, Copartition groups are persisted, including their ordering. If an existing client is upgraded to send sorted groups while the broker remains unpatched, the broker could reject the topology because its ordering differs from the persisted record, even though the topology is semantically unchanged. Separating the client-side change would let us backport the broker normalization independently and evaluate the client-side backport with this compatibility concern in mind. What do you think? |
|
Good point, I hadn't thought about the unpatched-broker case. Agreed, let's keep this PR broker-only so it can be backported on its own, and handle the client side in a separate ticket. The same argument applies to my suggestion of writing sorted records: if a patched broker writes sorted records and is later downgraded, existing clients sending the old order would be rejected. So let's drop that and keep persisting what the client sent. |
|
Thanks for the clarification! Currently, I'm working on addressing your comments in this PR. |
|
Small addition after looking at how the client builds the groups: the order comes from That also means existing records generally match what current clients send, which makes your point about sorting on the client even stronger. A sorting client would usually differ from the persisted order on an unpatched broker. |
|
I've pushed a new commit that incorporates most of your suggestions! I left out the I also added a comment at the end of KAFKA-21302. Even when running the exact same code, the ordering of copartition groups can vary across JVM runs if the source nodes are defined using I wanted to highlight this again, as I thought it might be helpful when determining the priority of this issue. When you get a chance, please take another look! 🙇♂️ |
|
Thanks for the update and for checking the |
| // The first step sorts indexes within each group; the second sorts the groups. | ||
| // The referenced topic lists stay unchanged, so each index still identifies the same topic. | ||
| // Copy before sorting to avoid mutating a replayed record or the heartbeat request. | ||
| Subtopology copy = subtopology.duplicate(); |
There was a problem hiding this comment.
I think matches() would actually replace the normalization rather than add to it: with an order-insensitive comparison we don't need to sort or copy the copartition groups at all, and fromRecord/fromHeartbeatRequest stay as they were. The current version also lists the Subtopology fields explicitly, so the risk of missing a field is the same either way and needs the same reflection test. So I'd lean towards doing it in this PR. What do you think?
| return copy; | ||
| } | ||
|
|
||
| private static int compareTopicIndexes(List<Short> first, List<Short> second) { |
There was a problem hiding this comment.
Sorry, my nit made this slightly worse: toArray(Short[]::new) allocates two arrays on every comparison. The previous loop didn't allocate, so if we keep the normalization I'd go back to it.
| .thenComparing(CopartitionGroup::repartitionSourceTopics, StreamsTopology::compareTopicIndexes) | ||
| ); | ||
|
|
||
| return new Subtopology() |
There was a problem hiding this comment.
Since this lists the fields explicitly, a field added to Subtopology later (it would have to be a tagged field) would be silently dropped here. Could we add a small test that counts the declared instance fields of StreamsGroupTopologyValue.Subtopology via reflection (8 today, including _unknownTaggedFields)? A schema field count wouldn't catch tagged fields. Or at least a comment saying this has to list every field. The same applies to matches() if we go that way.
There was a problem hiding this comment.
Yes, you are right!
I've addressed it.
|
@lucasbru |
Fixes KAFKA-21302
With
group.protocol=streams, a member with identical copartitionconstraints can be rejected because of differences in source topic
ordering. For example, constructing the same join with source topics
["A", "B"]and["B", "A"]can produce heartbeat copartition indexesof
[0, 1, 2]and[1, 0, 2], respectively.The coordinator compares these lists in an order-sensitive manner and
treats the second member’s request as a topology change. It returns
INVALID_REQUEST, causing the client to transition to FATAL andpreventing an otherwise valid instance from joining or replacing an
existing member
Reviewers: Lucas Brutschy lbrutschy@confluent.io