This is an automated email from the ASF dual-hosted git repository.
apoorvmittal10 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 97bbffa0a89 KAFKA-20742: Start offset for newly initialized share
partitions of known topics should be 0 (#22714)
97bbffa0a89 is described below
commit 97bbffa0a89ebc3d88eb05d7e189ea1cf9798f26
Author: Sanskar Jhajharia <[email protected]>
AuthorDate: Thu Jul 2 02:52:55 2026 +0530
KAFKA-20742: Start offset for newly initialized share partitions of known
topics should be 0 (#22714)
### Summary
When a share group heartbeat detects subscribed-but-uninitialized
topic-partitions, the group coordinator builds an
`InitializeShareGroupStateParameters` request. Previously it sent
`startOffset = -1` (`PartitionFactory.UNINITIALIZED_START_OFFSET`) for
*every* partition.
`-1` is a sentinel meaning "not yet decided":
`SharePartition.startOffsetDuringInitialization()` resolves the real
offset from the group's `share.auto.offset.reset` strategy (default
`LATEST`). This is correct for a brand-new topic subscription, but
incorrect when **new partitions are added to a topic the group already
knows about**. Resolving those new partitions via `LATEST` can skip
records produced before initialization completes, causing data loss.
This change makes newly initialized partitions of an **already-known
topic** (present in the group's `initializedTopics` or
`initializingTopics`) start at offset `0`, guaranteeing no records are
missed. Partitions of a topic seen for the first time keep `-1` so that
`share.auto.offset.reset` still applies to fresh subscriptions.
### Change
In `GroupMetadataManager`:
- `maybeCreateInitializeShareGroupStateRequest` computes the set of
already-known topics (`initializedTopics ∪ initializingTopics`) from the
current persisted metadata. This is read before the heartbeat's records
are replayed, so a brand-new topic is correctly absent.
- `buildInitializeShareGroupStateRequest` selects the start offset per
topic: `0` for an already-known topic, `-1` for a first-sighting topic.
### Testing
- Added `testShareGroupHeartbeatInitializeMixedNewAndExpandedTopics`: a
single heartbeat that both adds a brand-new topic and expands an
already-initialized topic produces one request where the new topic uses
`-1` and the expanded topic's new partitions use `0` — verifying the
decision is per-topic, not per-request.
Reviewers: Sushant Mahajan <[email protected]>, Apoorv Mittal
<[email protected]>
---
.../kafka/clients/consumer/ShareConsumerTest.java | 60 ++++++++++
.../coordinator/group/GroupMetadataManager.java | 40 +++++--
.../group/GroupMetadataManagerTest.java | 129 ++++++++++++++++++++-
3 files changed, 220 insertions(+), 9 deletions(-)
diff --git
a/clients/clients-integration-tests/src/test/java/org/apache/kafka/clients/consumer/ShareConsumerTest.java
b/clients/clients-integration-tests/src/test/java/org/apache/kafka/clients/consumer/ShareConsumerTest.java
index a9f45bf73b1..d4865ab6cf8 100644
---
a/clients/clients-integration-tests/src/test/java/org/apache/kafka/clients/consumer/ShareConsumerTest.java
+++
b/clients/clients-integration-tests/src/test/java/org/apache/kafka/clients/consumer/ShareConsumerTest.java
@@ -19,6 +19,7 @@ package org.apache.kafka.clients.consumer;
import kafka.server.KafkaBroker;
import org.apache.kafka.clients.admin.Admin;
+import org.apache.kafka.clients.admin.NewPartitions;
import org.apache.kafka.clients.admin.RecordsToDelete;
import org.apache.kafka.clients.admin.SharePartitionOffsetInfo;
import org.apache.kafka.clients.producer.Producer;
@@ -2033,4 +2034,63 @@ public class ShareConsumerTest extends
ShareConsumerTestBase {
throw new RuntimeException(e);
}
}
+
+ @ClusterTest
+ public void testAddedPartitionsOfKnownTopicStartAtOffsetZero() throws
InterruptedException, ExecutionException {
+ String groupId = "expand-group";
+ String topic = "expand-topic";
+ createTopic(topic, 1, 1);
+
+ alterShareAutoOffsetReset(groupId, "latest");
+
+ try (Producer<byte[], byte[]> producer = createProducer();
+ Admin admin = createAdminClient();
+ ShareConsumer<byte[], byte[]> shareConsumer =
createShareConsumer(groupId)) {
+
+ // Produce 10 records to partition 0 before anyone subscribes.
+ for (int i = 0; i < 10; i++) {
+ producer.send(new ProducerRecord<>(topic, 0, null, ("p0-key-"
+ i).getBytes(), ("p0-value-" + i).getBytes()));
+ }
+ producer.flush();
+
+ // The consumer joins and polls: with "latest" it starts partition
0 at the log end, so it receives
+ // none of the pre-existing records. This also makes the group
aware of (initializes) the topic.
+ shareConsumer.subscribe(Set.of(topic));
+ int received = 0;
+ long deadline = System.currentTimeMillis() + 10000L;
+ while (System.currentTimeMillis() < deadline) {
+ received +=
shareConsumer.poll(Duration.ofMillis(1000)).count();
+ }
+ assertEquals(0, received, "latest reset should skip records
produced before the topic was known");
+
+ // Expand the topic and produce 10 records to the brand-new
partition 1, each with a distinct,
+ // known value so we can assert all of them are delivered (none
skipped by the latest reset).
+ admin.createPartitions(Map.of(topic,
NewPartitions.increaseTo(2))).all().get();
+ cluster.waitTopicCreation(topic, 2);
+
+ List<String> expectedValues = new ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+ String value = "p1-value-" + i;
+ expectedValues.add(value);
+ producer.send(new ProducerRecord<>(topic, 1, null, ("p1-key-"
+ i).getBytes(), value.getBytes()));
+ }
+ producer.flush();
+
+ // Partition 1 belongs to an already-known topic, so it must be
initialized at offset 0 and deliver
+ // every record produced above — even though it was created under
a "latest" reset. Share consumers
+ // deliver at-least-once, so records may be redelivered; track
distinct values in a set so
+ // duplicates don't cause spurious failures, then assert the count
of distinct records.
+ Set<String> receivedValues = new HashSet<>();
+ waitForCondition(() -> {
+ ConsumerRecords<byte[], byte[]> records =
shareConsumer.poll(Duration.ofMillis(1000));
+ for (ConsumerRecord<byte[], byte[]> record : records) {
+ if (record.partition() == 1) {
+ receivedValues.add(new String(record.value()));
+ }
+ }
+ return receivedValues.size() >= expectedValues.size();
+ }, 30000L, 500L, () -> "did not receive all records from the newly
added partition");
+ assertEquals(expectedValues.size(), receivedValues.size());
+ }
+ }
}
diff --git
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupMetadataManager.java
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupMetadataManager.java
index 238e637e05c..97a49040f56 100644
---
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupMetadataManager.java
+++
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupMetadataManager.java
@@ -3133,18 +3133,42 @@ public class GroupMetadataManager {
}
addInitializingTopicsRecords(groupId, records,
topicPartitionChangeMap);
- return Optional.of(buildInitializeShareGroupStateRequest(groupId,
groupEpoch, topicPartitionChangeMap));
+
+ // Topics already known to the group (already initialized or already
initializing) whose
+ // newly added partitions are now being initialized must start at
offset 0 to avoid losing
+ // records produced before initialization completes. Brand-new topics
(seen for the first
+ // time, present in neither map) use -1 so that the group's
share.auto.offset.reset strategy
+ // applies. This is read before the records above are replayed, so a
brand-new topic is
+ // correctly absent here.
+ Set<Uuid> alreadyKnownTopics = null;
+ if (shareGroupStatePartitionMetadata.containsKey(groupId)) {
+ ShareGroupStatePartitionMetadataInfo info =
shareGroupStatePartitionMetadata.get(groupId);
+ alreadyKnownTopics = new HashSet<>();
+ alreadyKnownTopics.addAll(info.initializedTopics().keySet());
+ alreadyKnownTopics.addAll(info.initializingTopics().keySet());
+ }
+
+ return Optional.of(buildInitializeShareGroupStateRequest(groupId,
groupEpoch, topicPartitionChangeMap, alreadyKnownTopics != null ?
alreadyKnownTopics : Set.of()));
}
- private InitializeShareGroupStateParameters
buildInitializeShareGroupStateRequest(String groupId, int groupEpoch, Map<Uuid,
InitMapValue> topicPartitions) {
+ private InitializeShareGroupStateParameters
buildInitializeShareGroupStateRequest(
+ String groupId,
+ int groupEpoch,
+ Map<Uuid, InitMapValue> topicPartitions,
+ Set<Uuid> alreadyKnownTopics
+ ) {
return new
InitializeShareGroupStateParameters.Builder().setGroupTopicPartitionData(
new GroupTopicPartitionData<>(groupId,
topicPartitions.entrySet().stream()
- .map(entry -> new TopicData<>(
- entry.getKey(),
- entry.getValue().partitions().stream()
- .map(partitionId ->
PartitionFactory.newPartitionStateData(partitionId, groupEpoch, -1))
- .toList())
- ).toList()
+ .map(entry -> {
+ // New partitions of an already-known topic start at 0;
brand-new topics use -1
+ // so that the group's share.auto.offset.reset strategy
applies.
+ long startOffset =
alreadyKnownTopics.contains(entry.getKey()) ? 0L :
PartitionFactory.UNINITIALIZED_START_OFFSET;
+ return new TopicData<>(
+ entry.getKey(),
+ entry.getValue().partitions().stream()
+ .map(partitionId ->
PartitionFactory.newPartitionStateData(partitionId, groupEpoch, startOffset))
+ .toList());
+ }).toList()
)).build();
}
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 ef816ee2ac6..9f348600f4c 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
@@ -27247,12 +27247,15 @@ public class GroupMetadataManagerTest {
assertTrue(actual.isPresent());
assertRecordEquals(expected, actual.get());
+ // t1 is already initialized, so its newly added partitions (2, 3)
must start at offset 0
+ // to avoid losing records produced before initialization completes.
verifyShareGroupHeartbeatInitializeRequest(
result.response().getValue(),
Map.of(
t1Uuid,
Set.of(2, 3)
),
+ Map.of(t1Uuid, 0L),
groupId,
3,
true
@@ -27262,6 +27265,114 @@ public class GroupMetadataManagerTest {
verify(context.metrics,
times(2)).record(SHARE_GROUP_REBALANCES_SENSOR_NAME);
}
+ @Test
+ public void testShareGroupHeartbeatInitializeMixedNewAndExpandedTopics() {
+ // A single heartbeat that both adds a brand-new topic and expands an
already-initialized
+ // topic must produce one initialize request where the new topic uses
the uninitialized (-1)
+ // start offset (so share.auto.offset.reset applies) while the
expanded topic's new
+ // partitions use a fixed start offset of 0 (to avoid losing records).
+ MockPartitionAssignor assignor = new MockPartitionAssignor("range");
+ assignor.prepareGroupAssignment(new GroupAssignment(Map.of()));
+ GroupMetadataManagerTestContext context = new
GroupMetadataManagerTestContext.Builder()
+ .withShareGroupAssignor(assignor)
+ .build();
+
+ Uuid t1Uuid = Uuid.randomUuid();
+ String t1Name = "t1";
+ Uuid t2Uuid = Uuid.randomUuid();
+ String t2Name = "t2";
+ Uuid t3Uuid = Uuid.randomUuid();
+ String t3Name = "t3";
+ CoordinatorMetadataImage image = new MetadataImageBuilder()
+ .addTopic(t1Uuid, t1Name, 2)
+ .addTopic(t2Uuid, t2Name, 2)
+ .buildCoordinatorMetadataImage();
+
+ String groupId = "share-group";
+
+
context.groupMetadataManager.onMetadataUpdate(mock(CoordinatorMetadataDelta.class),
image);
+
+ Uuid memberId = Uuid.randomUuid();
+ CoordinatorResult<Map.Entry<ShareGroupHeartbeatResponseData,
Optional<InitializeShareGroupStateParameters>>, CoordinatorRecord> result =
context.shareGroupHeartbeat(
+ new ShareGroupHeartbeatRequestData()
+ .setGroupId(groupId)
+ .setMemberId(memberId.toString())
+ .setMemberEpoch(0)
+ .setSubscribedTopicNames(List.of(t1Name, t2Name)));
+
+ // Both topics are brand-new, so both initialize at the uninitialized
(-1) start offset.
+ verifyShareGroupHeartbeatInitializeRequest(
+ result.response().getValue(),
+ Map.of(
+ t1Uuid, Set.of(0, 1),
+ t2Uuid, Set.of(0, 1)
+ ),
+ groupId,
+ 2,
+ true
+ );
+
+ context.groupMetadataManager.replay(
+ new ShareGroupStatePartitionMetadataKey()
+ .setGroupId(groupId),
+ new ShareGroupStatePartitionMetadataValue()
+ .setInitializingTopics(List.of())
+ .setInitializedTopics(List.of(
+ new
ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
+ .setTopicId(t1Uuid)
+ .setTopicName(t1Name)
+ .setPartitions(List.of(0, 1)),
+ new
ShareGroupStatePartitionMetadataValue.TopicPartitionsInfo()
+ .setTopicId(t2Uuid)
+ .setTopicName(t2Name)
+ .setPartitions(List.of(0, 1))
+ ))
+ .setDeletingTopics(List.of())
+ );
+
+ // t1 expands from 2 to 4 partitions and a brand-new topic t3 (3
partitions) is added.
+ image = new MetadataImageBuilder()
+ .addTopic(t1Uuid, t1Name, 4)
+ .addTopic(t2Uuid, t2Name, 2)
+ .addTopic(t3Uuid, t3Name, 3)
+ .buildCoordinatorMetadataImage();
+
+
context.groupMetadataManager.onMetadataUpdate(mock(CoordinatorMetadataDelta.class),
image);
+
+ assignor.prepareGroupAssignment(new GroupAssignment(
+ Map.of(
+ memberId.toString(),
+ new MemberAssignmentImpl(
+ Map.of(
+ t1Uuid, Set.of(0, 1),
+ t2Uuid, Set.of(0, 1)
+ )
+ )
+ )
+ ));
+
+ result = context.shareGroupHeartbeat(
+ new ShareGroupHeartbeatRequestData()
+ .setGroupId(groupId)
+ .setMemberId(memberId.toString())
+ .setMemberEpoch(2)
+ .setSubscribedTopicNames(List.of(t1Name, t2Name, t3Name)));
+
+ // Single request: t1's new partitions {2,3} (already-initialized
topic) start at 0, while
+ // brand-new topic t3's partitions {0,1,2} use the uninitialized (-1)
start offset.
+ verifyShareGroupHeartbeatInitializeRequest(
+ result.response().getValue(),
+ Map.of(
+ t1Uuid, Set.of(2, 3),
+ t3Uuid, Set.of(0, 1, 2)
+ ),
+ Map.of(t1Uuid, 0L),
+ groupId,
+ 3,
+ true
+ );
+ }
+
@Test
public void
testShareGroupHeartbeatDoesNotBumpGroupEpochDuringAssignmentDelay() {
Uuid t1Uuid = Uuid.randomUuid();
@@ -27441,9 +27552,12 @@ public class GroupMetadataManagerTest {
))
);
+ // t1 was already in the initializing state, so retrying its
initialization uses a fixed
+ // start offset of 0 rather than re-deferring to the
share.auto.offset.reset strategy.
verifyShareGroupHeartbeatInitializeRequest(
result.response().getValue(),
Map.of(t1Uuid, Set.of(0, 1)),
+ Map.of(t1Uuid, 0L),
groupId,
3,
true
@@ -29119,6 +29233,18 @@ public class GroupMetadataManagerTest {
String groupId,
int stateEpoch,
boolean shouldExist
+ ) {
+ // Brand-new topics are expected to use the uninitialized (-1) start
offset.
+ verifyShareGroupHeartbeatInitializeRequest(initRequest,
expectedTopicPartitionsMap, Map.of(), groupId, stateEpoch, shouldExist);
+ }
+
+ private void verifyShareGroupHeartbeatInitializeRequest(
+ Optional<InitializeShareGroupStateParameters> initRequest,
+ Map<Uuid, Set<Integer>> expectedTopicPartitionsMap,
+ Map<Uuid, Long> expectedStartOffsetByTopic,
+ String groupId,
+ int stateEpoch,
+ boolean shouldExist
) {
if (shouldExist) {
assertTrue(initRequest.isPresent());
@@ -29126,10 +29252,11 @@ public class GroupMetadataManagerTest {
assertEquals(groupId, request.groupTopicPartitionData().groupId());
Map<Uuid, Set<Integer>> actualTopicPartitionsMap = new HashMap<>();
for (TopicData<PartitionStateData> topicData :
request.groupTopicPartitionData().topicsData()) {
+ long expectedStartOffset =
expectedStartOffsetByTopic.getOrDefault(topicData.topicId(), -1L);
actualTopicPartitionsMap.computeIfAbsent(topicData.topicId(),
k -> new HashSet<>())
.addAll(topicData.partitions().stream().map(partitionData
-> {
assertEquals(stateEpoch, partitionData.stateEpoch());
- assertEquals(-1, partitionData.startOffset());
+ assertEquals(expectedStartOffset,
partitionData.startOffset());
return partitionData.partition();
}).toList());
}