You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@kafka.apache.org by da...@apache.org on 2026/09/23 08:41:05 UTC
(kafka) branch trunk updated: MINOR: Write the streams topology tombstone before the group epoch tombstone (#23552)
This is an automated email from the ASF dual-hosted git repository.
dajac 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 433ec656ce1 MINOR: Write the streams topology tombstone before the group epoch tombstone (#23552)
433ec656ce1 is described below
commit 433ec656ce151d789867efa62b63c20e9aa1d0af
Author: David Jacot <da...@apache.org>
AuthorDate: Wed Sep 23 10:40:56 2026 +0200
MINOR: Write the streams topology tombstone before the group epoch tombstone (#23552)
Deleting a streams group leaks its topics in the group coordinator's
topic-to-groups index (`groupsByTopics`), which `onMetadataUpdate` uses
to tell groups that a topic they depend on changed.
A streams group's entry in that index is derived from its topology:
replaying a `StreamsGroupTopologyValue` record subscribes the group to
the topology's required topics and replaying its tombstone unsubscribes
it. `StreamsGroup.createGroupTombstoneRecords` wrote the
`StreamsGroupMetadataValue` tombstone before the
`StreamsGroupTopologyValue` tombstone. On replay, the first one removes
the group, so the second one finds no group and returns before
unsubscribing anything. The entry survives for the lifetime of the
coordinator, and every deleted streams group adds to it. The consumer of
the index checks that the group still exists, so the effect is bounded
growth of the index and a spurious metadata refresh request on a group
that later reuses the id, not a failure.
This change writes the topology tombstone before the group epoch
tombstone. This restores the invariant the other group types already
have: every sibling record is torn down before the epoch tombstone, so
the epoch tombstone is the last record of a deletion. Deletions already
in the log with the previous order keep leaking on replay until
compaction removes their tombstones.
The new test builds a streams group with a topology, replays exactly the
records that `createGroupTombstoneRecords` produces, and verifies that
the group is removed and that the index no longer references it. It
fails with the previous record order.
Reviewers: Lucas Brutschy <lb...@confluent.io>
---
.../coordinator/group/streams/StreamsGroup.java | 4 ++-
.../group/GroupMetadataManagerTest.java | 31 +++++++++++++++++++---
2 files changed, 30 insertions(+), 5 deletions(-)
diff --git a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroup.java b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroup.java
index 213e49a2eb1..11eb0875cba 100644
--- a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroup.java
+++ b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroup.java
@@ -1138,8 +1138,10 @@ public class StreamsGroup implements Group {
records.add(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberTombstoneRecord(groupId(), memberId))
);
- records.add(StreamsCoordinatorRecordHelpers.newStreamsGroupEpochTombstoneRecord(groupId()));
+ // The topology tombstone must precede the group epoch tombstone: replaying it unsubscribes
+ // the group from its topics, which is only possible while the group still exists.
records.add(StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecordTombstone(groupId()));
+ records.add(StreamsCoordinatorRecordHelpers.newStreamsGroupEpochTombstoneRecord(groupId()));
}
/**
diff --git a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTest.java b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTest.java
index a56652cbe7a..7cba2b92310 100644
--- a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTest.java
+++ b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTest.java
@@ -13196,8 +13196,8 @@ public class GroupMetadataManagerTest {
StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentTombstoneRecord(groupId, memberId),
StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataTombstoneRecord(groupId),
StreamsCoordinatorRecordHelpers.newStreamsGroupMemberTombstoneRecord(groupId, memberId),
- StreamsCoordinatorRecordHelpers.newStreamsGroupEpochTombstoneRecord(groupId),
- StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecordTombstone(groupId)
+ StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecordTombstone(groupId),
+ StreamsCoordinatorRecordHelpers.newStreamsGroupEpochTombstoneRecord(groupId)
);
assertEquals(expectedRecords, records);
}
@@ -24169,8 +24169,8 @@ public class GroupMetadataManagerTest {
List<CoordinatorRecord> expectedRecords = List.of(
StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentMetadataTombstoneRecord(streamsGroupId),
- StreamsCoordinatorRecordHelpers.newStreamsGroupEpochTombstoneRecord(streamsGroupId),
- StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecordTombstone(streamsGroupId)
+ StreamsCoordinatorRecordHelpers.newStreamsGroupTopologyRecordTombstone(streamsGroupId),
+ StreamsCoordinatorRecordHelpers.newStreamsGroupEpochTombstoneRecord(streamsGroupId)
);
assertEquals(Errors.MEMBER_ID_REQUIRED.code(), joinResult.joinFuture.get().errorCode());
@@ -25409,6 +25409,29 @@ public class GroupMetadataManagerTest {
assertThrows(GroupIdNotFoundException.class, () -> context.groupMetadataManager.streamsGroup("foo"));
}
+ @Test
+ public void testReplayStreamsGroupEpochTombstoneUnsubscribesTopologyTopics() {
+ String subtopologyId = "subtopology-1";
+ StreamsTopology topology = new StreamsTopology(1, Map.of(subtopologyId,
+ new StreamsGroupTopologyValue.Subtopology()
+ .setSubtopologyId(subtopologyId)
+ .setSourceTopics(List.of("bar"))));
+ GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
+ .withStreamsGroup(new StreamsGroupBuilder("foo", 10).withTopology(topology))
+ .build();
+
+ assertEquals(Set.of("foo"), context.groupMetadataManager.groupsSubscribedToTopic("bar"));
+
+ // The topology tombstone must be written before the group epoch tombstone: once the group
+ // is gone, replaying the topology tombstone can no longer unsubscribe the group.
+ List<CoordinatorRecord> records = new ArrayList<>();
+ context.groupMetadataManager.streamsGroup("foo").createGroupTombstoneRecords(records);
+ records.forEach(context::replay);
+
+ assertThrows(GroupIdNotFoundException.class, () -> context.groupMetadataManager.streamsGroup("foo"));
+ assertEquals(Set.of(), context.groupMetadataManager.groupsSubscribedToTopic("bar"));
+ }
+
@Test
public void testReplayStreamsGroupTargetAssignmentMember() {
GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()