This is an automated email from the ASF dual-hosted git repository.
AndrewJSchofield 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 93d0db45199 KAFKA-20723: Merge records while coalescing share DLQ
requests. [2/N] (#22662)
93d0db45199 is described below
commit 93d0db451995652a5d0c45c544a436cf2fa1588a
Author: Sushant Mahajan <[email protected]>
AuthorDate: Wed Jun 24 21:08:11 2026 +0530
KAFKA-20723: Merge records while coalescing share DLQ requests. [2/N]
(#22662)
Current code in `ShareGroupDLQStateManager.coalesceProduceRequests`
combines requests by appending records in a list. For example, consider
2 DLQ requests `r1` and `r2`:
```
r1: (DLQPartition: 0, records: [a, b, c]),
r2: (DLQPartition: 0, records: [p, q, r])
would result in
produce request: (...., partitionData: [
(0, [a,b,c]),
(0, [p,q,r])
]
```
This is incorrect as the API (`KafkaApis.handleProduceRequest`) will
overwrite the record entries for the same `(topicId, partition)`
ignoring `(0, [a,b,c])`. To fix this the caller must send merged records
in
the request, which should be:
```
produce request: (...., partitionData: [
(0, [a,b,c,p,q,r])
]
```
Headers for records are preserved in both cases.
This PR fixes the issue caller side (`ShareGroupDLQStateManager`) and
adds a new unit test to verify the same.
Reviewers: Andrew Schofield <[email protected]>, Apoorv Mittal
<[email protected]>
---
.../share/dlq/ShareGroupDLQStateManager.java | 57 ++++++++++++---
.../share/dlq/ShareGroupDLQStateManagerTest.java | 85 +++++++++++++++++++++-
2 files changed, 131 insertions(+), 11 deletions(-)
diff --git
a/server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQStateManager.java
b/server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQStateManager.java
index 8a0b3e04d26..799346ebf8c 100644
---
a/server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQStateManager.java
+++
b/server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQStateManager.java
@@ -924,19 +924,24 @@ public class ShareGroupDLQStateManager {
// Visibility for tests
static CoalesceResults coalesceProduceRequests(List<ProduceRequestHandler>
handlers) {
// Above handlers are destined for the same broker node - it could be
for different DLQ topics and partitions
- // but the same broker node. Now the produce request requires each
topic data request to be
- // scoped to a specific topic/topicId and the partition data could
have all the record information
- // and the destination DLQ partition. To accomplish this, we will map
handlers by DLQ topic id.
- Map<Uuid, ProduceRequestData.TopicProduceData> produceHandlerMap = new
HashMap<>();
+ // but the same broker node. The produce request requires each topic
data request to be scoped to a
+ // specific topic/topicId, and within a topic each partition must
appear at most once (the broker keys
+ // partitions by (topicId, index) and would otherwise drop all but one
entry). So we first collect the
+ // records into a map keyed by DLQ topic id and then DLQ partition -
merging the records of all handlers
+ // that target the same (topic, partition) - and then build a single
produce request from that map.
+ Map<Uuid, String> topicNames = new HashMap<>();
+ Map<Uuid, Map<Integer, List<MemoryRecords>>>
recordsByTopicAndPartition = new LinkedHashMap<>();
List<ProduceRequestHandler> liveHandlers = new
ArrayList<>(handlers.size());
handlers.forEach(handler -> {
try {
ProduceRequestData.TopicProduceData topicProduceData =
handler.topicProduceData();
- produceHandlerMap.computeIfAbsent(topicProduceData.topicId(),
topicId ->
- new ProduceRequestData.TopicProduceData()
- .setName(topicProduceData.name())
- .setTopicId(topicId)
- ).partitionData().addAll(topicProduceData.partitionData());
+ Uuid topicId = topicProduceData.topicId();
+ topicNames.putIfAbsent(topicId, topicProduceData.name());
+ Map<Integer, List<MemoryRecords>> partitionRecords =
+ recordsByTopicAndPartition.computeIfAbsent(topicId, k ->
new LinkedHashMap<>());
+ topicProduceData.partitionData().forEach(partitionData ->
+ partitionRecords.computeIfAbsent(partitionData.index(), k
-> new ArrayList<>())
+ .add((MemoryRecords) partitionData.records()));
liveHandlers.add(handler);
} catch (Exception exception) {
log.error("Unable to coalesce ProduceRequestData for handler
{}. It will be skipped from DLQ.", handler, exception);
@@ -944,8 +949,21 @@ public class ShareGroupDLQStateManager {
}
});
+ ProduceRequestData.TopicProduceDataCollection topicData = new
ProduceRequestData.TopicProduceDataCollection();
+ recordsByTopicAndPartition.forEach((topicId, partitionRecords) -> {
+ List<ProduceRequestData.PartitionProduceData> partitionData = new
ArrayList<>(partitionRecords.size());
+ partitionRecords.forEach((partitionIndex, records) ->
+ partitionData.add(new ProduceRequestData.PartitionProduceData()
+ .setIndex(partitionIndex)
+ .setRecords(mergeRecords(records))));
+ topicData.add(new ProduceRequestData.TopicProduceData()
+ .setName(topicNames.get(topicId))
+ .setTopicId(topicId)
+ .setPartitionData(partitionData));
+ });
+
ProduceRequestData data = new ProduceRequestData()
- .setTopicData(new
ProduceRequestData.TopicProduceDataCollection(produceHandlerMap.values().iterator()))
+ .setTopicData(topicData)
.setAcks((short) -1) // all replicas
.setTimeoutMs(ServerConfigs.REQUEST_TIMEOUT_MS_DEFAULT);
@@ -954,4 +972,23 @@ public class ShareGroupDLQStateManager {
liveHandlers
);
}
+
+ /**
+ * Merges the records of all handlers that target the same DLQ partition
into a single {@link MemoryRecords}
+ * (one record batch). The partition must appear only once in the
coalesced produce request, and a produce
+ * request is only allowed one record batch per partition - so when more
than one handler contributes records
+ * for a partition, they are combined into a single batch.
+ */
+ private static MemoryRecords mergeRecords(List<MemoryRecords> recordsList)
{
+ if (recordsList.size() == 1) {
+ return recordsList.get(0);
+ }
+ List<SimpleRecord> simpleRecords = new ArrayList<>();
+ for (MemoryRecords records : recordsList) {
+ for (Record record : records.records()) {
+ simpleRecords.add(new SimpleRecord(record.timestamp(),
record.key(), record.value(), record.headers()));
+ }
+ }
+ return MemoryRecords.withRecords(Compression.NONE,
simpleRecords.toArray(new SimpleRecord[0]));
+ }
}
diff --git
a/server/src/test/java/org/apache/kafka/server/share/dlq/ShareGroupDLQStateManagerTest.java
b/server/src/test/java/org/apache/kafka/server/share/dlq/ShareGroupDLQStateManagerTest.java
index a1767ab634b..2fd2cf2b76b 100644
---
a/server/src/test/java/org/apache/kafka/server/share/dlq/ShareGroupDLQStateManagerTest.java
+++
b/server/src/test/java/org/apache/kafka/server/share/dlq/ShareGroupDLQStateManagerTest.java
@@ -59,6 +59,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
+import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -1401,6 +1402,79 @@ class ShareGroupDLQStateManagerTest {
verify(mockMetrics, times(2)).recordDLQProduce(GROUP_ID);
}
+ @Test
+ public void testCoalesceProduceRequestsMergesRecordsForSameDlqPartition()
throws Exception {
+ // One share group with two source topics, two partitions each (four
source topic-partitions). The DLQ
+ // topic has a single partition (default cache helper), so all four
handlers target the same DLQ
+ // (topic, partition). Their records must be merged into a single
partition entry / single record batch,
+ // preserving every record - rather than producing duplicate partition
entries that the broker would
+ // collapse, dropping records.
+ Uuid sourceTopicAId = Uuid.randomUuid();
+ Uuid sourceTopicBId = Uuid.randomUuid();
+ List<TopicIdPartition> sources = List.of(
+ new TopicIdPartition(sourceTopicAId, 0, "source-topic-a"),
+ new TopicIdPartition(sourceTopicAId, 1, "source-topic-a"),
+ new TopicIdPartition(sourceTopicBId, 0, "source-topic-b"),
+ new TopicIdPartition(sourceTopicBId, 1, "source-topic-b"));
+
+ stateManager = builder().build();
+ List<ShareGroupDLQStateManager.ProduceRequestHandler> handlers = new
ArrayList<>();
+ for (TopicIdPartition source : sources) {
+ ShareGroupDLQStateManager.ProduceRequestHandler handler =
+ newHandlerForCoalesceTest(stateManager, GROUP_ID, source);
+ handler.populateDLQTopicData();
+ handlers.add(handler);
+ }
+
+ ShareGroupDLQStateManager.CoalesceResults result =
+ ShareGroupDLQStateManager.coalesceProduceRequests(handlers);
+
+ assertEquals(handlers, result.liveHandlers());
+
+ ProduceRequest request = ((ProduceRequest.Builder)
result.request()).build();
+ assertEquals(1, request.data().topicData().size(), "All records target
the single DLQ topic");
+ ProduceRequestData.TopicProduceData topic =
request.data().topicData().iterator().next();
+ assertEquals(DLQ_TOPIC_ID, topic.topicId());
+ assertEquals(1, topic.partitionData().size(),
+ "All four handlers target the same DLQ partition and must merge
into a single partition entry");
+ ProduceRequestData.PartitionProduceData partition =
topic.partitionData().get(0);
+ assertEquals(0, partition.index());
+
+ // The single merged batch must contain one record per source
topic-partition, with each record's
+ // source topic + partition headers intact.
+ MemoryRecords records = (MemoryRecords) partition.records();
+ assertEquals(sources.size(), recordCount(records));
+ Set<String> headers = new HashSet<>();
+ for (Record record : records.records()) {
+ headers.add(headerValue(record, HEADER_DLQ_ERRORS_TOPIC)
+ + "-" + headerValue(record, HEADER_DLQ_ERRORS_PARTITION));
+ }
+ assertEquals(
+ Set.of("source-topic-a-0", "source-topic-a-1", "source-topic-b-0",
"source-topic-b-1"),
+ headers);
+
+ verify(mockMetrics, times(sources.size())).recordDLQProduce(GROUP_ID);
+ }
+
+ private static int recordCount(MemoryRecords records) {
+ int count = 0;
+ Iterator<Record> iterator = records.records().iterator();
+ while (iterator.hasNext()) {
+ count++;
+ iterator.next();
+ }
+ return count;
+ }
+
+ private static String headerValue(Record record, String key) {
+ for (Header header : record.headers()) {
+ if (header.key().equals(key)) {
+ return new String(header.value(), StandardCharsets.UTF_8);
+ }
+ }
+ return null;
+ }
+
@Test
public void testCoalesceProduceRequestsKeepsDifferentDlqTopicsSeparate()
throws Exception {
String groupA = "group-a";
@@ -1744,10 +1818,19 @@ class ShareGroupDLQStateManagerTest {
ShareGroupDLQStateManager manager,
String groupId,
int sourcePartition
+ ) {
+ return newHandlerForCoalesceTest(manager, groupId,
+ new TopicIdPartition(SOURCE_TOPIC_ID, sourcePartition,
"source-topic"));
+ }
+
+ private static ShareGroupDLQStateManager.ProduceRequestHandler
newHandlerForCoalesceTest(
+ ShareGroupDLQStateManager manager,
+ String groupId,
+ TopicIdPartition source
) {
ShareGroupDLQRecordParameter param = new ShareGroupDLQRecordParameter(
groupId,
- new TopicIdPartition(SOURCE_TOPIC_ID, sourcePartition,
"source-topic"),
+ source,
0L, 0L,
Optional.empty(), Optional.empty());
return manager.new ProduceRequestHandler(