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 a7f9e519f25 KAFKA-20613: Add code to support copy record in DLQ state 
manager. (#22479)
a7f9e519f25 is described below

commit a7f9e519f25a4dabf4512723d2d5f290c63e2b3d
Author: Sushant Mahajan <[email protected]>
AuthorDate: Mon Jun 15 16:14:25 2026 +0530

    KAFKA-20613: Add code to support copy record in DLQ state manager. (#22479)
    
    * In this PR, we have added code to read original record key/value data
    from the offset being DLQ'ed. The data is read via the `LogReader`
    interface.
    * New tests have been added to check the functionality.
    
    Reviewers: Apoorv Mittal <[email protected]>
---
 .../src/main/scala/kafka/server/BrokerServer.scala |  12 +-
 .../share/dlq/DefaultShareGroupDLQManager.java     |  11 +-
 .../share/dlq/ShareGroupDLQStateManager.java       | 111 ++++++-
 .../share/dlq/ShareGroupDLQStateManagerTest.java   | 354 +++++++++++++++++++--
 4 files changed, 460 insertions(+), 28 deletions(-)

diff --git a/core/src/main/scala/kafka/server/BrokerServer.scala 
b/core/src/main/scala/kafka/server/BrokerServer.scala
index 8467b285f33..dce48fa521d 100644
--- a/core/src/main/scala/kafka/server/BrokerServer.scala
+++ b/core/src/main/scala/kafka/server/BrokerServer.scala
@@ -23,7 +23,7 @@ import kafka.log.LogManager
 import kafka.network.SocketServer
 import kafka.raft.KafkaRaftManager
 import kafka.server.metadata._
-import kafka.server.share.{ReplicaManagerPartitionMetadataProvider, 
ReplicaManagerLogReader, ShareCoordinatorMetadataCacheHelperImpl, 
SharePartitionManager}
+import kafka.server.share.{ReplicaManagerLogReader, 
ReplicaManagerPartitionMetadataProvider, 
ShareCoordinatorMetadataCacheHelperImpl, SharePartitionManager}
 import org.apache.kafka.common.config.ConfigException
 import org.apache.kafka.common.internals.Plugin
 import org.apache.kafka.common.message.ApiMessageType.ListenerType
@@ -177,6 +177,8 @@ class BrokerServer(
 
   private var shareGroupDLQManager: ShareGroupDLQManager = _
 
+  private var shareGroupLogReader: ReplicaManagerLogReader = _
+
   private def maybeChangeStatus(from: ProcessStatus, to: ProcessStatus): 
Boolean = {
     lock.lock()
     try {
@@ -396,6 +398,9 @@ class BrokerServer(
       /* create metrics object to be shared with share DLQ manager share 
partition manager*/
       shareGroupMetrics = new ShareGroupMetrics(time)
 
+      /* create log reader object to share with share group DLQ manager and 
SharePartitionManager */
+      shareGroupLogReader = new ReplicaManagerLogReader(replicaManager)
+
       /* create share group DLQ manager */
       shareGroupDLQManager = createShareGroupDLQManager()
 
@@ -467,7 +472,7 @@ class BrokerServer(
 
       sharePartitionManager = new SharePartitionManager(
         replicaManager,
-        new ReplicaManagerLogReader(replicaManager),
+        shareGroupLogReader,
         new ReplicaManagerPartitionMetadataProvider(replicaManager),
         (key: DelayedShareFetchKey) => 
replicaManager.completeDelayedShareFetchRequest(key),
         time,
@@ -777,7 +782,8 @@ class BrokerServer(
           new ShareCoordinatorMetadataCacheHelperImpl(metadataCache, key => 
shareCoordinator.partitionFor(key), config.interBrokerListenerName, 
groupConfigManager),
           Time.SYSTEM,
           shareGroupTimer,
-          shareGroupMetrics
+          shareGroupMetrics,
+          shareGroupLogReader
         )
       } else if 
(klass.getName.equals(classOf[NoOpShareGroupDLQManager].getName)) {
         info("Using no-op share group DLQ manager")
diff --git 
a/server/src/main/java/org/apache/kafka/server/share/dlq/DefaultShareGroupDLQManager.java
 
b/server/src/main/java/org/apache/kafka/server/share/dlq/DefaultShareGroupDLQManager.java
index 32e242b0ed1..73559b3cebf 100644
--- 
a/server/src/main/java/org/apache/kafka/server/share/dlq/DefaultShareGroupDLQManager.java
+++ 
b/server/src/main/java/org/apache/kafka/server/share/dlq/DefaultShareGroupDLQManager.java
@@ -19,6 +19,7 @@ package org.apache.kafka.server.share.dlq;
 
 import org.apache.kafka.clients.KafkaClient;
 import org.apache.kafka.common.utils.Time;
+import org.apache.kafka.server.share.LogReader;
 import org.apache.kafka.server.share.metrics.ShareGroupMetrics;
 import org.apache.kafka.server.util.timer.Timer;
 
@@ -45,9 +46,10 @@ public class DefaultShareGroupDLQManager implements 
ShareGroupDLQManager {
         ShareGroupDLQMetadataCacheHelper cacheHelper,
         Time time,
         Timer timer,
-        ShareGroupMetrics metrics
+        ShareGroupMetrics shareGroupMetrics,
+        LogReader logReader
     ) {
-        DefaultShareGroupDLQManager instance = new 
DefaultShareGroupDLQManager(client, cacheHelper, time, timer, metrics);
+        DefaultShareGroupDLQManager instance = new 
DefaultShareGroupDLQManager(client, cacheHelper, time, timer, 
shareGroupMetrics, logReader);
         instance.start();
         return instance;
     }
@@ -57,9 +59,10 @@ public class DefaultShareGroupDLQManager implements 
ShareGroupDLQManager {
         ShareGroupDLQMetadataCacheHelper cacheHelper,
         Time time,
         Timer timer,
-        ShareGroupMetrics shareGroupMetrics
+        ShareGroupMetrics shareGroupMetrics,
+        LogReader logReader
     ) {
-        this.stateManager = new ShareGroupDLQStateManager(client, cacheHelper, 
time, timer, shareGroupMetrics);
+        this.stateManager = new ShareGroupDLQStateManager(client, cacheHelper, 
time, timer, shareGroupMetrics, logReader);
     }
 
     private void start() {
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 b54878d7187..6bd1863aca8 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
@@ -36,30 +36,39 @@ import org.apache.kafka.common.message.ProduceResponseData;
 import org.apache.kafka.common.protocol.ApiKeys;
 import org.apache.kafka.common.protocol.Errors;
 import org.apache.kafka.common.record.internal.MemoryRecords;
+import org.apache.kafka.common.record.internal.Record;
+import org.apache.kafka.common.record.internal.RecordBatch;
 import org.apache.kafka.common.record.internal.SimpleRecord;
 import org.apache.kafka.common.requests.AbstractRequest;
 import org.apache.kafka.common.requests.CreateTopicsRequest;
 import org.apache.kafka.common.requests.CreateTopicsResponse;
+import org.apache.kafka.common.requests.FetchRequest;
 import org.apache.kafka.common.requests.ProduceRequest;
 import org.apache.kafka.common.requests.ProduceResponse;
 import org.apache.kafka.common.utils.Time;
 import org.apache.kafka.common.utils.internals.ExponentialBackoffManager;
 import org.apache.kafka.server.config.ServerConfigs;
+import org.apache.kafka.server.share.LogReader;
 import org.apache.kafka.server.share.metrics.ShareGroupMetrics;
+import org.apache.kafka.server.storage.log.FetchIsolation;
+import org.apache.kafka.server.storage.log.FetchParams;
 import org.apache.kafka.server.util.InterBrokerSendThread;
 import org.apache.kafka.server.util.RequestAndCompletionHandler;
 import org.apache.kafka.server.util.timer.Timer;
 import org.apache.kafka.server.util.timer.TimerTask;
+import org.apache.kafka.storage.internals.log.LogReadResult;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.nio.ByteBuffer;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
@@ -82,12 +91,21 @@ public class ShareGroupDLQStateManager {
     private final Time time;
     private final Timer timer;
     private final ShareGroupDLQMetadataCacheHelper cacheHelper;
+    private final LogReader logReader;
     private final ShareGroupMetrics shareGroupMetrics;
     public static final long REQUEST_BACKOFF_MS = 1_000L;
     public static final long REQUEST_BACKOFF_MAX_MS = 30_000L;
     private static final int MAX_REQUEST_ATTEMPTS = 5;
     private static final int RETRY_BACKOFF_EXP_BASE = 
CommonClientConfigs.RETRY_BACKOFF_EXP_BASE;
     private static final double RETRY_BACKOFF_JITTER = 
CommonClientConfigs.RETRY_BACKOFF_JITTER;
+
+    /**
+     * In most cases we expect the records getting DLQ'ed will be single 
offsets and
+     * not complete batches. Hence, using a large upper limit while reading 
from the log
+     * would be fruitless in most cases. Therefore, the value of 1 MB has been 
chosen
+     * for the DLQ related log reads.
+     */
+    private static final int DLQ_MAX_FETCH_BYTES = 1024 * 1024;
     private static final Logger log = 
LoggerFactory.getLogger(ShareGroupDLQStateManager.class);
 
     private final Set<Node> inFlight = new HashSet<>();
@@ -99,7 +117,8 @@ public class ShareGroupDLQStateManager {
         ShareGroupDLQMetadataCacheHelper cacheHelper,
         Time time,
         Timer timer,
-        ShareGroupMetrics shareGroupMetrics
+        ShareGroupMetrics shareGroupMetrics,
+        LogReader logReader
     ) {
         if (client == null) {
             throw new IllegalArgumentException("Kafkaclient must not be 
null.");
@@ -121,10 +140,15 @@ public class ShareGroupDLQStateManager {
             throw new IllegalArgumentException("ShareGroupMetrics must not be 
null.");
         }
 
+        if (logReader == null) {
+            throw new IllegalArgumentException("LogReader must not be null.");
+        }
+
         this.time = time;
         this.timer = timer;
         this.cacheHelper = cacheHelper;
         this.shareGroupMetrics = shareGroupMetrics;
+        this.logReader = logReader;
         this.sender = new SendThread(
             "ShareGroupDLQSendThread",
             client,
@@ -345,10 +369,19 @@ public class ShareGroupDLQStateManager {
         }
 
         public ProduceRequestData.TopicProduceData topicProduceData() {
+            Map<Long, Record> originalRecordData = maybeFetchRecordData();
+
             List<SimpleRecord> simpleRecords = new ArrayList<>();
             for (long i = param.firstOffset(); i <= param.lastOffset(); i++) {
                 long timestamp = time.hiResClockMs();
-                simpleRecords.add(new SimpleRecord(timestamp, (byte[]) null, 
null, headers(i)));
+                ByteBuffer key = null;
+                ByteBuffer value = null;
+                Record record = originalRecordData.get(i);
+                if (record != null) {
+                    key = record.hasKey() ? record.key() : null;
+                    value = record.hasValue() ? record.value() : null;
+                }
+                simpleRecords.add(new SimpleRecord(timestamp, key, value, 
headers(i)));
             }
 
             MemoryRecords records = MemoryRecords.withRecords(
@@ -653,6 +686,80 @@ public class ShareGroupDLQStateManager {
                     requestErrorResponse(clientResponseError.exception());
             }
         }
+
+        private Map<Long, Record> maybeFetchRecordData() {
+            if 
(!cacheHelper.isShareGroupDlqCopyRecordEnabled(param.groupId())) {
+                return Map.of();
+            }
+            long startTime = time.hiResClockMs();
+            TopicIdPartition tp = param.topicIdPartition();
+
+            FetchParams fetchParams = new FetchParams(
+                FetchRequest.CONSUMER_REPLICA_ID,           // -1, reading as 
a consumer
+                -1,                                         // replicaEpoch
+                0L,                                         // maxWaitMs - 
don't block
+                1,                                          // minBytes
+                DLQ_MAX_FETCH_BYTES,                        // maxBytes
+                FetchIsolation.HIGH_WATERMARK,              // committed only
+                Optional.empty()                            // clientMetadata
+            );
+
+            long nextOffset = param.firstOffset();
+            long endOffset = param.lastOffset();
+            int recordCount = (int) (param.lastOffset() - param.firstOffset() 
+ 1);
+
+            Map<Long, Record> recordMap = new HashMap<>(recordCount);
+            LinkedHashMap<TopicIdPartition, Long> offsets = new 
LinkedHashMap<>();
+            LinkedHashMap<TopicIdPartition, Integer> maxBytesMap = new 
LinkedHashMap<>();
+            maxBytesMap.put(tp, DLQ_MAX_FETCH_BYTES);
+
+            // We are fetching data for one TopicIdPartition only. Hence, there
+            // is no need to keep recreating the maxBytes map, and we can 
re-use a
+            // single copy. In similar vein, we needn't clear the offsets map
+            // either and just update the value corresponding to the 
TopicIdPartition
+            // key in offsets map within the while loop.
+            while (nextOffset <= endOffset) {
+                long readFrom = nextOffset; // offset requested for this 
iteration
+                offsets.put(tp, readFrom);
+
+                LinkedHashMap<TopicIdPartition, LogReadResult> result =
+                    logReader.read(fetchParams, Set.of(tp), offsets, 
maxBytesMap);
+
+                LogReadResult res = result.get(param.topicIdPartition());
+                if (res == null || res.error().code() != Errors.NONE.code()) {
+                    log.warn("Unable to fetch actual record at offset {} for 
handler {}.", readFrom, this);
+                    return Map.of();
+                }
+
+                res.info().delayedRemoteStorageFetch.ifPresent(data -> 
log.info(
+                    "Some offset data in is in remote storage. Skipping it."));
+
+                for (RecordBatch batch : res.info().records.batches()) {
+                    for (Record record : batch) {
+                        // A fetch can return a batch whose base offset is 
below the requested
+                        // offset, so skip any record at or before the read 
position to avoid
+                        // re-processing and dragging nextOffset backwards.
+                        if (record.offset() < readFrom) continue;
+                        if (record.offset() > param.lastOffset()) {
+                            log.trace("Preempted log fetch took {} ms for {} 
records starting at {} for {}", time.hiResClockMs() - startTime,
+                                recordCount, param.firstOffset(), this);
+                            return Map.copyOf(recordMap);
+                        }
+                        recordMap.put(record.offset(), record);
+                        nextOffset = Math.max(nextOffset, record.offset() + 
1); // never moves backwards
+                    }
+                }
+
+                // If the read position did not advance this iteration we have 
made no progress
+                // (reached HWM/LEO or only stale records were returned). Bail 
out to guarantee
+                // termination rather than re-fetching the same offset forever.
+                if (nextOffset <= readFrom) break;
+            }
+            log.trace("Full log fetch took {} ms for {} records starting at {} 
for {}", time.hiResClockMs() - startTime,
+                recordCount, param.firstOffset(), this);
+            log.info("Total offsets fetched: {}, Records found: {}", 
recordCount, recordMap.size());
+            return Map.copyOf(recordMap);
+        }
     }
 
     private class SendThread extends InterBrokerSendThread {
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 6eb5b659250..2da51e250d3 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
@@ -22,6 +22,7 @@ import org.apache.kafka.clients.MockClient;
 import org.apache.kafka.common.Node;
 import org.apache.kafka.common.TopicIdPartition;
 import org.apache.kafka.common.Uuid;
+import org.apache.kafka.common.compress.Compression;
 import org.apache.kafka.common.config.ConfigException;
 import org.apache.kafka.common.header.Header;
 import org.apache.kafka.common.message.CreateTopicsResponseData;
@@ -30,12 +31,14 @@ import org.apache.kafka.common.message.ProduceResponseData;
 import org.apache.kafka.common.protocol.Errors;
 import org.apache.kafka.common.record.internal.MemoryRecords;
 import org.apache.kafka.common.record.internal.Record;
+import org.apache.kafka.common.record.internal.SimpleRecord;
 import org.apache.kafka.common.requests.CreateTopicsRequest;
 import org.apache.kafka.common.requests.CreateTopicsResponse;
 import org.apache.kafka.common.requests.ProduceRequest;
 import org.apache.kafka.common.requests.ProduceResponse;
 import org.apache.kafka.common.utils.Time;
 import org.apache.kafka.common.utils.Utils;
+import org.apache.kafka.server.share.LogReader;
 import 
org.apache.kafka.server.share.dlq.ShareGroupDLQMetadataCacheHelper.TopicPartitionData;
 import org.apache.kafka.server.share.metrics.ShareGroupMetrics;
 import org.apache.kafka.server.util.MockTime;
@@ -43,6 +46,8 @@ import org.apache.kafka.server.util.timer.MockTimer;
 import org.apache.kafka.server.util.timer.SystemTimer;
 import org.apache.kafka.server.util.timer.SystemTimerReaper;
 import org.apache.kafka.server.util.timer.Timer;
+import org.apache.kafka.storage.internals.log.FetchDataInfo;
+import org.apache.kafka.storage.internals.log.LogReadResult;
 import org.apache.kafka.test.TestUtils;
 
 import org.junit.jupiter.api.AfterEach;
@@ -51,8 +56,10 @@ import org.mockito.Mockito;
 
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -72,6 +79,7 @@ import static 
org.apache.kafka.server.share.dlq.ShareGroupDLQStateManager.Produc
 import static 
org.apache.kafka.server.share.dlq.ShareGroupDLQStateManager.ProduceRequestHandler.HEADER_DLQ_ERRORS_OFFSET;
 import static 
org.apache.kafka.server.share.dlq.ShareGroupDLQStateManager.ProduceRequestHandler.HEADER_DLQ_ERRORS_PARTITION;
 import static 
org.apache.kafka.server.share.dlq.ShareGroupDLQStateManager.ProduceRequestHandler.HEADER_DLQ_ERRORS_TOPIC;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
@@ -82,6 +90,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anySet;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
@@ -100,6 +109,7 @@ class ShareGroupDLQStateManagerTest {
     private static final Uuid DLQ_TOPIC_ID = Uuid.randomUuid();
     private static final Uuid SOURCE_TOPIC_ID = Uuid.randomUuid();
     private static final Node DEFAULT_LEADER = new Node(0, HOST, PORT);
+    private static final LogReader MOCK_LOG_READER = mock(LogReader.class);
 
     private final MockTimer mockTimer = new MockTimer(MOCK_TIME);
     private final ShareGroupMetrics mockMetrics = 
mock(ShareGroupMetrics.class);
@@ -118,6 +128,7 @@ class ShareGroupDLQStateManagerTest {
         private Timer timer;
         private ShareGroupDLQMetadataCacheHelper cacheHelper;
         private ShareGroupMetrics shareGroupMetrics;
+        private LogReader logReader;
 
         Builder withClient(KafkaClient client) {
             this.client = client;
@@ -144,15 +155,21 @@ class ShareGroupDLQStateManagerTest {
             return this;
         }
 
+        Builder withLogReader(LogReader logReader) {
+            this.logReader = logReader;
+            return this;
+        }
+
         ShareGroupDLQStateManager build() {
             // Default to the test-class mockMetrics field so tests can verify 
interactions
             // without having to thread a custom metrics mock through the 
builder.
             return new ShareGroupDLQStateManager(
                 client != null ? client : new MockClient(MOCK_TIME),
-                cacheHelper != null ? cacheHelper : 
happyCacheHelper(DEFAULT_LEADER),
+                cacheHelper != null ? cacheHelper : 
cacheHelper(DEFAULT_LEADER),
                 time,
                 timer != null ? timer : mockTimer,
-                shareGroupMetrics != null ? shareGroupMetrics : mockMetrics
+                shareGroupMetrics != null ? shareGroupMetrics : mockMetrics,
+                logReader == null ? MOCK_LOG_READER : logReader
             );
         }
     }
@@ -172,7 +189,13 @@ class ShareGroupDLQStateManagerTest {
         );
     }
 
-    private static ShareGroupDLQMetadataCacheHelper happyCacheHelper(Node 
leader) {
+    /**
+     * Util method to populate DLQ metadata cache with sensible defaults.
+     *
+     * @param leader Node representing leader node for DLQ partition.
+     * @return Populated DLQ cache helper object.
+     */
+    private static ShareGroupDLQMetadataCacheHelper cacheHelper(Node leader) {
         ShareGroupDLQMetadataCacheHelper helper = 
mock(ShareGroupDLQMetadataCacheHelper.class);
         
when(helper.shareGroupDlqTopic(GROUP_ID)).thenReturn(Optional.of(DLQ_TOPIC));
         when(helper.shareGroupDlqTopicPrefix()).thenReturn(Optional.empty());
@@ -187,6 +210,7 @@ class ShareGroupDLQStateManagerTest {
             Optional.of(DLQ_TOPIC_ID),
             List.of(leader)
         ));
+        
when(helper.isShareGroupDlqCopyRecordEnabled(GROUP_ID)).thenReturn(false);
         return helper;
     }
 
@@ -208,7 +232,8 @@ class ShareGroupDLQStateManagerTest {
      * request. {@code sharedHeaders} are expected to be identical on every 
record in that partition;
      * the offset header is built per-record from {@code firstOffset}..{@code 
lastOffset}.
      */
-    private record ExpectedDlqPartition(long firstOffset, long lastOffset, 
Map<String, String> sharedHeaders) {
+    private record ExpectedDlqPartition(long firstOffset, long lastOffset, 
Map<String, String> sharedHeaders,
+                                        List<byte[]> keys, List<byte[]> 
values) {
     }
 
     /**
@@ -243,6 +268,19 @@ class ShareGroupDLQStateManagerTest {
                 expectedHeaders.put(HEADER_DLQ_ERRORS_OFFSET, 
Long.toString(expectedOffset));
                 assertEquals(expectedHeaders, actualHeaders,
                     "Partition " + partition.index() + " record at offset " + 
expectedOffset + " has unexpected headers");
+
+                if (!expected.keys().isEmpty()) {
+                    assertKeyValue(record, expected.keys.get(recordCount), 
true);
+                } else {
+                    assertFalse(record.hasKey());
+                }
+
+                if (!expected.values().isEmpty()) {
+                    assertKeyValue(record, expected.values.get(recordCount), 
false);
+                } else {
+                    assertFalse(record.hasValue());
+                }
+
                 expectedOffset++;
                 recordCount++;
             }
@@ -251,20 +289,46 @@ class ShareGroupDLQStateManagerTest {
         }
     }
 
+    private static void assertKeyValue(Record record, byte[] expectedData, 
boolean isKey) {
+        if (expectedData == null) {
+            if (isKey) {
+                assertFalse(record.hasKey());
+            } else {
+                assertFalse(record.hasValue());
+            }
+        } else {
+            if (isKey && !record.hasValue()) {
+                fail("record key not found");
+            }
+
+            if (!isKey && !record.hasValue()) {
+                fail("record value not found");
+            }
+
+            byte[] actualChunk = new byte[expectedData.length];
+            if (isKey) {
+                record.key().get(actualChunk);
+            } else {
+                record.value().get(actualChunk);
+            }
+            assertArrayEquals(expectedData, actualChunk);
+        }
+    }
+
     // ---- Constructor null-check tests ----
 
     @Test
     public void testConstructorRejectsNullClient() {
         ShareGroupDLQMetadataCacheHelper cacheHelper = 
mock(ShareGroupDLQMetadataCacheHelper.class);
         assertThrows(IllegalArgumentException.class,
-            () -> new ShareGroupDLQStateManager(null, cacheHelper, MOCK_TIME, 
mockTimer, mockMetrics));
+            () -> new ShareGroupDLQStateManager(null, cacheHelper, MOCK_TIME, 
mockTimer, mockMetrics, MOCK_LOG_READER));
     }
 
     @Test
     public void testConstructorRejectsNullCacheHelper() {
         KafkaClient client = mock(KafkaClient.class);
         assertThrows(IllegalArgumentException.class,
-            () -> new ShareGroupDLQStateManager(client, null, MOCK_TIME, 
mockTimer, mockMetrics));
+            () -> new ShareGroupDLQStateManager(client, null, MOCK_TIME, 
mockTimer, mockMetrics, MOCK_LOG_READER));
     }
 
     @Test
@@ -272,7 +336,7 @@ class ShareGroupDLQStateManagerTest {
         KafkaClient client = mock(KafkaClient.class);
         ShareGroupDLQMetadataCacheHelper cacheHelper = 
mock(ShareGroupDLQMetadataCacheHelper.class);
         assertThrows(IllegalArgumentException.class,
-            () -> new ShareGroupDLQStateManager(client, cacheHelper, null, 
mockTimer, mockMetrics));
+            () -> new ShareGroupDLQStateManager(client, cacheHelper, null, 
mockTimer, mockMetrics, MOCK_LOG_READER));
     }
 
     @Test
@@ -280,7 +344,7 @@ class ShareGroupDLQStateManagerTest {
         KafkaClient client = mock(KafkaClient.class);
         ShareGroupDLQMetadataCacheHelper cacheHelper = 
mock(ShareGroupDLQMetadataCacheHelper.class);
         assertThrows(IllegalArgumentException.class,
-            () -> new ShareGroupDLQStateManager(client, cacheHelper, 
MOCK_TIME, null, mockMetrics));
+            () -> new ShareGroupDLQStateManager(client, cacheHelper, 
MOCK_TIME, null, mockMetrics, MOCK_LOG_READER));
     }
 
     @Test
@@ -288,7 +352,15 @@ class ShareGroupDLQStateManagerTest {
         KafkaClient client = mock(KafkaClient.class);
         ShareGroupDLQMetadataCacheHelper cacheHelper = 
mock(ShareGroupDLQMetadataCacheHelper.class);
         assertThrows(IllegalArgumentException.class,
-            () -> new ShareGroupDLQStateManager(client, cacheHelper, 
MOCK_TIME, mockTimer, null));
+            () -> new ShareGroupDLQStateManager(client, cacheHelper, 
MOCK_TIME, mockTimer, null, MOCK_LOG_READER));
+    }
+
+    @Test
+    public void testConstructorRejectsNullLogReader() {
+        KafkaClient client = mock(KafkaClient.class);
+        ShareGroupDLQMetadataCacheHelper cacheHelper = 
mock(ShareGroupDLQMetadataCacheHelper.class);
+        assertThrows(IllegalArgumentException.class,
+            () -> new ShareGroupDLQStateManager(client, cacheHelper, 
MOCK_TIME, mockTimer, mockMetrics, null));
     }
 
     // ---- Lifecycle tests ----
@@ -428,7 +500,7 @@ class ShareGroupDLQStateManagerTest {
                 HEADER_DLQ_ERRORS_GROUP, GROUP_ID,
                 HEADER_DLQ_ERRORS_DELIVERY_COUNT, "1",
                 HEADER_DLQ_ERRORS_MESSAGE, "simulated cause"
-            ))
+            ), List.of(), List.of())
         ));
         verify(mockMetrics).recordDLQProduce(GROUP_ID);
         verify(mockMetrics).recordDLQRecordWrite(GROUP_ID, 3);
@@ -481,7 +553,7 @@ class ShareGroupDLQStateManagerTest {
                 HEADER_DLQ_ERRORS_GROUP, GROUP_ID,
                 HEADER_DLQ_ERRORS_DELIVERY_COUNT, "1",
                 HEADER_DLQ_ERRORS_MESSAGE, "simulated cause"
-            ))
+            ), List.of(), List.of())
         ));
         verify(mockMetrics).recordDLQProduce(GROUP_ID);
         verify(mockMetrics).recordDLQRecordWrite(GROUP_ID, 3);
@@ -539,7 +611,7 @@ class ShareGroupDLQStateManagerTest {
                 HEADER_DLQ_ERRORS_GROUP, GROUP_ID,
                 HEADER_DLQ_ERRORS_DELIVERY_COUNT, "1",
                 HEADER_DLQ_ERRORS_MESSAGE, "simulated cause"
-            ))
+            ), List.of(), List.of())
         ));
         verify(mockMetrics).recordDLQProduce(GROUP_ID);
         verify(mockMetrics).recordDLQRecordWrite(GROUP_ID, 3);
@@ -694,7 +766,7 @@ class ShareGroupDLQStateManagerTest {
                     HEADER_DLQ_ERRORS_GROUP, GROUP_ID,
                     HEADER_DLQ_ERRORS_DELIVERY_COUNT, "1",
                     HEADER_DLQ_ERRORS_MESSAGE, "simulated cause"
-                ))
+                ), List.of(), List.of())
             ));
             // CreateTopics retried, but produce only ran once (after the 
eventual create success).
             verify(mockMetrics).recordDLQProduce(GROUP_ID);
@@ -742,7 +814,7 @@ class ShareGroupDLQStateManagerTest {
                     HEADER_DLQ_ERRORS_GROUP, GROUP_ID,
                     HEADER_DLQ_ERRORS_DELIVERY_COUNT, "1",
                     HEADER_DLQ_ERRORS_MESSAGE, "simulated cause"
-                ))
+                ), List.of(), List.of())
             );
             for (ProduceRequest pr : capturedProduces) {
                 assertDlqProduceRecordHeaders(pr, expectedByPartition);
@@ -991,12 +1063,12 @@ class ShareGroupDLQStateManagerTest {
             HEADER_DLQ_ERRORS_TOPIC, "source-topic",
             HEADER_DLQ_ERRORS_PARTITION, "0",
             HEADER_DLQ_ERRORS_GROUP, GROUP_ID
-        ));
+        ), List.of(), List.of());
         ExpectedDlqPartition expectedDlqPartition1 = new 
ExpectedDlqPartition(0L, 0L, Map.of(
             HEADER_DLQ_ERRORS_TOPIC, "source-topic",
             HEADER_DLQ_ERRORS_PARTITION, "1",
             HEADER_DLQ_ERRORS_GROUP, GROUP_ID
-        ));
+        ), List.of(), List.of());
         if (capturedProduces.size() == 1) {
             assertDlqProduceRecordHeaders(capturedProduces.get(0), Map.of(
                 0, expectedDlqPartition0,
@@ -1054,7 +1126,7 @@ class ShareGroupDLQStateManagerTest {
                 HEADER_DLQ_ERRORS_TOPIC, "source-topic",
                 HEADER_DLQ_ERRORS_PARTITION, "0",
                 HEADER_DLQ_ERRORS_GROUP, GROUP_ID
-            ))
+            ), List.of(), List.of())
         ));
         verify(mockMetrics).recordDLQProduce(GROUP_ID);
         verify(mockMetrics).recordDLQRecordWrite(GROUP_ID, 1);
@@ -1182,7 +1254,7 @@ class ShareGroupDLQStateManagerTest {
 
         client.prepareResponseFrom(body -> true, null, DEFAULT_LEADER);
 
-        stateManager = 
builder().withClient(client).withCacheHelper(happyCacheHelper(DEFAULT_LEADER)).build();
+        stateManager = 
builder().withClient(client).withCacheHelper(cacheHelper(DEFAULT_LEADER)).build();
 
         Future<Boolean> done = executor.submit(() -> {
             long start = System.currentTimeMillis();
@@ -1389,6 +1461,251 @@ class ShareGroupDLQStateManagerTest {
         assertEquals(DLQ_TOPIC_ID, 
request.data().topicData().iterator().next().topicId());
     }
 
+    // --- DLQ record with copy record enabled ---
+
+    @Test
+    public void testDLQRecordCopyEnabled() throws Exception {
+        MockClient client = new MockClient(MOCK_TIME);
+        List<ProduceRequest> capturedProduces = new ArrayList<>();
+        client.prepareResponseFrom(
+            body -> {
+                if (body instanceof ProduceRequest pr) {
+                    capturedProduces.add(pr);
+                    return true;
+                }
+                return false;
+            },
+            successfulProduceResponse(0),
+            DEFAULT_LEADER
+        );
+
+        ShareGroupDLQRecordParameter param = param();
+        byte[] keyData1 = "key1".getBytes(StandardCharsets.UTF_8);
+        byte[] valueData1 = "value1".getBytes(StandardCharsets.UTF_8);
+        byte[] keyData2 = "key2".getBytes(StandardCharsets.UTF_8);
+        byte[] valueData2 = "value2".getBytes(StandardCharsets.UTF_8);
+        byte[] keyData3 = "key3".getBytes(StandardCharsets.UTF_8);
+        byte[] valueData3 = "value3".getBytes(StandardCharsets.UTF_8);
+        LogReader logReader = mock(LogReader.class);
+        LogReadResult readResult = mock(LogReadResult.class);
+        when(readResult.error()).thenReturn(Errors.NONE);
+        when(readResult.info()).thenReturn(new FetchDataInfo(
+            null,
+            MemoryRecords.withRecords(
+                Compression.NONE,
+                new SimpleRecord(MOCK_TIME.milliseconds(), keyData1, 
valueData1),
+                new SimpleRecord(MOCK_TIME.milliseconds(), keyData2, 
valueData2),
+                new SimpleRecord(MOCK_TIME.milliseconds(), keyData3, 
valueData3)
+            )
+        ));
+        LinkedHashMap<TopicIdPartition, LogReadResult> readResultMap = new 
LinkedHashMap<>();
+        readResultMap.put(param.topicIdPartition(), readResult);
+        when(logReader.read(any(), anySet(), any(), any()))
+            .thenReturn(readResultMap);
+
+        ShareGroupDLQMetadataCacheHelper cacheHelper = 
cacheHelper(DEFAULT_LEADER);
+        
when(cacheHelper.isShareGroupDlqCopyRecordEnabled(any())).thenReturn(true);
+        stateManager = 
builder().withClient(client).withLogReader(logReader).withCacheHelper(cacheHelper).build();
+        stateManager.start();
+        assertNull(stateManager.dlq(param).get(10, TimeUnit.SECONDS));
+
+        assertEquals(1, capturedProduces.size());
+        assertDlqProduceRecordHeaders(capturedProduces.get(0), Map.of(
+            0, new ExpectedDlqPartition(0L, 2L, Map.of(
+                HEADER_DLQ_ERRORS_TOPIC, "source-topic",
+                HEADER_DLQ_ERRORS_PARTITION, "0",
+                HEADER_DLQ_ERRORS_GROUP, GROUP_ID,
+                HEADER_DLQ_ERRORS_DELIVERY_COUNT, "1",
+                HEADER_DLQ_ERRORS_MESSAGE, "simulated cause"
+            ), List.of(keyData1, keyData2, keyData3), List.of(valueData1, 
valueData2, valueData3))
+        ));
+        verify(mockMetrics).recordDLQProduce(GROUP_ID);
+        verify(mockMetrics).recordDLQRecordWrite(GROUP_ID, 3);
+        verify(mockMetrics, never()).recordDLQProduceFailed(any());
+    }
+
+    @Test
+    public void testDLQRecordCopyEnabledWithPartialLogReaderRecords() throws 
Exception {
+        MockClient client = new MockClient(MOCK_TIME);
+        List<ProduceRequest> capturedProduces = new ArrayList<>();
+        client.prepareResponseFrom(
+            body -> {
+                if (body instanceof ProduceRequest pr) {
+                    capturedProduces.add(pr);
+                    return true;
+                }
+                return false;
+            },
+            successfulProduceResponse(0),
+            DEFAULT_LEADER
+        );
+
+        ShareGroupDLQRecordParameter param = param();   // 3 offset requested
+        byte[] keyData1 = "key1".getBytes(StandardCharsets.UTF_8);
+        byte[] valueData1 = "value1".getBytes(StandardCharsets.UTF_8);
+        byte[] keyData2 = "key2".getBytes(StandardCharsets.UTF_8);
+        byte[] valueData2 = "value2".getBytes(StandardCharsets.UTF_8);
+        byte[] keyData3 = "key3".getBytes(StandardCharsets.UTF_8);
+        byte[] valueData3 = "value3".getBytes(StandardCharsets.UTF_8);
+        LogReader logReader = mock(LogReader.class);
+        LogReadResult readResult1 = mock(LogReadResult.class);
+        when(readResult1.error()).thenReturn(Errors.NONE);
+        // Return 2 records only
+        when(readResult1.info()).thenReturn(new FetchDataInfo(
+            null,
+            MemoryRecords.withRecords(
+                Compression.NONE,
+                new SimpleRecord(MOCK_TIME.milliseconds(), keyData1, 
valueData1),
+                new SimpleRecord(MOCK_TIME.milliseconds(), keyData2, 
valueData2)
+            )
+        ));
+        LogReadResult readResult2 = mock(LogReadResult.class);
+        when(readResult2.error()).thenReturn(Errors.NONE);
+        // Next read contains the 3rd offset as well
+        when(readResult2.info()).thenReturn(new FetchDataInfo(
+            null,
+            MemoryRecords.withRecords(
+                Compression.NONE,
+                new SimpleRecord(MOCK_TIME.milliseconds(), keyData1, 
valueData1),
+                new SimpleRecord(MOCK_TIME.milliseconds(), keyData2, 
valueData2),
+                new SimpleRecord(MOCK_TIME.milliseconds(), keyData3, 
valueData3)
+            )
+        ));
+
+        LinkedHashMap<TopicIdPartition, LogReadResult> readResultMap1 = new 
LinkedHashMap<>();
+        LinkedHashMap<TopicIdPartition, LogReadResult> readResultMap2 = new 
LinkedHashMap<>();
+        readResultMap1.put(param.topicIdPartition(), readResult1);
+        readResultMap2.put(param.topicIdPartition(), readResult2);
+        when(logReader.read(any(), anySet(), any(), any()))
+            .thenReturn(readResultMap1)
+            .thenReturn(readResultMap2);
+
+        ShareGroupDLQMetadataCacheHelper cacheHelper = 
cacheHelper(DEFAULT_LEADER);
+        
when(cacheHelper.isShareGroupDlqCopyRecordEnabled(any())).thenReturn(true);
+        stateManager = 
builder().withClient(client).withLogReader(logReader).withCacheHelper(cacheHelper).build();
+        stateManager.start();
+        assertNull(stateManager.dlq(param).get(10, TimeUnit.SECONDS));
+
+        assertEquals(1, capturedProduces.size());
+        assertDlqProduceRecordHeaders(capturedProduces.get(0), Map.of(
+            0, new ExpectedDlqPartition(0L, 2L, Map.of(
+                HEADER_DLQ_ERRORS_TOPIC, "source-topic",
+                HEADER_DLQ_ERRORS_PARTITION, "0",
+                HEADER_DLQ_ERRORS_GROUP, GROUP_ID,
+                HEADER_DLQ_ERRORS_DELIVERY_COUNT, "1",
+                HEADER_DLQ_ERRORS_MESSAGE, "simulated cause"
+            ), Arrays.asList(keyData1, keyData2, keyData3), 
Arrays.asList(valueData1, valueData2, valueData3))
+        ));
+        verify(mockMetrics).recordDLQProduce(GROUP_ID);
+        verify(mockMetrics).recordDLQRecordWrite(GROUP_ID, 3);
+        verify(mockMetrics, never()).recordDLQProduceFailed(any());
+    }
+
+    @Test
+    public void testDLQRecordCopyEnabledWithMultipleLogReaderIterations() 
throws Exception {
+        MockClient client = new MockClient(MOCK_TIME);
+        List<ProduceRequest> capturedProduces = new ArrayList<>();
+        client.prepareResponseFrom(
+            body -> {
+                if (body instanceof ProduceRequest pr) {
+                    capturedProduces.add(pr);
+                    return true;
+                }
+                return false;
+            },
+            successfulProduceResponse(0),
+            DEFAULT_LEADER
+        );
+
+        ShareGroupDLQRecordParameter param = param();   // 3 offsets requested
+        byte[] keyData1 = "key1".getBytes(StandardCharsets.UTF_8);
+        byte[] valueData1 = "value1".getBytes(StandardCharsets.UTF_8);
+        byte[] keyData2 = "key2".getBytes(StandardCharsets.UTF_8);
+        byte[] valueData2 = "value2".getBytes(StandardCharsets.UTF_8);
+        LogReader logReader = mock(LogReader.class);
+        LogReadResult readResult = mock(LogReadResult.class);
+        when(readResult.error()).thenReturn(Errors.NONE);
+        // Return 2 records only
+        when(readResult.info()).thenReturn(new FetchDataInfo(
+            null,
+            MemoryRecords.withRecords(
+                Compression.NONE,
+                new SimpleRecord(MOCK_TIME.milliseconds(), keyData1, 
valueData1),
+                new SimpleRecord(MOCK_TIME.milliseconds(), keyData2, 
valueData2)
+            )
+        ));
+        LinkedHashMap<TopicIdPartition, LogReadResult> readResultMap = new 
LinkedHashMap<>();
+        readResultMap.put(param.topicIdPartition(), readResult);
+        when(logReader.read(any(), anySet(), any(), any()))
+            .thenReturn(readResultMap);
+
+        ShareGroupDLQMetadataCacheHelper cacheHelper = 
cacheHelper(DEFAULT_LEADER);
+        
when(cacheHelper.isShareGroupDlqCopyRecordEnabled(any())).thenReturn(true);
+        stateManager = 
builder().withClient(client).withLogReader(logReader).withCacheHelper(cacheHelper).build();
+        stateManager.start();
+        assertNull(stateManager.dlq(param).get(10, TimeUnit.SECONDS));
+
+        assertEquals(1, capturedProduces.size());
+        assertDlqProduceRecordHeaders(capturedProduces.get(0), Map.of(
+            0, new ExpectedDlqPartition(0L, 2L, Map.of(
+                HEADER_DLQ_ERRORS_TOPIC, "source-topic",
+                HEADER_DLQ_ERRORS_PARTITION, "0",
+                HEADER_DLQ_ERRORS_GROUP, GROUP_ID,
+                HEADER_DLQ_ERRORS_DELIVERY_COUNT, "1",
+                HEADER_DLQ_ERRORS_MESSAGE, "simulated cause"
+            ), Arrays.asList(keyData1, keyData2, null), 
Arrays.asList(valueData1, valueData2, null))
+        ));
+        verify(mockMetrics).recordDLQProduce(GROUP_ID);
+        verify(mockMetrics).recordDLQRecordWrite(GROUP_ID, 3);
+        verify(mockMetrics, never()).recordDLQProduceFailed(any());
+    }
+
+    @Test
+    public void testDLQRecordCopyEnabledWithErrorOnLogRead() throws Exception {
+        MockClient client = new MockClient(MOCK_TIME);
+        List<ProduceRequest> capturedProduces = new ArrayList<>();
+        client.prepareResponseFrom(
+            body -> {
+                if (body instanceof ProduceRequest pr) {
+                    capturedProduces.add(pr);
+                    return true;
+                }
+                return false;
+            },
+            successfulProduceResponse(0),
+            DEFAULT_LEADER
+        );
+
+        ShareGroupDLQRecordParameter param = param();
+        LogReader logReader = mock(LogReader.class);
+        LogReadResult readResult = mock(LogReadResult.class);
+        when(readResult.error()).thenReturn(Errors.UNKNOWN_SERVER_ERROR);
+        LinkedHashMap<TopicIdPartition, LogReadResult> readResultMap = new 
LinkedHashMap<>();
+        readResultMap.put(param.topicIdPartition(), readResult);
+        when(logReader.read(any(), anySet(), any(), any()))
+            .thenReturn(readResultMap);
+
+        ShareGroupDLQMetadataCacheHelper cacheHelper = 
cacheHelper(DEFAULT_LEADER);
+        
when(cacheHelper.isShareGroupDlqCopyRecordEnabled(any())).thenReturn(true);
+        stateManager = 
builder().withClient(client).withLogReader(logReader).withCacheHelper(cacheHelper).build();
+        stateManager.start();
+        assertNull(stateManager.dlq(param).get(10, TimeUnit.SECONDS));
+
+        assertEquals(1, capturedProduces.size());
+        assertDlqProduceRecordHeaders(capturedProduces.get(0), Map.of(
+            0, new ExpectedDlqPartition(0L, 2L, Map.of(
+                HEADER_DLQ_ERRORS_TOPIC, "source-topic",
+                HEADER_DLQ_ERRORS_PARTITION, "0",
+                HEADER_DLQ_ERRORS_GROUP, GROUP_ID,
+                HEADER_DLQ_ERRORS_DELIVERY_COUNT, "1",
+                HEADER_DLQ_ERRORS_MESSAGE, "simulated cause"
+            ), List.of(), List.of())
+        ));
+        verify(mockMetrics).recordDLQProduce(GROUP_ID);
+        verify(mockMetrics).recordDLQRecordWrite(GROUP_ID, 3);
+        verify(mockMetrics, never()).recordDLQProduceFailed(any());
+    }
+
     private static ShareGroupDLQStateManager.ProduceRequestHandler 
newHandlerForCoalesceTest(
         ShareGroupDLQStateManager manager,
         String groupId,
@@ -1407,7 +1724,6 @@ class ShareGroupDLQStateManagerTest {
             3);
     }
 
-
     // ---- Response builder helpers ----
 
     private static ProduceResponse successfulProduceResponse(int partition) {


Reply via email to