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 e0d2d650ed1 KAFKA-20678: Support share group DLQ remote record fetch. 
(#22601)
e0d2d650ed1 is described below

commit e0d2d650ed1ae2b68624fd8de9d9db7fa266267c
Author: Sushant Mahajan <[email protected]>
AuthorDate: Tue Jun 30 03:20:00 2026 +0530

    KAFKA-20678: Support share group DLQ remote record fetch. (#22601)
    
    * In this PR, we have extracted the record fetch logic from
    `ShareGroupDLQStateManager` and encapsulated it in
    `ShareGroupDLQRecordFetcher`.
    * The fetcher also gets the ability to fetch the records from the remote
    tier and then returns the combined record map via its public `fetch`
    method.
    * Since the remote record fetch is inherently async, there is a change
    in the way the records are populated:
      * Earlier records population was lazy in that it was invoked when
    handlers were coalesced. Now, we will issue the record fetch call when
    the handler is enqueued. Otherwise, record population will block the
    `Sender` thread.
      * Furthermore, we also require storing additional state in the fetcher
    so
    that local and remote records could be held until combined.
    This is also divergent from previous code.
    * Lastly, we have added tests to `ShareGroupDLQRecordFetcherTest` and
    `ReplicaManagerLogReaderTest`.
    
    Reviewers: Apoorv Mittal <[email protected]>
---
 .../server/share/ReplicaManagerLogReader.java      | 128 +++++++
 .../server/share/ReplicaManagerLogReaderTest.java  | 403 +++++++++++++++++++++
 .../org/apache/kafka/server/share/LogReader.java   |  32 ++
 .../share/dlq/ShareGroupDLQRecordFetcher.java      | 226 ++++++++++++
 .../share/dlq/ShareGroupDLQStateManager.java       | 182 ++++------
 .../share/dlq/ShareGroupDLQRecordFetcherTest.java  | 291 +++++++++++++++
 .../share/dlq/ShareGroupDLQStateManagerTest.java   | 340 +++++++++++++----
 7 files changed, 1425 insertions(+), 177 deletions(-)

diff --git a/core/src/main/java/kafka/server/share/ReplicaManagerLogReader.java 
b/core/src/main/java/kafka/server/share/ReplicaManagerLogReader.java
index 71ce7c74ca9..acebdb6a735 100644
--- a/core/src/main/java/kafka/server/share/ReplicaManagerLogReader.java
+++ b/core/src/main/java/kafka/server/share/ReplicaManagerLogReader.java
@@ -20,10 +20,14 @@ import kafka.server.QuotaFactory;
 import kafka.server.ReplicaManager;
 
 import org.apache.kafka.common.TopicIdPartition;
+import org.apache.kafka.common.protocol.Errors;
 import org.apache.kafka.common.requests.FetchRequest;
+import org.apache.kafka.server.log.remote.storage.RemoteLogManager;
 import org.apache.kafka.server.share.LogReader;
 import org.apache.kafka.server.storage.log.FetchParams;
+import org.apache.kafka.storage.internals.log.FetchDataInfo;
 import org.apache.kafka.storage.internals.log.LogReadResult;
+import org.apache.kafka.storage.internals.log.RemoteStorageFetchInfo;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -31,11 +35,14 @@ import org.slf4j.LoggerFactory;
 import java.util.LinkedHashMap;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
 import java.util.stream.Collectors;
 
 import scala.Tuple2;
 import scala.collection.Seq;
 import scala.jdk.javaapi.CollectionConverters;
+import scala.jdk.javaapi.OptionConverters;
 import scala.runtime.BoxedUnit;
 
 /**
@@ -92,4 +99,125 @@ public class ReplicaManagerLogReader implements LogReader {
         log.trace("Data successfully retrieved by replica manager: {}", 
responseData);
         return responseData;
     }
+
+    @Override
+    public CompletableFuture<LinkedHashMap<TopicIdPartition, LogReadResult>> 
readAsync(
+            FetchParams fetchParams,
+            Set<TopicIdPartition> partitionsToFetch,
+            LinkedHashMap<TopicIdPartition, Long> topicPartitionFetchOffsets,
+            LinkedHashMap<TopicIdPartition, Integer> partitionMaxBytes,
+            boolean readRemote) {
+
+        if (partitionsToFetch.isEmpty()) {
+            return CompletableFuture.completedFuture(new LinkedHashMap<>());
+        }
+
+        // Perform the local read for all partitions once; remote follow-ups 
(if any) are issued per partition.
+        LinkedHashMap<TopicIdPartition, LogReadResult> localReadResults =
+            read(fetchParams, partitionsToFetch, topicPartitionFetchOffsets, 
partitionMaxBytes);
+
+        // One future per partition; combined into a single future once every 
partition has resolved.
+        LinkedHashMap<TopicIdPartition, CompletableFuture<LogReadResult>> 
futures = new LinkedHashMap<>();
+        for (TopicIdPartition topicIdPartition : partitionsToFetch) {
+            LogReadResult logReadResult = 
localReadResults.get(topicIdPartition);
+            if (logReadResult == null) {
+                futures.put(topicIdPartition, 
CompletableFuture.completedFuture(
+                    new LogReadResult(Errors.UNKNOWN_SERVER_ERROR)));
+                continue;
+            }
+
+            FetchDataInfo localFetchDataInfo = logReadResult.info();
+            Errors error = logReadResult.error();
+            Optional<RemoteStorageFetchInfo> remoteStorageFetchInfo = 
localFetchDataInfo.delayedRemoteStorageFetch;
+
+            // Return the local read directly when it carries data, when it 
failed, or when the data is tiered
+            // but the caller does not want remote reads (those offsets are 
simply skipped).
+            if (error != Errors.NONE || remoteStorageFetchInfo.isEmpty() || 
!readRemote) {
+                futures.put(topicIdPartition, 
CompletableFuture.completedFuture(logReadResult));
+                continue;
+            }
+
+            // Tiered data - follow it to the remote tier asynchronously, 
wrapping the remote read into a
+            // LogReadResult that carries the metadata from the local read.
+            futures.put(topicIdPartition, 
readRemote(remoteStorageFetchInfo.get()).handle((remoteFetchDataInfo, 
exception) -> {
+                if (exception != null) {
+                    Throwable cause = exception instanceof CompletionException 
&& exception.getCause() != null
+                        ? exception.getCause() : exception;
+                    log.warn("Unable to read partition {} from remote 
storage.", topicIdPartition, cause);
+                    return withInfoAndError(logReadResult, localFetchDataInfo, 
Errors.forException(cause));
+                }
+                if (remoteFetchDataInfo == null) {
+                    return withInfoAndError(logReadResult, localFetchDataInfo, 
Errors.UNKNOWN_SERVER_ERROR);
+                }
+                return withInfoAndError(logReadResult, remoteFetchDataInfo, 
Errors.NONE);
+            }));
+        }
+
+        return CompletableFuture.allOf(futures.values().toArray(new 
CompletableFuture<?>[0]))
+            .thenApply(ignored -> {
+                LinkedHashMap<TopicIdPartition, LogReadResult> results = new 
LinkedHashMap<>();
+                futures.forEach((topicIdPartition, future) -> 
results.put(topicIdPartition, future.getNow(null)));
+                return results;
+            });
+    }
+
+    /**
+     * Returns a copy of {@code base} with its read data ({@code info}) and 
{@code error} replaced, preserving
+     * all other read metadata (high watermark, log offsets, etc.). Used to 
wrap a remote read into a
+     * LogReadResult that carries the metadata from the originating local read.
+     */
+    private static LogReadResult withInfoAndError(LogReadResult base, 
FetchDataInfo info, Errors error) {
+        return new LogReadResult(
+            info,
+            base.divergingEpoch(),
+            base.highWatermark(),
+            base.leaderLogStartOffset(),
+            base.leaderLogEndOffset(),
+            base.followerLogStartOffset(),
+            base.fetchTimeMs(),
+            base.lastStableOffset(),
+            base.preferredReadReplica(),
+            error);
+    }
+
+    /**
+     * Reads asynchronously from the remote tier for an offset tiered off the 
local log. The
+     * RemoteStorageFetchInfo is the descriptor surfaced by a prior local read 
as
+     * FetchDataInfo#delayedRemoteStorageFetch. The read runs on the remote 
storage reader pool so the
+     * caller's thread is not blocked; the future completes exceptionally when 
remote storage is not
+     * configured or the read could not be completed. Used internally by 
readAsync (package-private so
+     * it remains unit-testable).
+     */
+    // Visibility for testing
+    CompletableFuture<FetchDataInfo> readRemote(RemoteStorageFetchInfo 
remoteStorageFetchInfo) {
+        CompletableFuture<FetchDataInfo> future = new CompletableFuture<>();
+
+        Optional<RemoteLogManager> remoteLogManager = 
OptionConverters.toJava(replicaManager.remoteLogManager());
+        if (remoteLogManager.isEmpty()) {
+            future.completeExceptionally(new IllegalStateException(
+                "Cannot read " + remoteStorageFetchInfo + " from remote 
storage as remote log manager is not configured."));
+            return future;
+        }
+
+        try {
+            // The read runs on the remote storage reader thread pool; the 
callback completes the
+            // future on that pool's thread, so the caller's thread is never 
blocked on remote IO.
+            remoteLogManager.get().asyncRead(remoteStorageFetchInfo, result -> 
{
+                if (result.error().isPresent()) {
+                    future.completeExceptionally(result.error().get());
+                } else if (result.fetchDataInfo().isPresent()) {
+                    future.complete(result.fetchDataInfo().get());
+                } else {
+                    future.completeExceptionally(new IllegalStateException(
+                        "Remote read for " + remoteStorageFetchInfo + " 
returned neither data nor error."));
+                }
+            });
+        } catch (Exception e) {
+            // e.g. RejectedExecutionException if the reader pool is shutting 
down.
+            log.warn("Unable to schedule remote read for {}.", 
remoteStorageFetchInfo, e);
+            future.completeExceptionally(e);
+        }
+
+        return future;
+    }
 }
diff --git 
a/core/src/test/java/kafka/server/share/ReplicaManagerLogReaderTest.java 
b/core/src/test/java/kafka/server/share/ReplicaManagerLogReaderTest.java
new file mode 100644
index 00000000000..f0f75b329f2
--- /dev/null
+++ b/core/src/test/java/kafka/server/share/ReplicaManagerLogReaderTest.java
@@ -0,0 +1,403 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package kafka.server.share;
+
+import kafka.server.ReplicaManager;
+
+import org.apache.kafka.common.TopicIdPartition;
+import org.apache.kafka.common.Uuid;
+import org.apache.kafka.common.protocol.Errors;
+import org.apache.kafka.common.record.internal.MemoryRecords;
+import org.apache.kafka.common.requests.FetchRequest;
+import org.apache.kafka.server.log.remote.storage.RemoteLogManager;
+import org.apache.kafka.server.storage.log.FetchIsolation;
+import org.apache.kafka.server.storage.log.FetchParams;
+import org.apache.kafka.storage.internals.log.FetchDataInfo;
+import org.apache.kafka.storage.internals.log.LogOffsetMetadata;
+import org.apache.kafka.storage.internals.log.LogReadResult;
+import org.apache.kafka.storage.internals.log.RemoteLogReadResult;
+import org.apache.kafka.storage.internals.log.RemoteStorageFetchInfo;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+import scala.Option;
+import scala.Tuple2;
+import scala.collection.immutable.Seq;
+import scala.jdk.javaapi.CollectionConverters;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class ReplicaManagerLogReaderTest {
+
+    private static final TopicIdPartition TOPIC_ID_PARTITION =
+        new TopicIdPartition(Uuid.randomUuid(), 0, "topic");
+    private static final TopicIdPartition TOPIC_ID_PARTITION_2 =
+        new TopicIdPartition(Uuid.randomUuid(), 1, "topic");
+
+    private static RemoteStorageFetchInfo remoteStorageFetchInfo() {
+        return new RemoteStorageFetchInfo(
+            1024,
+            true,
+            TOPIC_ID_PARTITION,
+            new FetchRequest.PartitionData(TOPIC_ID_PARTITION.topicId(), 0L, 
0L, 1024, Optional.empty()),
+            FetchIsolation.HIGH_WATERMARK);
+    }
+
+    private static FetchParams fetchParams() {
+        return new FetchParams(
+            FetchRequest.CONSUMER_REPLICA_ID, -1, 0L, 1, 1024, 
FetchIsolation.HIGH_WATERMARK, Optional.empty());
+    }
+
+    private static void stubReadFromLog(ReplicaManager replicaManager, 
List<Tuple2<TopicIdPartition, LogReadResult>> results) {
+        when(replicaManager.readFromLog(any(), any(), any(), anyBoolean()))
+            .thenReturn(CollectionConverters.asScala(results).toSeq());
+    }
+
+    private static LogReadResult localReadResult(FetchDataInfo info, Errors 
error) {
+        LogReadResult logReadResult = mock(LogReadResult.class);
+        when(logReadResult.info()).thenReturn(info);
+        when(logReadResult.error()).thenReturn(error);
+        return logReadResult;
+    }
+
+    private static FetchDataInfo localData() {
+        return new FetchDataInfo(new LogOffsetMetadata(0L), 
MemoryRecords.EMPTY);
+    }
+
+    private static FetchDataInfo tieredData(RemoteStorageFetchInfo 
remoteStorageFetchInfo) {
+        return new FetchDataInfo(new LogOffsetMetadata(0L), 
MemoryRecords.EMPTY, false,
+            Optional.empty(), Optional.of(remoteStorageFetchInfo));
+    }
+
+    private static LinkedHashMap<TopicIdPartition, Long> 
offsets(TopicIdPartition... partitions) {
+        LinkedHashMap<TopicIdPartition, Long> offsets = new LinkedHashMap<>();
+        for (TopicIdPartition partition : partitions) {
+            offsets.put(partition, 0L);
+        }
+        return offsets;
+    }
+
+    private static LinkedHashMap<TopicIdPartition, Integer> 
maxBytes(TopicIdPartition... partitions) {
+        LinkedHashMap<TopicIdPartition, Integer> maxBytes = new 
LinkedHashMap<>();
+        for (TopicIdPartition partition : partitions) {
+            maxBytes.put(partition, 1024);
+        }
+        return maxBytes;
+    }
+
+    @Test
+    public void testReadReturnsEmptyWhenNoPartitionsToFetch() {
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+
+        ReplicaManagerLogReader logReader = new 
ReplicaManagerLogReader(replicaManager);
+        LinkedHashMap<TopicIdPartition, LogReadResult> result =
+            logReader.read(fetchParams(), Set.of(), new LinkedHashMap<>(), new 
LinkedHashMap<>());
+
+        assertTrue(result.isEmpty());
+        verify(replicaManager, never()).readFromLog(any(), any(), any(), 
anyBoolean());
+    }
+
+    @Test
+    public void testReadReturnsResultsFromReplicaManager() {
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        LogReadResult logReadResult = mock(LogReadResult.class);
+        Seq<Tuple2<TopicIdPartition, LogReadResult>> readFromLogResult =
+            CollectionConverters.asScala(List.of(new 
Tuple2<>(TOPIC_ID_PARTITION, logReadResult))).toSeq();
+        when(replicaManager.readFromLog(any(), any(), any(), 
anyBoolean())).thenReturn(readFromLogResult);
+
+        LinkedHashMap<TopicIdPartition, Long> offsets = new LinkedHashMap<>();
+        offsets.put(TOPIC_ID_PARTITION, 5L);
+        LinkedHashMap<TopicIdPartition, Integer> maxBytes = new 
LinkedHashMap<>();
+        maxBytes.put(TOPIC_ID_PARTITION, 1024);
+
+        ReplicaManagerLogReader logReader = new 
ReplicaManagerLogReader(replicaManager);
+        LinkedHashMap<TopicIdPartition, LogReadResult> result =
+            logReader.read(fetchParams(), Set.of(TOPIC_ID_PARTITION), offsets, 
maxBytes);
+
+        assertEquals(1, result.size());
+        assertSame(logReadResult, result.get(TOPIC_ID_PARTITION));
+    }
+
+    @Test
+    public void 
testReadRemoteCompletesExceptionallyWhenRemoteLogManagerNotConfigured() {
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        when(replicaManager.remoteLogManager()).thenReturn(Option.empty());
+
+        ReplicaManagerLogReader logReader = new 
ReplicaManagerLogReader(replicaManager);
+        CompletableFuture<FetchDataInfo> future = 
logReader.readRemote(remoteStorageFetchInfo());
+
+        assertTrue(future.isCompletedExceptionally());
+        ExecutionException exception = assertThrows(ExecutionException.class, 
() -> future.get(10, TimeUnit.SECONDS));
+        assertInstanceOf(IllegalStateException.class, exception.getCause());
+    }
+
+    @Test
+    public void testReadRemoteCompletesWithFetchedData() throws Exception {
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        RemoteLogManager remoteLogManager = mock(RemoteLogManager.class);
+        
when(replicaManager.remoteLogManager()).thenReturn(Option.apply(remoteLogManager));
+
+        FetchDataInfo fetchDataInfo = new FetchDataInfo(new 
LogOffsetMetadata(0L), MemoryRecords.EMPTY);
+        RemoteStorageFetchInfo fetchInfo = remoteStorageFetchInfo();
+        doAnswer(invocation -> {
+            Consumer<RemoteLogReadResult> callback = invocation.getArgument(1);
+            callback.accept(new 
RemoteLogReadResult(Optional.of(fetchDataInfo), Optional.empty()));
+            return null;
+        }).when(remoteLogManager).asyncRead(eq(fetchInfo), any());
+
+        ReplicaManagerLogReader logReader = new 
ReplicaManagerLogReader(replicaManager);
+        CompletableFuture<FetchDataInfo> future = 
logReader.readRemote(fetchInfo);
+
+        assertSame(fetchDataInfo, future.get(10, TimeUnit.SECONDS));
+    }
+
+    @Test
+    public void testReadRemoteCompletesExceptionallyWhenReadResultHasError() {
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        RemoteLogManager remoteLogManager = mock(RemoteLogManager.class);
+        
when(replicaManager.remoteLogManager()).thenReturn(Option.apply(remoteLogManager));
+
+        RuntimeException readError = new RuntimeException("remote read 
failed");
+        doAnswer(invocation -> {
+            Consumer<RemoteLogReadResult> callback = invocation.getArgument(1);
+            callback.accept(new RemoteLogReadResult(Optional.empty(), 
Optional.of(readError)));
+            return null;
+        }).when(remoteLogManager).asyncRead(any(), any());
+
+        ReplicaManagerLogReader logReader = new 
ReplicaManagerLogReader(replicaManager);
+        CompletableFuture<FetchDataInfo> future = 
logReader.readRemote(remoteStorageFetchInfo());
+
+        assertTrue(future.isCompletedExceptionally());
+        ExecutionException exception = assertThrows(ExecutionException.class, 
() -> future.get(10, TimeUnit.SECONDS));
+        assertSame(readError, exception.getCause());
+    }
+
+    @Test
+    public void testReadRemoteCompletesExceptionallyWhenReadResultIsEmpty() {
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        RemoteLogManager remoteLogManager = mock(RemoteLogManager.class);
+        
when(replicaManager.remoteLogManager()).thenReturn(Option.apply(remoteLogManager));
+
+        doAnswer(invocation -> {
+            Consumer<RemoteLogReadResult> callback = invocation.getArgument(1);
+            callback.accept(new RemoteLogReadResult(Optional.empty(), 
Optional.empty()));
+            return null;
+        }).when(remoteLogManager).asyncRead(any(), any());
+
+        ReplicaManagerLogReader logReader = new 
ReplicaManagerLogReader(replicaManager);
+        CompletableFuture<FetchDataInfo> future = 
logReader.readRemote(remoteStorageFetchInfo());
+
+        assertTrue(future.isCompletedExceptionally());
+        ExecutionException exception = assertThrows(ExecutionException.class, 
() -> future.get(10, TimeUnit.SECONDS));
+        assertInstanceOf(IllegalStateException.class, exception.getCause());
+    }
+
+    @Test
+    public void testReadRemoteCompletesExceptionallyWhenSchedulingRejected() {
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        RemoteLogManager remoteLogManager = mock(RemoteLogManager.class);
+        
when(replicaManager.remoteLogManager()).thenReturn(Option.apply(remoteLogManager));
+
+        RejectedExecutionException rejected = new 
RejectedExecutionException("reader pool shutting down");
+        doThrow(rejected).when(remoteLogManager).asyncRead(any(), any());
+
+        ReplicaManagerLogReader logReader = new 
ReplicaManagerLogReader(replicaManager);
+        CompletableFuture<FetchDataInfo> future = 
logReader.readRemote(remoteStorageFetchInfo());
+
+        assertTrue(future.isCompletedExceptionally());
+        ExecutionException exception = assertThrows(ExecutionException.class, 
() -> future.get(10, TimeUnit.SECONDS));
+        assertSame(rejected, exception.getCause());
+    }
+
+    @Test
+    public void testReadAsyncReturnsEmptyWhenNoPartitionsToFetch() {
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+
+        ReplicaManagerLogReader logReader = new 
ReplicaManagerLogReader(replicaManager);
+        LinkedHashMap<TopicIdPartition, LogReadResult> result =
+            logReader.readAsync(fetchParams(), Set.of(), new 
LinkedHashMap<>(), new LinkedHashMap<>(), true).join();
+
+        assertTrue(result.isEmpty());
+        verify(replicaManager, never()).readFromLog(any(), any(), any(), 
anyBoolean());
+    }
+
+    @Test
+    public void testReadAsyncReturnsLocalDataWhenNotTiered() throws Exception {
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        FetchDataInfo localData = localData();
+        stubReadFromLog(replicaManager,
+            List.of(new Tuple2<>(TOPIC_ID_PARTITION, 
localReadResult(localData, Errors.NONE))));
+
+        ReplicaManagerLogReader logReader = new 
ReplicaManagerLogReader(replicaManager);
+        LinkedHashMap<TopicIdPartition, LogReadResult> result =
+            logReader.readAsync(fetchParams(), Set.of(TOPIC_ID_PARTITION),
+                offsets(TOPIC_ID_PARTITION), maxBytes(TOPIC_ID_PARTITION), 
true).get(10, TimeUnit.SECONDS);
+
+        assertEquals(Set.of(TOPIC_ID_PARTITION), result.keySet());
+        LogReadResult logReadResult = result.get(TOPIC_ID_PARTITION);
+        assertSame(localData, logReadResult.info());
+        assertEquals(Errors.NONE, logReadResult.error());
+        // Local data, so no remote read should have been attempted.
+        verify(replicaManager, never()).remoteLogManager();
+    }
+
+    @Test
+    public void testReadAsyncFollowsRemoteWhenTieredAndReadRemoteTrue() throws 
Exception {
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        RemoteLogManager remoteLogManager = mock(RemoteLogManager.class);
+        
when(replicaManager.remoteLogManager()).thenReturn(Option.apply(remoteLogManager));
+
+        RemoteStorageFetchInfo remoteStorageFetchInfo = 
remoteStorageFetchInfo();
+        stubReadFromLog(replicaManager,
+            List.of(new Tuple2<>(TOPIC_ID_PARTITION, 
localReadResult(tieredData(remoteStorageFetchInfo), Errors.NONE))));
+
+        FetchDataInfo remoteData = new FetchDataInfo(new 
LogOffsetMetadata(0L), MemoryRecords.EMPTY);
+        doAnswer(invocation -> {
+            Consumer<RemoteLogReadResult> callback = invocation.getArgument(1);
+            callback.accept(new RemoteLogReadResult(Optional.of(remoteData), 
Optional.empty()));
+            return null;
+        }).when(remoteLogManager).asyncRead(eq(remoteStorageFetchInfo), any());
+
+        ReplicaManagerLogReader logReader = new 
ReplicaManagerLogReader(replicaManager);
+        LinkedHashMap<TopicIdPartition, LogReadResult> result =
+            logReader.readAsync(fetchParams(), Set.of(TOPIC_ID_PARTITION),
+                offsets(TOPIC_ID_PARTITION), maxBytes(TOPIC_ID_PARTITION), 
true).get(10, TimeUnit.SECONDS);
+
+        LogReadResult logReadResult = result.get(TOPIC_ID_PARTITION);
+        assertSame(remoteData, logReadResult.info());
+        assertEquals(Errors.NONE, logReadResult.error());
+        verify(remoteLogManager).asyncRead(eq(remoteStorageFetchInfo), any());
+    }
+
+    @Test
+    public void testReadAsyncSkipsRemoteWhenReadRemoteFalse() throws Exception 
{
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        FetchDataInfo tieredData = tieredData(remoteStorageFetchInfo());
+        stubReadFromLog(replicaManager,
+            List.of(new Tuple2<>(TOPIC_ID_PARTITION, 
localReadResult(tieredData, Errors.NONE))));
+
+        ReplicaManagerLogReader logReader = new 
ReplicaManagerLogReader(replicaManager);
+        LinkedHashMap<TopicIdPartition, LogReadResult> result =
+            logReader.readAsync(fetchParams(), Set.of(TOPIC_ID_PARTITION),
+                offsets(TOPIC_ID_PARTITION), maxBytes(TOPIC_ID_PARTITION), 
false).get(10, TimeUnit.SECONDS);
+
+        LogReadResult logReadResult = result.get(TOPIC_ID_PARTITION);
+        // Tiered data is skipped (local read returned as-is), and the remote 
tier is never consulted.
+        assertSame(tieredData, logReadResult.info());
+        assertEquals(Errors.NONE, logReadResult.error());
+        verify(replicaManager, never()).remoteLogManager();
+    }
+
+    @Test
+    public void testReadAsyncReturnsErrorFromLocalRead() throws Exception {
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        FetchDataInfo localData = localData();
+        stubReadFromLog(replicaManager,
+            List.of(new Tuple2<>(TOPIC_ID_PARTITION, 
localReadResult(localData, Errors.UNKNOWN_SERVER_ERROR))));
+
+        ReplicaManagerLogReader logReader = new 
ReplicaManagerLogReader(replicaManager);
+        LinkedHashMap<TopicIdPartition, LogReadResult> result =
+            logReader.readAsync(fetchParams(), Set.of(TOPIC_ID_PARTITION),
+                offsets(TOPIC_ID_PARTITION), maxBytes(TOPIC_ID_PARTITION), 
true).get(10, TimeUnit.SECONDS);
+
+        LogReadResult logReadResult = result.get(TOPIC_ID_PARTITION);
+        assertSame(localData, logReadResult.info());
+        assertEquals(Errors.UNKNOWN_SERVER_ERROR, logReadResult.error());
+        verify(replicaManager, never()).remoteLogManager();
+    }
+
+    @Test
+    public void testReadAsyncReturnsErrorWhenRemoteReadFails() throws 
Exception {
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        RemoteLogManager remoteLogManager = mock(RemoteLogManager.class);
+        
when(replicaManager.remoteLogManager()).thenReturn(Option.apply(remoteLogManager));
+
+        FetchDataInfo tieredData = tieredData(remoteStorageFetchInfo());
+        stubReadFromLog(replicaManager,
+            List.of(new Tuple2<>(TOPIC_ID_PARTITION, 
localReadResult(tieredData, Errors.NONE))));
+
+        doAnswer(invocation -> {
+            Consumer<RemoteLogReadResult> callback = invocation.getArgument(1);
+            callback.accept(new RemoteLogReadResult(Optional.empty(), 
Optional.of(new RuntimeException("remote read failed"))));
+            return null;
+        }).when(remoteLogManager).asyncRead(any(), any());
+
+        ReplicaManagerLogReader logReader = new 
ReplicaManagerLogReader(replicaManager);
+        LinkedHashMap<TopicIdPartition, LogReadResult> result =
+            logReader.readAsync(fetchParams(), Set.of(TOPIC_ID_PARTITION),
+                offsets(TOPIC_ID_PARTITION), maxBytes(TOPIC_ID_PARTITION), 
true).get(10, TimeUnit.SECONDS);
+
+        LogReadResult logReadResult = result.get(TOPIC_ID_PARTITION);
+        // Partial-data tolerant: the read completes (does not throw) with the 
local info and the error.
+        assertSame(tieredData, logReadResult.info());
+        assertEquals(Errors.UNKNOWN_SERVER_ERROR, logReadResult.error());
+    }
+
+    @Test
+    public void testReadAsyncResolvesPartitionsIndependently() throws 
Exception {
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        RemoteLogManager remoteLogManager = mock(RemoteLogManager.class);
+        
when(replicaManager.remoteLogManager()).thenReturn(Option.apply(remoteLogManager));
+
+        // Partition 1 is tiered (resolved from remote), partition 2 is 
available locally.
+        RemoteStorageFetchInfo remoteStorageFetchInfo = 
remoteStorageFetchInfo();
+        FetchDataInfo localData = localData();
+        stubReadFromLog(replicaManager, List.of(
+            new Tuple2<>(TOPIC_ID_PARTITION, 
localReadResult(tieredData(remoteStorageFetchInfo), Errors.NONE)),
+            new Tuple2<>(TOPIC_ID_PARTITION_2, localReadResult(localData, 
Errors.NONE))));
+
+        FetchDataInfo remoteData = new FetchDataInfo(new 
LogOffsetMetadata(0L), MemoryRecords.EMPTY);
+        doAnswer(invocation -> {
+            Consumer<RemoteLogReadResult> callback = invocation.getArgument(1);
+            callback.accept(new RemoteLogReadResult(Optional.of(remoteData), 
Optional.empty()));
+            return null;
+        }).when(remoteLogManager).asyncRead(eq(remoteStorageFetchInfo), any());
+
+        ReplicaManagerLogReader logReader = new 
ReplicaManagerLogReader(replicaManager);
+        LinkedHashMap<TopicIdPartition, LogReadResult> result =
+            logReader.readAsync(fetchParams(), Set.of(TOPIC_ID_PARTITION, 
TOPIC_ID_PARTITION_2),
+                offsets(TOPIC_ID_PARTITION, TOPIC_ID_PARTITION_2),
+                maxBytes(TOPIC_ID_PARTITION, TOPIC_ID_PARTITION_2), 
true).get(10, TimeUnit.SECONDS);
+
+        assertEquals(Set.of(TOPIC_ID_PARTITION, TOPIC_ID_PARTITION_2), 
result.keySet());
+        assertSame(remoteData, result.get(TOPIC_ID_PARTITION).info());
+        assertSame(localData, result.get(TOPIC_ID_PARTITION_2).info());
+    }
+}
diff --git a/server/src/main/java/org/apache/kafka/server/share/LogReader.java 
b/server/src/main/java/org/apache/kafka/server/share/LogReader.java
index 60e453e9b86..3ae9500f9f4 100644
--- a/server/src/main/java/org/apache/kafka/server/share/LogReader.java
+++ b/server/src/main/java/org/apache/kafka/server/share/LogReader.java
@@ -22,6 +22,7 @@ import org.apache.kafka.storage.internals.log.LogReadResult;
 
 import java.util.LinkedHashMap;
 import java.util.Set;
+import java.util.concurrent.CompletableFuture;
 
 /**
  * Abstraction for reading records from log.
@@ -42,4 +43,35 @@ public interface LogReader {
         Set<TopicIdPartition> partitionsToFetch,
         LinkedHashMap<TopicIdPartition, Long> topicPartitionFetchOffsets,
         LinkedHashMap<TopicIdPartition, Integer> partitionMaxBytes);
+
+    /**
+     * Read records for the given partitions starting at the specified 
offsets, combining the local read
+     * and - when {@code readRemote} is true and the requested data has been 
tiered off the local log - the
+     * follow-up remote read into a single call.
+     *
+     * <p>This is the asynchronous, remote-aware counterpart to {@link #read}: 
it returns a single future
+     * holding one {@link LogReadResult} per requested partition. The future 
completes once every partition
+     * has resolved - partitions available locally (or whose local read 
failed) resolve immediately, while
+     * partitions whose data is in remote storage resolve later, once the 
remote read finishes on the remote
+     * storage reader pool, so the caller's thread is never blocked on remote 
IO. When {@code readRemote} is
+     * false, tiered offsets are simply omitted from the result rather than 
fetched.
+     *
+     * <p>Each per-partition {@link LogReadResult} is partial-data tolerant: 
the read never fails as a whole,
+     * allowing callers to use whatever records were retrieved (via {@link 
LogReadResult#info()}) and skip
+     * the rest based on {@link LogReadResult#error()}.
+     *
+     * @param fetchParams                The fetch parameters (isolation 
level, maxBytes, etc.)
+     * @param partitionsToFetch          The set of partitions to fetch
+     * @param topicPartitionFetchOffsets The fetch offset per partition
+     * @param partitionMaxBytes          The max bytes per partition
+     * @param readRemote                 Whether to follow tiered offsets to 
the remote tier; when false,
+     *                                   tiered offsets are skipped.
+     * @return A future of a map from partition to that partition's {@link 
LogReadResult}.
+     */
+    CompletableFuture<LinkedHashMap<TopicIdPartition, LogReadResult>> 
readAsync(
+        FetchParams fetchParams,
+        Set<TopicIdPartition> partitionsToFetch,
+        LinkedHashMap<TopicIdPartition, Long> topicPartitionFetchOffsets,
+        LinkedHashMap<TopicIdPartition, Integer> partitionMaxBytes,
+        boolean readRemote);
 }
diff --git 
a/server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordFetcher.java
 
b/server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordFetcher.java
new file mode 100644
index 00000000000..df6f381dec9
--- /dev/null
+++ 
b/server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordFetcher.java
@@ -0,0 +1,226 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.kafka.server.share.dlq;
+
+import org.apache.kafka.common.TopicIdPartition;
+import org.apache.kafka.common.protocol.Errors;
+import org.apache.kafka.common.record.internal.Record;
+import org.apache.kafka.common.record.internal.RecordBatch;
+import org.apache.kafka.common.record.internal.Records;
+import org.apache.kafka.common.requests.FetchRequest;
+import org.apache.kafka.common.utils.Time;
+import org.apache.kafka.server.share.LogReader;
+import org.apache.kafka.server.storage.log.FetchIsolation;
+import org.apache.kafka.server.storage.log.FetchParams;
+import org.apache.kafka.storage.internals.log.LogReadResult;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Reads the original source records for the offset range described by a 
{@link ShareGroupDLQRecordParameter}
+ * so they can be copied into a DLQ record. Reads are issued one batch at a 
time in a loop via
+ * {@link LogReader#readAsync}, which combines the local read with the 
follow-up remote read for any offset
+ * tiered off the local log. When a read is already complete the loop 
continues in place; when it is still
+ * pending (a remote read in flight) the loop returns and is resumed from the 
callback - so the calling
+ * thread is never blocked on remote storage IO and the synchronous path never 
recurses.
+ *
+ * <p>Best-effort: the returned future always completes normally with whatever 
records could be read.
+ * Offsets that cannot be read - locally or remotely - are simply absent from 
the map, leaving the caller
+ * to produce a DLQ record with headers only for them.
+ *
+ * <p>Instances are single-use: create one fetcher per {@link #fetch()} call.
+ */
+public class ShareGroupDLQRecordFetcher {
+    private static final Logger log = 
LoggerFactory.getLogger(ShareGroupDLQRecordFetcher.class);
+
+    private final LogReader logReader;
+    private final Time time;
+    private final ShareGroupDLQRecordParameter param;
+
+    private final TopicIdPartition tp;
+    private final long endOffset;
+    private final int recordCount;
+    private final long startTime;
+    private final Map<Long, Record> recordMap;
+    // 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 across iterations.
+    private final LinkedHashMap<TopicIdPartition, Long> offsets = new 
LinkedHashMap<>();
+    private final LinkedHashMap<TopicIdPartition, Integer> maxBytesMap = new 
LinkedHashMap<>();
+    private final CompletableFuture<Map<Long, Record>> result = new 
CompletableFuture<>();
+    private final FetchParams fetchParams;
+
+    public ShareGroupDLQRecordFetcher(LogReader logReader, Time time, 
ShareGroupDLQRecordParameter param, int maxFetchBytes) {
+        this.logReader = logReader;
+        this.time = time;
+        this.param = param;
+        this.tp = param.topicIdPartition();
+        this.endOffset = param.lastOffset();
+        this.recordCount = (int) (param.lastOffset() - param.firstOffset() + 
1);
+        this.startTime = time.hiResClockMs();
+        this.recordMap = new HashMap<>(recordCount);
+        this.maxBytesMap.put(tp, maxFetchBytes);
+        this.fetchParams = new FetchParams(
+            FetchRequest.CONSUMER_REPLICA_ID,           // -1, reading as a 
consumer
+            -1,                                         // replicaEpoch
+            0L,                                         // maxWaitMs - don't 
block
+            1,                                          // minBytes
+            maxFetchBytes,                              // maxBytes
+            FetchIsolation.HIGH_WATERMARK,              // committed only
+            Optional.empty()                            // clientMetadata
+        );
+    }
+
+    /**
+     * Fetches the source records for the configured offset range.
+     *
+     * @return A future that always completes normally with the records that 
could be read, keyed by offset.
+     */
+    public CompletableFuture<Map<Long, Record>> fetch() {
+        try {
+            runFrom(param.firstOffset());
+        } catch (Exception e) {
+            // Never let an unexpected error escape; skip record copy entirely.
+            log.warn("Unexpected error fetching records for {}. Skipping 
record copy.", param, e);
+            result.complete(Map.of());
+        }
+        return result;
+    }
+
+    /**
+     * Drives the reads in a loop via {@link LogReader#readAsync}. When the 
per-offset read is already
+     * complete (local data, or remote data already resolved) the loop 
continues in place; when it is still
+     * pending (remote read in flight) the loop returns and is resumed from 
the callback - so the synchronous
+     * path never recurses and the async path resumes on a fresh stack (the 
remote storage reader thread).
+     */
+    private void runFrom(long startFrom) {
+        long nextOffset = startFrom;
+        while (nextOffset <= endOffset) {
+            offsets.put(tp, nextOffset);
+
+            CompletableFuture<LinkedHashMap<TopicIdPartition, LogReadResult>> 
future =
+                logReader.readAsync(fetchParams, Set.of(tp), offsets, 
maxBytesMap, true);
+
+            if (!future.isDone()) {
+                // A remote read is in flight: resume from the callback so the 
calling thread is unblocked.
+                long readFrom = nextOffset;
+                future.whenComplete((results, exception) -> resume(readFrom, 
logReadResult(results), exception));
+                return;
+            }
+
+            // Safe (non-blocking) because the future is done. readAsync is 
partial-data tolerant, so it
+            // completes normally; any unexpected exceptional completion is 
caught by fetch().
+            long advanced = collect(nextOffset, 
logReadResult(future.getNow(null)));
+            if (advanced <= nextOffset) {
+                complete();     // no progress, stop
+                return;
+            }
+            nextOffset = advanced;
+        }
+        complete();
+    }
+
+    /**
+     * Extracts the read result for the partition being fetched, or {@code 
null} when none was produced
+     * (e.g. the read returned no entry for the partition).
+     */
+    private LogReadResult logReadResult(LinkedHashMap<TopicIdPartition, 
LogReadResult> results) {
+        return results == null ? null : results.get(tp);
+    }
+
+    /**
+     * Resumes the read loop after an asynchronous (remote) read completes. 
Runs after runFrom() has already
+     * returned, so invoking runFrom() here does not grow the original call 
stack.
+     */
+    private void resume(long readFrom, LogReadResult logReadResult, Throwable 
exception) {
+        try {
+            if (exception != null) {
+                log.warn("Unable to read records at offset {} for {}. Skipping 
it.", readFrom, param, exception);
+                complete();
+                return;
+            }
+            long advanced = collect(readFrom, logReadResult);
+            if (advanced <= readFrom) {
+                complete();         // no progress, stop
+            } else {
+                runFrom(advanced);  // resume the loop
+            }
+        } catch (Exception e) {
+            log.warn("Unexpected error processing records for {}. Skipping 
record copy.", param, e);
+            result.complete(Map.of());
+        }
+    }
+
+    /**
+     * Collects the records from a completed read into the map and returns the 
offset to read from next.
+     * A read that failed (carries an error) or returned no usable data leaves 
the offsets unread (skipped),
+     * which the loop treats as no progress.
+     */
+    private long collect(long readFrom, LogReadResult logReadResult) {
+        if (logReadResult == null) {
+            return readFrom;
+        }
+        if (logReadResult.error() != Errors.NONE) {
+            log.warn("Unable to read records at offset {} for {} due to error 
{}. Skipping it.",
+                readFrom, param, logReadResult.error());
+            return readFrom;
+        }
+        return collectRecords(logReadResult.info().records, readFrom);
+    }
+
+    /**
+     * Adds the records within the requested range to the map and returns the 
offset to read from next
+     * (never moves backwards). Records below readFrom or above endOffset are 
ignored.
+     */
+    private long collectRecords(Records records, long readFrom) {
+        long nextOffset = readFrom;
+        for (RecordBatch batch : 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() > endOffset) return nextOffset;
+                recordMap.put(record.offset(), record);
+                nextOffset = Math.max(nextOffset, record.offset() + 1); // 
never moves backwards
+            }
+        }
+        return nextOffset;
+    }
+
+    /**
+     * Completes the result future with an immutable snapshot of the records 
collected so far. Offsets
+     * that could not be read are absent from the map; the caller produces a 
headers-only DLQ record for them.
+     */
+    private void complete() {
+        log.trace("Log fetch took {} ms for {} records starting at {} for {}", 
time.hiResClockMs() - startTime,
+            recordCount, param.firstOffset(), param);
+        if (recordCount != recordMap.size()) {
+            log.info("Total offsets requested: {}, Records found: {}", 
recordCount, recordMap.size());
+        }
+        result.complete(Map.copyOf(recordMap));
+    }
+}
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 799346ebf8c..1a27dd7f0bc 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
@@ -37,12 +37,10 @@ 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;
@@ -50,13 +48,10 @@ 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;
@@ -190,7 +185,27 @@ public class ShareGroupDLQStateManager {
         }
         CompletableFuture<Void> future = new CompletableFuture<>();
         ProduceRequestHandler requestHandler = new 
ProduceRequestHandler(param, future, requestBackoffMs, requestBackoffMaxMs, 
maxRequestAttempts);
-        enqueue(requestHandler);
+
+        // Validate the DLQ configuration up front, synchronously on the 
calling thread, so a
+        // misconfigured DLQ fails fast, and we never read source records for 
one. enqueue() also
+        // re-validates so that retries re-check the (dynamic) config.
+        Optional<Throwable> validationError = 
requestHandler.validateDlqTopic();
+        if (validationError.isPresent()) {
+            future.completeExceptionally(validationError.get());
+            return future;
+        }
+
+        // Resolve the source records once, here - on the calling thread for 
local offsets and, for
+        // tiered offsets, asynchronously on the remote-storage reader pool - 
and enqueue only once
+        // resolution finishes. This keeps both the local and remote reads off 
the single sender
+        // thread, and the memoized result is reused on every (re)send so 
retries never re-fetch.
+        // Records are only read when copy is enabled for the group and the 
DLQ is correctly
+        // configured (validated above); otherwise we enqueue immediately.
+        if (cacheHelper.isShareGroupDlqCopyRecordEnabled(param.groupId())) {
+            requestHandler.resolveRecords().whenComplete((ignored, 
ignoredError) -> enqueue(requestHandler));
+        } else {
+            enqueue(requestHandler);
+        }
         return future;
     }
 
@@ -221,9 +236,6 @@ public class ShareGroupDLQStateManager {
      * @param handler The handler instance to add to the node map.
      */
     private void addRequestToNodeMap(Node node, ProduceRequestHandler handler) 
{
-        if (!handler.isBatchable()) {
-            return;
-        }
         synchronized (nodeMapLock) {
             nodeRPCMap.computeIfAbsent(node, k -> new LinkedList<>())
                 .add(handler);
@@ -238,9 +250,18 @@ public class ShareGroupDLQStateManager {
         private static final Logger LOG = 
LoggerFactory.getLogger(ShareGroupDLQStateManager.ProduceRequestHandler.class);
         private final ExponentialBackoffManager createTopicsBackoff;
         private final ExponentialBackoffManager produceRequestBackoff;
-        private Node dlqPartitionLeaderNode;
-        private int dlqDestinationPartition;
-        private ShareGroupDLQMetadataCacheHelper.TopicPartitionData 
dlqTopicPartitionData;
+        // These DLQ topic fields are written by populateDLQTopicData() and 
read while building the
+        // produce request - both on the sender thread (from 
dlqTopicExists()/handleCreateTopicsResponse()).
+        // Kept volatile defensively.
+        private volatile Node dlqPartitionLeaderNode;
+        private volatile int dlqDestinationPartition;
+        private volatile ShareGroupDLQMetadataCacheHelper.TopicPartitionData 
dlqTopicPartitionData;
+        // The original source records, resolved once before this handler is 
enqueued (see resolveRecords()).
+        // Volatile because resolution runs off the sender thread - on the 
calling thread for local offsets
+        // and, for tiered offsets, on the remote-storage reader pool - while 
this value is read on the
+        // sender thread when the produce request is built. Memoized: set once 
and reused for every (re)send,
+        // so retries never re-fetch.
+        private volatile Map<Long, Record> resolvedRecordData = Map.of();
 
         public static final String HEADER_DLQ_ERRORS_TOPIC = 
"__dlq.errors.topic";
         public static final String HEADER_DLQ_ERRORS_PARTITION = 
"__dlq.errors.partition";
@@ -296,19 +317,6 @@ public class ShareGroupDLQStateManager {
             return "ProduceRequestHandler";
         }
 
-        /**
-         * This method helps determine if the handler could
-         * participate in batching (added to nodeMap). This will
-         * be helpful if the RPCs which cannot be batched are included in
-         * this class as well.
-         *
-         * @return Boolean indicating whether this handler can be coalesced 
with others
-         * to reduce number of RPCs sent.
-         */
-        boolean isBatchable() {
-            return true;
-        }
-
         public void requestErrorResponse(Throwable exception) {
             this.result.completeExceptionally(exception);
         }
@@ -337,10 +345,6 @@ public class ShareGroupDLQStateManager {
                 .setTopics(topicCollection));
         }
 
-        public AbstractRequest.Builder<? extends AbstractRequest> 
requestBuilder() {
-            throw new RuntimeException("Produce requests are batchable, hence 
individual requests not needed.");
-        }
-
         public void populateDLQTopicData() throws ConfigException {
             Optional<String> dlqTopic = 
cacheHelper.shareGroupDlqTopic(param.groupId());
             if (dlqTopic.isEmpty()) {
@@ -372,7 +376,9 @@ public class ShareGroupDLQStateManager {
         }
 
         public ProduceRequestData.TopicProduceData topicProduceData() {
-            Map<Long, Record> originalRecordData = maybeFetchRecordData();
+            // Records have already been resolved (including any remote 
storage reads) before this
+            // handler was added to the node map, so no blocking fetch happens 
on the sender thread here.
+            Map<Long, Record> originalRecordData = resolvedRecordData;
 
             List<SimpleRecord> simpleRecords = new ArrayList<>();
             for (long i = param.firstOffset(); i <= param.lastOffset(); i++) {
@@ -451,6 +457,7 @@ public class ShareGroupDLQStateManager {
                 } catch (ConfigException e) {
                     return false;
                 }
+                // Source records were already resolved before enqueue; just 
add to the node map.
                 addRequestToNodeMap(dlqPartitionLeaderNode, this);
             }
             return isDlqTopicPresent;
@@ -545,11 +552,8 @@ public class ShareGroupDLQStateManager {
                             try {
                                 populateDLQTopicData();
                                 createTopicsBackoff.resetAttempts();
-                                if (this.isBatchable()) {
-                                    
addRequestToNodeMap(this.dlqPartitionLeaderNode, this);
-                                } else {
-                                    enqueue(this);
-                                }
+                                // Source records were already resolved before 
enqueue; just add to the node map.
+                                
addRequestToNodeMap(this.dlqPartitionLeaderNode, this);
                             } catch (ConfigException e) {
                                 LOG.error("Error enqueueing after DLQ create 
topic response {}.", this, e);
                                 if (!createTopicsBackoff.canAttempt()) {
@@ -690,78 +694,37 @@ public class ShareGroupDLQStateManager {
             }
         }
 
-        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
-                    }
+        /**
+         * Resolves the original source records for this handler once, before 
it is enqueued - reading
+         * from the local log on the calling thread and, for any offsets 
tiered to remote storage,
+         * asynchronously on the remote-storage reader pool. The result is 
memoized in
+         * {@link #resolvedRecordData} and reused for every (re)send, so the 
single sender thread never
+         * reads the log (neither local nor remote) and retries do not 
re-fetch.
+         *
+         * <p>A failed fetch is non-fatal: {@link #resolvedRecordData} stays 
empty and the DLQ record is
+         * produced with headers only (no key/value), mirroring how 
individually unavailable offsets are skipped.
+         *
+         * @return A future that always completes normally, once resolution 
has finished.
+         */
+        CompletableFuture<Void> resolveRecords() {
+            CompletableFuture<Void> resolved = new CompletableFuture<>();
+            maybeFetchRecordData().whenComplete((records, exception) -> {
+                if (exception != null || records == null) {
+                    LOG.warn("Unable to fetch original record data for handler 
{}. DLQ records will be produced with headers only.", this, exception);
+                    this.resolvedRecordData = Map.of();
+                } else {
+                    this.resolvedRecordData = records;
                 }
+                resolved.complete(null);
+            });
+            return resolved;
+        }
 
-                // 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;
+        private CompletableFuture<Map<Long, Record>> maybeFetchRecordData() {
+            if 
(!cacheHelper.isShareGroupDlqCopyRecordEnabled(param.groupId())) {
+                return CompletableFuture.completedFuture(Map.of());
             }
-            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);
+            return new ShareGroupDLQRecordFetcher(logReader, time, param, 
DLQ_MAX_FETCH_BYTES).fetch();
         }
     }
 
@@ -806,16 +769,9 @@ public class ShareGroupDLQStateManager {
                         log.error("Unable to create topic request for handler 
{}.", handler, exp);
                         
handler.requestErrorResponse(Errors.INVALID_CONFIG.exception());
                     }
-                } else {
-                    if (!handler.isBatchable()) {
-                        requests.add(new RequestAndCompletionHandler(
-                            time.milliseconds(),
-                            handler.dlqPartitionLeaderNode(),
-                            handler.requestBuilder(),
-                            handler
-                        ));
-                    }
                 }
+                // When the DLQ topic already exists, the handler is added to 
the node map for produce
+                // coalescing (asynchronously, once its records are resolved), 
so nothing more to do here.
             }
 
             // {
diff --git 
a/server/src/test/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordFetcherTest.java
 
b/server/src/test/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordFetcherTest.java
new file mode 100644
index 00000000000..6779e9617cc
--- /dev/null
+++ 
b/server/src/test/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordFetcherTest.java
@@ -0,0 +1,291 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.kafka.server.share.dlq;
+
+import org.apache.kafka.common.TopicIdPartition;
+import org.apache.kafka.common.Uuid;
+import org.apache.kafka.common.compress.Compression;
+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.Records;
+import org.apache.kafka.common.record.internal.SimpleRecord;
+import org.apache.kafka.common.utils.Utils;
+import org.apache.kafka.server.share.LogReader;
+import org.apache.kafka.server.util.MockTime;
+import org.apache.kafka.storage.internals.log.FetchDataInfo;
+import org.apache.kafka.storage.internals.log.LogReadResult;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalLong;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anySet;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ShareGroupDLQRecordFetcherTest {
+
+    private static final MockTime MOCK_TIME = new MockTime();
+    private static final String GROUP_ID = "test-group";
+    private static final TopicIdPartition TOPIC_ID_PARTITION =
+        new TopicIdPartition(Uuid.randomUuid(), 0, "source-topic");
+    private static final int MAX_FETCH_BYTES = 1024 * 1024;
+
+    private final LogReader logReader = mock(LogReader.class);
+
+    private static ShareGroupDLQRecordParameter param(long firstOffset, long 
lastOffset) {
+        return new ShareGroupDLQRecordParameter(
+            GROUP_ID, TOPIC_ID_PARTITION, firstOffset, lastOffset, 
Optional.empty(), Optional.empty());
+    }
+
+    private ShareGroupDLQRecordFetcher fetcher(ShareGroupDLQRecordParameter 
param) {
+        return new ShareGroupDLQRecordFetcher(logReader, MOCK_TIME, param, 
MAX_FETCH_BYTES);
+    }
+
+    private Map<Long, Record> fetch(ShareGroupDLQRecordParameter param) throws 
Exception {
+        return fetcher(param).fetch().get(10, TimeUnit.SECONDS);
+    }
+
+    // ---- helpers ----
+
+    // A read result carrying the given data and error. Other read metadata is 
irrelevant to the fetcher.
+    private static LogReadResult logReadResult(FetchDataInfo info, Errors 
error) {
+        return new LogReadResult(info, Optional.empty(), 0L, 0L, 0L, 0L, -1L, 
OptionalLong.empty(), error);
+    }
+
+    // A successful read carrying the given records (offsets assigned from 0 
by MemoryRecords#withRecords).
+    private static LogReadResult success(SimpleRecord... records) {
+        return logReadResult(
+            new FetchDataInfo(null, 
MemoryRecords.withRecords(Compression.NONE, records)), Errors.NONE);
+    }
+
+    // A failed read - partial-data tolerant, so it still carries a (here 
empty) FetchDataInfo.
+    private static LogReadResult failure(Errors error) {
+        return logReadResult(new FetchDataInfo(null, MemoryRecords.EMPTY), 
error);
+    }
+
+    private static LinkedHashMap<TopicIdPartition, LogReadResult> 
resultMap(LogReadResult result) {
+        LinkedHashMap<TopicIdPartition, LogReadResult> map = new 
LinkedHashMap<>();
+        map.put(TOPIC_ID_PARTITION, result);
+        return map;
+    }
+
+    private static CompletableFuture<LinkedHashMap<TopicIdPartition, 
LogReadResult>> done(LogReadResult result) {
+        return CompletableFuture.completedFuture(resultMap(result));
+    }
+
+    @SafeVarargs
+    private void 
whenReadAsync(CompletableFuture<LinkedHashMap<TopicIdPartition, LogReadResult>> 
first,
+                              
CompletableFuture<LinkedHashMap<TopicIdPartition, LogReadResult>>... rest) {
+        var stub = when(logReader.readAsync(any(), anySet(), any(), any(), 
anyBoolean())).thenReturn(first);
+        for (CompletableFuture<LinkedHashMap<TopicIdPartition, LogReadResult>> 
future : rest) {
+            stub = stub.thenReturn(future);
+        }
+    }
+
+    private static SimpleRecord record(String key, String value) {
+        return new SimpleRecord(MOCK_TIME.milliseconds(),
+            key.getBytes(StandardCharsets.UTF_8), 
value.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private static void assertRecord(Map<Long, Record> result, long offset, 
String key, String value) {
+        Record record = result.get(offset);
+        assertTrue(record != null, "Expected a record at offset " + offset);
+        assertArrayEquals(key.getBytes(StandardCharsets.UTF_8), 
toArray(record.key()));
+        assertArrayEquals(value.getBytes(StandardCharsets.UTF_8), 
toArray(record.value()));
+    }
+
+    private static byte[] toArray(ByteBuffer buffer) {
+        return Utils.toArray(buffer);
+    }
+
+    @Test
+    public void testFetchAllRecordsInSingleRead() throws Exception {
+        whenReadAsync(done(success(record("k0", "v0"), record("k1", "v1"), 
record("k2", "v2"))));
+
+        Map<Long, Record> result = fetch(param(0L, 2L));
+
+        assertEquals(3, result.size());
+        assertRecord(result, 0L, "k0", "v0");
+        assertRecord(result, 1L, "k1", "v1");
+        assertRecord(result, 2L, "k2", "v2");
+        // The DLQ copy always asks readAsync to follow tiered offsets to the 
remote tier.
+        verify(logReader).readAsync(any(), anySet(), any(), any(), eq(true));
+    }
+
+    @Test
+    public void testFetchRecordsAcrossMultipleReads() throws Exception {
+        // First read returns only the first two offsets; the next read 
returns the batch containing the
+        // remaining offset (records at or before the already-read position 
are skipped by the fetcher).
+        whenReadAsync(
+            done(success(record("k0", "v0"), record("k1", "v1"))),
+            done(success(record("k0", "v0"), record("k1", "v1"), record("k2", 
"v2"))));
+
+        Map<Long, Record> result = fetch(param(0L, 2L));
+
+        assertEquals(3, result.size());
+        assertRecord(result, 0L, "k0", "v0");
+        assertRecord(result, 1L, "k1", "v1");
+        assertRecord(result, 2L, "k2", "v2");
+        verify(logReader, times(2)).readAsync(any(), anySet(), any(), any(), 
eq(true));
+    }
+
+    @Test
+    public void testReadErrorYieldsNoRecords() throws Exception {
+        whenReadAsync(done(failure(Errors.UNKNOWN_SERVER_ERROR)));
+
+        Map<Long, Record> result = fetch(param(0L, 2L));
+
+        assertTrue(result.isEmpty());
+    }
+
+    @Test
+    public void testReadErrorMidRangeReturnsRecordsReadSoFar() throws 
Exception {
+        // The first read succeeds and the second fails; the records already 
collected are still returned.
+        whenReadAsync(
+            done(success(record("k0", "v0"), record("k1", "v1"))),
+            done(failure(Errors.UNKNOWN_SERVER_ERROR)));
+
+        Map<Long, Record> result = fetch(param(0L, 2L));
+
+        assertEquals(2, result.size());
+        assertRecord(result, 0L, "k0", "v0");
+        assertRecord(result, 1L, "k1", "v1");
+        assertNull(result.get(2L));
+    }
+
+    @Test
+    public void testMissingPartitionResultYieldsNoRecords() throws Exception {
+        // readAsync returns no entry for the partition - nothing can be read.
+        when(logReader.readAsync(any(), anySet(), any(), any(), anyBoolean()))
+            .thenReturn(CompletableFuture.completedFuture(new 
LinkedHashMap<>()));
+
+        Map<Long, Record> result = fetch(param(0L, 2L));
+
+        assertTrue(result.isEmpty());
+    }
+
+    @Test
+    public void testNoProgressYieldsNoRecords() throws Exception {
+        // A read that returns no records does not advance the read position, 
so the loop terminates.
+        whenReadAsync(done(success()));
+
+        Map<Long, Record> result = fetch(param(0L, 2L));
+
+        assertTrue(result.isEmpty());
+    }
+
+    @Test
+    public void testSingleOffsetFetch() throws Exception {
+        whenReadAsync(done(success(record("k0", "v0"))));
+
+        Map<Long, Record> result = fetch(param(0L, 0L));
+
+        assertEquals(1, result.size());
+        assertRecord(result, 0L, "k0", "v0");
+    }
+
+    @Test
+    public void testRecordsBeyondEndOffsetIgnored() throws Exception {
+        // The read returns more records than the requested range [0, 1]; 
offsets beyond endOffset are ignored.
+        whenReadAsync(done(success(record("k0", "v0"), record("k1", "v1"), 
record("k2", "v2"))));
+
+        Map<Long, Record> result = fetch(param(0L, 1L));
+
+        assertEquals(2, result.size());
+        assertRecord(result, 0L, "k0", "v0");
+        assertRecord(result, 1L, "k1", "v1");
+        assertNull(result.get(2L));
+    }
+
+    @Test
+    public void testAsyncReadResumesLoopWhenPending() throws Exception {
+        // A not-yet-complete future (e.g. an in-flight remote read) suspends 
the loop; the fetch returns
+        // before the read completes and resumes from the callback.
+        CompletableFuture<LinkedHashMap<TopicIdPartition, LogReadResult>> 
pending = new CompletableFuture<>();
+        when(logReader.readAsync(any(), anySet(), any(), any(), 
anyBoolean())).thenReturn(pending);
+
+        CompletableFuture<Map<Long, Record>> resultFuture = fetcher(param(0L, 
2L)).fetch();
+        assertFalse(resultFuture.isDone(), "Fetch should be waiting on the 
pending read");
+
+        pending.complete(resultMap(success(record("k0", "v0"), record("k1", 
"v1"), record("k2", "v2"))));
+
+        Map<Long, Record> result = resultFuture.get(10, TimeUnit.SECONDS);
+        assertEquals(3, result.size());
+        assertRecord(result, 0L, "k0", "v0");
+        assertRecord(result, 1L, "k1", "v1");
+        assertRecord(result, 2L, "k2", "v2");
+    }
+
+    @Test
+    public void testAsyncReadResumeWithNoProgressCompletesEmpty() throws 
Exception {
+        CompletableFuture<LinkedHashMap<TopicIdPartition, LogReadResult>> 
pending = new CompletableFuture<>();
+        when(logReader.readAsync(any(), anySet(), any(), any(), 
anyBoolean())).thenReturn(pending);
+
+        CompletableFuture<Map<Long, Record>> resultFuture = fetcher(param(0L, 
2L)).fetch();
+        assertFalse(resultFuture.isDone());
+
+        // Completing with no records makes no progress, so the resumed loop 
stops and completes empty.
+        pending.complete(resultMap(success()));
+
+        assertTrue(resultFuture.get(10, TimeUnit.SECONDS).isEmpty());
+    }
+
+    @Test
+    public void testAsyncReadResumeCompletesEmptyWhenProcessingThrows() throws 
Exception {
+        CompletableFuture<LinkedHashMap<TopicIdPartition, LogReadResult>> 
pending = new CompletableFuture<>();
+        when(logReader.readAsync(any(), anySet(), any(), any(), 
anyBoolean())).thenReturn(pending);
+
+        CompletableFuture<Map<Long, Record>> resultFuture = fetcher(param(0L, 
2L)).fetch();
+
+        // An unexpected error while processing the resumed records must not 
escape the callback.
+        Records throwing = mock(Records.class);
+        when(throwing.batches()).thenThrow(new RuntimeException("boom"));
+        pending.complete(resultMap(logReadResult(new FetchDataInfo(null, 
throwing), Errors.NONE)));
+
+        assertTrue(resultFuture.get(10, TimeUnit.SECONDS).isEmpty());
+    }
+
+    @Test
+    public void testFetchCompletesEmptyWhenReadAsyncThrows() throws Exception {
+        // An unexpected error from the log reader must not escape; the copy 
is skipped entirely.
+        when(logReader.readAsync(any(), anySet(), any(), any(), 
anyBoolean())).thenThrow(new RuntimeException("boom"));
+
+        Map<Long, Record> result = fetch(param(0L, 2L));
+
+        assertTrue(result.isEmpty());
+    }
+}
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 2fd2cf2b76b..a1b96b88660 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
@@ -64,6 +64,7 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.OptionalLong;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
@@ -90,6 +91,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 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.anyBoolean;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anySet;
 import static org.mockito.ArgumentMatchers.anyString;
@@ -1571,6 +1573,33 @@ class ShareGroupDLQStateManagerTest {
 
     // --- DLQ record with copy record enabled ---
 
+    private static FetchDataInfo recordsInfo(SimpleRecord... records) {
+        return new FetchDataInfo(null, 
MemoryRecords.withRecords(Compression.NONE, records));
+    }
+
+    // A read result carrying the given data and error. Other read metadata is 
irrelevant to the fetcher.
+    private static LogReadResult logReadResult(FetchDataInfo info, Errors 
error) {
+        return new LogReadResult(info, Optional.empty(), 0L, 0L, 0L, 0L, -1L, 
OptionalLong.empty(), error);
+    }
+
+    private static CompletableFuture<LinkedHashMap<TopicIdPartition, 
LogReadResult>> asyncReadMap(
+            TopicIdPartition topicIdPartition, LogReadResult result) {
+        LinkedHashMap<TopicIdPartition, LogReadResult> map = new 
LinkedHashMap<>();
+        map.put(topicIdPartition, result);
+        return CompletableFuture.completedFuture(map);
+    }
+
+    // Stubs logReader.readAsync to return, in order, the given per-call 
results for the partition,
+    // each wrapped in an already-complete future.
+    private static void whenReadAsync(LogReader logReader, TopicIdPartition 
topicIdPartition,
+                                      LogReadResult first, LogReadResult... 
rest) {
+        var stub = when(logReader.readAsync(any(), anySet(), any(), any(), 
anyBoolean()))
+            .thenReturn(asyncReadMap(topicIdPartition, first));
+        for (LogReadResult result : rest) {
+            stub = stub.thenReturn(asyncReadMap(topicIdPartition, result));
+        }
+    }
+
     @Test
     public void testDLQRecordCopyEnabled() throws Exception {
         MockClient client = new MockClient(MOCK_TIME);
@@ -1595,21 +1624,10 @@ class ShareGroupDLQStateManagerTest {
         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);
+        whenReadAsync(logReader, param.topicIdPartition(), 
logReadResult(recordsInfo(
+            new SimpleRecord(MOCK_TIME.milliseconds(), keyData1, valueData1),
+            new SimpleRecord(MOCK_TIME.milliseconds(), keyData2, valueData2),
+            new SimpleRecord(MOCK_TIME.milliseconds(), keyData3, valueData3)), 
Errors.NONE));
 
         ShareGroupDLQMetadataCacheHelper cacheHelper = 
cacheHelper(DEFAULT_LEADER);
         
when(cacheHelper.isShareGroupDlqCopyRecordEnabled(any())).thenReturn(true);
@@ -1632,6 +1650,91 @@ class ShareGroupDLQStateManagerTest {
         verify(mockMetrics, never()).recordDLQProduceFailed(any());
     }
 
+    @Test
+    public void testDLQRecordCopyEnabledFetchesOnceAcrossProduceRetries() 
throws Exception {
+        int maxAttempts = 3;
+        // Real timer with tiny backoffs lets the produce retries fire within 
milliseconds.
+        Timer realTimer = new SystemTimerReaper("shareGroupDLQTestTimer",
+            new SystemTimer("shareGroupDLQTestTimer"));
+        try {
+            // param() spans offsets 0..2, so return all three records in a 
single read; one
+            // resolution then maps to exactly one logReader.readAsync() call.
+            ShareGroupDLQRecordParameter param = param();
+            LogReader logReader = mock(LogReader.class);
+            whenReadAsync(logReader, param.topicIdPartition(), 
logReadResult(recordsInfo(
+                new SimpleRecord(MOCK_TIME.milliseconds(), 
"key0".getBytes(StandardCharsets.UTF_8), 
"value0".getBytes(StandardCharsets.UTF_8)),
+                new SimpleRecord(MOCK_TIME.milliseconds(), 
"key1".getBytes(StandardCharsets.UTF_8), 
"value1".getBytes(StandardCharsets.UTF_8)),
+                new SimpleRecord(MOCK_TIME.milliseconds(), 
"key2".getBytes(StandardCharsets.UTF_8), 
"value2".getBytes(StandardCharsets.UTF_8))), Errors.NONE));
+
+            ShareGroupDLQMetadataCacheHelper cacheHelper = 
cacheHelper(DEFAULT_LEADER);
+            
when(cacheHelper.isShareGroupDlqCopyRecordEnabled(any())).thenReturn(true);
+
+            MockClient client = new MockClient(MOCK_TIME);
+            // Two produce disconnects (retriable), then a successful produce.
+            client.prepareResponseFrom(body -> body instanceof ProduceRequest, 
null, DEFAULT_LEADER, true);
+            client.prepareResponseFrom(body -> body instanceof ProduceRequest, 
null, DEFAULT_LEADER, true);
+            client.prepareResponseFrom(body -> body instanceof ProduceRequest, 
successfulProduceResponse(0), DEFAULT_LEADER);
+
+            stateManager = builder()
+                .withClient(client)
+                .withLogReader(logReader)
+                .withCacheHelper(cacheHelper)
+                .withTimer(realTimer)
+                .build();
+            stateManager.start();
+            assertNull(stateManager.dlq(param, 1L, 5L, maxAttempts).get(5, 
TimeUnit.SECONDS));
+
+            // Records are resolved once, before enqueue, and the memoized 
result is reused on every
+            // (re)send. So despite three produce attempts the source log is 
read exactly once.
+            verify(logReader, times(1)).readAsync(any(), anySet(), any(), 
any(), anyBoolean());
+            verify(mockMetrics, times(maxAttempts)).recordDLQProduce(GROUP_ID);
+            verify(mockMetrics).recordDLQRecordWrite(GROUP_ID, 3);
+            verify(mockMetrics, never()).recordDLQProduceFailed(any());
+        } finally {
+            Utils.closeQuietly(realTimer, "shareGroupDLQTestTimer");
+        }
+    }
+
+    @Test
+    public void testDLQRecordCopyEnabledButInvalidConfigSkipsFetch() throws 
Exception {
+        LogReader logReader = mock(LogReader.class);
+        ShareGroupDLQMetadataCacheHelper cacheHelper = 
mock(ShareGroupDLQMetadataCacheHelper.class);
+        // Misconfigured DLQ (no topic configured) - validation must fail 
before any record copy.
+        
when(cacheHelper.shareGroupDlqTopic(GROUP_ID)).thenReturn(Optional.empty());
+        
when(cacheHelper.shareGroupDlqTopicPrefix()).thenReturn(Optional.empty());
+        
when(cacheHelper.isShareGroupDlqCopyRecordEnabled(any())).thenReturn(true);
+
+        stateManager = 
builder().withLogReader(logReader).withCacheHelper(cacheHelper).build();
+        stateManager.start();
+        Throwable cause = getCause(stateManager.dlq(param()));
+        assertInstanceOf(ConfigException.class, cause);
+        // Even though record copy is enabled, an invalid DLQ config fails 
fast and the source log
+        // is never read.
+        verifyNoInteractions(logReader);
+        verifyNoInteractions(mockMetrics);
+    }
+
+    @Test
+    public void testDLQRecordCopyDisabledSkipsFetch() throws Exception {
+        MockClient client = new MockClient(MOCK_TIME);
+        client.prepareResponseFrom(
+            body -> body instanceof ProduceRequest,
+            successfulProduceResponse(0),
+            DEFAULT_LEADER
+        );
+        LogReader logReader = mock(LogReader.class);
+
+        // Default cacheHelper has record copy disabled.
+        stateManager = 
builder().withClient(client).withLogReader(logReader).build();
+        stateManager.start();
+        assertNull(stateManager.dlq(param()).get(10, TimeUnit.SECONDS));
+
+        // Copy disabled => the source log is never read; the DLQ record 
carries headers only.
+        verifyNoInteractions(logReader);
+        verify(mockMetrics).recordDLQRecordWrite(GROUP_ID, 3);
+        verify(mockMetrics, never()).recordDLQProduceFailed(any());
+    }
+
     @Test
     public void testDLQRecordCopyEnabledWithPartialLogReaderRecords() throws 
Exception {
         MockClient client = new MockClient(MOCK_TIME);
@@ -1656,37 +1759,15 @@ class ShareGroupDLQStateManagerTest {
         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,
+        // First read returns 2 records; the next read contains the 3rd offset 
as well.
+        whenReadAsync(logReader, param.topicIdPartition(),
+            logReadResult(recordsInfo(
                 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(), keyData2, 
valueData2)), Errors.NONE),
+            logReadResult(recordsInfo(
                 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);
+                new SimpleRecord(MOCK_TIME.milliseconds(), keyData3, 
valueData3)), Errors.NONE));
 
         ShareGroupDLQMetadataCacheHelper cacheHelper = 
cacheHelper(DEFAULT_LEADER);
         
when(cacheHelper.isShareGroupDlqCopyRecordEnabled(any())).thenReturn(true);
@@ -1731,21 +1812,11 @@ class ShareGroupDLQStateManagerTest {
         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);
+        // Every read returns only 2 records (offsets 0 and 1); the 3rd offset 
is never read, so the
+        // loop makes no further progress and the record is produced with 
headers only.
+        whenReadAsync(logReader, param.topicIdPartition(), 
logReadResult(recordsInfo(
+            new SimpleRecord(MOCK_TIME.milliseconds(), keyData1, valueData1),
+            new SimpleRecord(MOCK_TIME.milliseconds(), keyData2, valueData2)), 
Errors.NONE));
 
         ShareGroupDLQMetadataCacheHelper cacheHelper = 
cacheHelper(DEFAULT_LEADER);
         
when(cacheHelper.isShareGroupDlqCopyRecordEnabled(any())).thenReturn(true);
@@ -1786,12 +1857,143 @@ class ShareGroupDLQStateManagerTest {
 
         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);
+        // The read fails; record copy is skipped and the DLQ record is 
produced with headers only.
+        whenReadAsync(logReader, param.topicIdPartition(),
+            logReadResult(recordsInfo(), Errors.UNKNOWN_SERVER_ERROR));
+
+        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());
+    }
+
+    @Test
+    public void testDLQRecordCopyEnabledWithRemoteFetch() throws Exception {
+        MockClient client = new MockClient(MOCK_TIME);
+        List<ProduceRequest> capturedProduces = new ArrayList<>();
+        client.prepareResponseFrom(
+            captureProduce(capturedProduces),
+            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);
+        byte[] keyData3 = "key3".getBytes(StandardCharsets.UTF_8);
+        byte[] valueData3 = "value3".getBytes(StandardCharsets.UTF_8);
+
+        LogReader logReader = mock(LogReader.class);
+        // readAsync resolves the (possibly tiered) records and returns them 
in a single read.
+        whenReadAsync(logReader, param.topicIdPartition(), 
logReadResult(recordsInfo(
+            new SimpleRecord(MOCK_TIME.milliseconds(), keyData1, valueData1),
+            new SimpleRecord(MOCK_TIME.milliseconds(), keyData2, valueData2),
+            new SimpleRecord(MOCK_TIME.milliseconds(), keyData3, valueData3)), 
Errors.NONE));
+
+        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 testDLQRecordCopyEnabledWithMixedLocalAndRemoteFetch() throws 
Exception {
+        MockClient client = new MockClient(MOCK_TIME);
+        List<ProduceRequest> capturedProduces = new ArrayList<>();
+        client.prepareResponseFrom(
+            captureProduce(capturedProduces),
+            successfulProduceResponse(0),
+            DEFAULT_LEADER
+        );
+
+        ShareGroupDLQRecordParameter param = param();   // 3 offsets requested 
(0, 1, 2)
+        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);
+        // First read returns offset 0; the next read returns the batch 
covering the remaining offsets
+        // (records at or before the already-read position are skipped by the 
fetcher).
+        whenReadAsync(logReader, param.topicIdPartition(),
+            logReadResult(recordsInfo(
+                new SimpleRecord(MOCK_TIME.milliseconds(), keyData1, 
valueData1)), Errors.NONE),
+            logReadResult(recordsInfo(
+                new SimpleRecord(MOCK_TIME.milliseconds(), keyData1, 
valueData1),
+                new SimpleRecord(MOCK_TIME.milliseconds(), keyData2, 
valueData2),
+                new SimpleRecord(MOCK_TIME.milliseconds(), keyData3, 
valueData3)), Errors.NONE));
+
+        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 testDLQRecordCopyEnabledWithRemoteFetchFailureSkipsRecords() 
throws Exception {
+        MockClient client = new MockClient(MOCK_TIME);
+        List<ProduceRequest> capturedProduces = new ArrayList<>();
+        client.prepareResponseFrom(
+            captureProduce(capturedProduces),
+            successfulProduceResponse(0),
+            DEFAULT_LEADER
+        );
+
+        ShareGroupDLQRecordParameter param = param();
+        LogReader logReader = mock(LogReader.class);
+        // readAsync cannot read the (tiered) data; the offsets are gracefully 
skipped and the DLQ
+        // record is produced with headers only.
+        whenReadAsync(logReader, param.topicIdPartition(),
+            logReadResult(recordsInfo(), Errors.UNKNOWN_SERVER_ERROR));
 
         ShareGroupDLQMetadataCacheHelper cacheHelper = 
cacheHelper(DEFAULT_LEADER);
         
when(cacheHelper.isShareGroupDlqCopyRecordEnabled(any())).thenReturn(true);
@@ -1814,6 +2016,16 @@ class ShareGroupDLQStateManagerTest {
         verify(mockMetrics, never()).recordDLQProduceFailed(any());
     }
 
+    private static MockClient.RequestMatcher 
captureProduce(List<ProduceRequest> capturedProduces) {
+        return body -> {
+            if (body instanceof ProduceRequest pr) {
+                capturedProduces.add(pr);
+                return true;
+            }
+            return false;
+        };
+    }
+
     private static ShareGroupDLQStateManager.ProduceRequestHandler 
newHandlerForCoalesceTest(
         ShareGroupDLQStateManager manager,
         String groupId,


Reply via email to