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 81447c182a0 KAFKA-20678: Add timeout in replica manager log reader 
remote read. (#22707)
81447c182a0 is described below

commit 81447c182a02667e40467e652effa50ad833d6f9
Author: Sushant Mahajan <[email protected]>
AuthorDate: Fri Jul 3 01:59:33 2026 +0530

    KAFKA-20678: Add timeout in replica manager log reader remote read. (#22707)
    
    ReplicaManagerLogReader.readAsync fetches data from remote storage, if
    enabled. The response is async. Current code does not bound this call to
    remote storage. This PR uses the existing `remoteFetchMaxWaitMs` to
    alleviate the situation by scheduling a timed killer task into the
    replica manager's timer wheel.
    
    A new integ test has been added to ShareConsumerDLQTest to verify e-2-e
    tiering  based DLQ.
    
    Reviewers: Apoorv Mittal <[email protected]>, Abhinav Dixit
     <[email protected]>
---
 build.gradle                                       |   1 +
 .../clients/consumer/ShareConsumerDLQTest.java     | 175 ++++++++++++++++++++-
 .../server/share/ReplicaManagerLogReader.java      |  68 +++++---
 .../server/share/ReplicaManagerLogReaderTest.java  |  70 +++++++--
 .../share/dlq/ShareGroupDLQRecordFetcher.java      |  10 +-
 5 files changed, 281 insertions(+), 43 deletions(-)

diff --git a/build.gradle b/build.gradle
index d4b484f0dfb..1267c5600e1 100644
--- a/build.gradle
+++ b/build.gradle
@@ -2182,6 +2182,7 @@ project(':clients:clients-integration-tests') {
     testImplementation project(':raft')
     testImplementation project(':server')
     testImplementation project(':storage')
+    testImplementation testFixtures(project(':storage'))
     testImplementation project(':core').sourceSets.test.output
     testImplementation testFixtures(project(':clients'))
     implementation project(':server-common')
diff --git 
a/clients/clients-integration-tests/src/test/java/org/apache/kafka/clients/consumer/ShareConsumerDLQTest.java
 
b/clients/clients-integration-tests/src/test/java/org/apache/kafka/clients/consumer/ShareConsumerDLQTest.java
index b3f7eb09afb..1cbf5d16e02 100644
--- 
a/clients/clients-integration-tests/src/test/java/org/apache/kafka/clients/consumer/ShareConsumerDLQTest.java
+++ 
b/clients/clients-integration-tests/src/test/java/org/apache/kafka/clients/consumer/ShareConsumerDLQTest.java
@@ -20,6 +20,7 @@ import org.apache.kafka.clients.admin.Admin;
 import org.apache.kafka.clients.admin.Config;
 import org.apache.kafka.clients.admin.ConfigEntry;
 import org.apache.kafka.clients.admin.NewTopic;
+import org.apache.kafka.clients.admin.OffsetSpec;
 import org.apache.kafka.clients.producer.Producer;
 import org.apache.kafka.clients.producer.ProducerRecord;
 import org.apache.kafka.common.TopicPartition;
@@ -267,6 +268,134 @@ public class ShareConsumerDLQTest extends 
ShareConsumerTestBase {
         verifyDlqMetrics(groupId, recordCount);
     }
 
+    /**
+     * End-to-end DLQ copy when the source records have been tiered to remote 
storage. The source topic enables
+     * tiered storage and rolls a segment per record, with a 45s total 
retention and a 5s local retention so the
+     * early offsets are offloaded to remote storage and then deleted locally 
(well before the remote segments
+     * expire). Once the local segments are gone (verified via the 
earliest-local offset advancing past them), the
+     * records are rejected with record copy enabled - so the DLQ record 
fetcher must read the original records
+     * back from remote storage. The resulting DLQ records carrying the 
original key/value confirm the fetcher
+     * successfully pulled them from remote storage.
+     *
+     * <p>Tiered storage is backed by the local-filesystem {@code 
LocalTieredStorage} RSM and the default
+     * {@code TopicBasedRemoteLogMetadataManager}; short task/cleanup 
intervals keep the offload + local-delete
+     * cycle quick.
+     */
+    @ClusterTest(
+        serverProperties = {
+            @ClusterConfigProperty(key = "remote.log.storage.system.enable", 
value = "true"),
+            @ClusterConfigProperty(key = 
"remote.log.storage.manager.class.name",
+                value = 
"org.apache.kafka.server.log.remote.storage.LocalTieredStorage"),
+            @ClusterConfigProperty(key = 
"remote.log.manager.task.interval.ms", value = "500"),
+            @ClusterConfigProperty(key = 
"remote.log.metadata.manager.listener.name", value = "EXTERNAL"),
+            @ClusterConfigProperty(key = 
"rlmm.config.remote.log.metadata.topic.replication.factor", value = "1"),
+            @ClusterConfigProperty(key = 
"rlmm.config.remote.log.metadata.topic.num.partitions", value = "1"),
+            @ClusterConfigProperty(key = "log.retention.check.interval.ms", 
value = "500"),
+            @ClusterConfigProperty(key = "log.initial.task.delay.ms", value = 
"100")
+        }
+    )
+    public void testDlqCopiesRecordsReadFromRemoteStorage() throws Exception {
+        String groupId = "dlq-remote-group";
+        // The broker's default share-group DLQ topic prefix is "dlq.", so the 
topic name must start with it.
+        String dlqTopic = "dlq.remote";
+        String sourceTopic = "dlq-remote-source";
+        int recordCount = 5;
+
+        alterShareAutoOffsetReset(groupId, "earliest");
+        createDlqTopic(dlqTopic);
+        alterShareGroupConfig(groupId, 
GroupConfig.ERRORS_DEADLETTERQUEUE_TOPIC_NAME_CONFIG, dlqTopic);
+        // Record copy enabled: the DLQ fetcher must read the original records 
back to copy their key/value.
+        alterShareGroupConfig(groupId, 
GroupConfig.ERRORS_DEADLETTERQUEUE_COPY_RECORD_ENABLE_CONFIG, "true");
+
+        // Tiered source topic: one segment per record, 45s total retention 
and 5s local retention so inactive
+        // segments are offloaded to remote storage and then deleted locally 
shortly after, while the remote
+        // segments comfortably survive the rest of the test.
+        createRemoteStorageSourceTopic(sourceTopic, 45_000L, 5_000L);
+
+        produceTo(sourceTopic, 0, recordCount);
+
+        // Wait until the early offsets have been offloaded to remote storage 
AND removed locally - i.e. the
+        // earliest *local* offset has advanced past them, so reading those 
offsets must now hit remote storage.
+        // The last record stays in the active (never-offloaded) segment, so 
the earliest local offset should
+        // reach recordCount - 1. A generous timeout (well inside the 45s 
remote retention) absorbs remote-log
+        // metadata-manager startup; it normally resolves a few seconds after 
the 5s local retention elapses.
+        waitForCondition(() -> earliestLocalOffset(sourceTopic, 0) >= 
recordCount - 1,
+            30_000L, 500L,
+            () -> "Source records were not tiered to remote storage and 
removed locally in time");
+
+        // Reject every record. Both the share fetch (to deliver them) and the 
DLQ record fetcher (to copy them)
+        // must read the tiered offsets back from remote storage.
+        rejectRecords(groupId, sourceTopic, recordCount);
+
+        // Record copy is enabled, so every DLQ record must carry the original 
key/value. For the tiered offsets
+        // (no longer present locally) that is only possible if the DLQ 
fetcher pulled them from remote storage.
+        verifyDlqTopicRecords(dlqTopic, groupId, sourceTopic, 0, 
expectedSourceOffsets(recordCount), true);
+        verifyDlqMetrics(groupId, recordCount);
+    }
+
+    @ClusterTest(
+        serverProperties = {
+            @ClusterConfigProperty(key = "remote.log.storage.system.enable", 
value = "true"),
+            @ClusterConfigProperty(key = 
"remote.log.storage.manager.class.name",
+                value = 
"org.apache.kafka.server.log.remote.storage.LocalTieredStorage"),
+            @ClusterConfigProperty(key = 
"remote.log.manager.task.interval.ms", value = "500"),
+            @ClusterConfigProperty(key = 
"remote.log.metadata.manager.listener.name", value = "EXTERNAL"),
+            @ClusterConfigProperty(key = 
"rlmm.config.remote.log.metadata.topic.replication.factor", value = "1"),
+            @ClusterConfigProperty(key = 
"rlmm.config.remote.log.metadata.topic.num.partitions", value = "1"),
+            @ClusterConfigProperty(key = "log.retention.check.interval.ms", 
value = "500"),
+            @ClusterConfigProperty(key = "log.initial.task.delay.ms", value = 
"100"),
+            @ClusterConfigProperty(key = 
"group.share.min.heartbeat.interval.ms", value = "1500"),
+            @ClusterConfigProperty(key = "group.share.heartbeat.interval.ms", 
value = "1500")
+        }
+    )
+    public void testDlqCopiesRecordsReadFromRemoteAndLocalStorage() throws 
Exception {
+        String groupId = "dlq-remote-and-local-group";
+        // The broker's default share-group DLQ topic prefix is "dlq.", so the 
topic name must start with it.
+        String dlqTopic = "dlq.remote-and-local-topic";
+        String sourceTopic = "dlq-remote-and-local-source";
+        int recordCount = 5;
+
+        alterShareAutoOffsetReset(groupId, "earliest");
+        createDlqTopic(dlqTopic);
+        alterShareGroupConfig(groupId, 
GroupConfig.ERRORS_DEADLETTERQUEUE_TOPIC_NAME_CONFIG, dlqTopic);
+        // Record copy enabled: the DLQ fetcher must read the original records 
back to copy their key/value.
+        alterShareGroupConfig(groupId, 
GroupConfig.ERRORS_DEADLETTERQUEUE_COPY_RECORD_ENABLE_CONFIG, "true");
+
+        // Tiered source topic: one segment per record, 45s total retention 
and 5s local retention so inactive
+        // segments are offloaded to remote storage and then deleted locally 
shortly after, while the remote
+        // segments comfortably survive the rest of the test.
+        createRemoteStorageSourceTopic(sourceTopic, 45_000L, 10_000L);
+
+        produceTo(sourceTopic, 0, recordCount);
+
+        // Wait until the early offsets have been offloaded to remote storage 
AND removed locally - i.e. the
+        // earliest *local* offset has advanced past them, so reading those 
offsets must now hit remote storage.
+        // The last record stays in the active (never-offloaded) segment, so 
the earliest local offset should
+        // reach recordCount - 1. A generous timeout (well inside the 45s 
remote retention) absorbs remote-log
+        // metadata-manager startup; it normally resolves a few seconds after 
the 5s local retention elapses.
+        waitForCondition(() -> earliestLocalOffset(sourceTopic, 0) >= 
recordCount - 1,
+            30_000L, 500L,
+            () -> "Source records were not tiered to remote storage and 
removed locally in time");
+
+        // Produce some more which stay in local.
+        produceTo(sourceTopic, 0, recordCount);
+
+        // Reject every record. Both the share fetch (to deliver them) and the 
DLQ record fetcher (to copy them)
+        // must read the tiered offsets back from remote storage.
+        rejectRecords(groupId, sourceTopic, recordCount * 2);
+
+        // Record copy is enabled, so every DLQ record must carry the original 
key/value. For the tiered offsets
+        // (no longer present locally) that is only possible if the DLQ 
fetcher pulled them from remote storage.
+        verifyDlqTopicRecords(dlqTopic, groupId, sourceTopic, 0, 
expectedSourceOffsets(recordCount * 2), true);
+
+        // Make sure not all offsets from second produce are tiered.
+        waitForCondition(() -> earliestLocalOffset(sourceTopic, 0) < 
recordCount * 2 - 1,
+            30_000L, 500L,
+            () -> "Offsets from second produce were tiered");
+
+        verifyDlqMetrics(groupId, recordCount * 2);
+    }
+
     /**
      * Rejects records from a multi-partition source topic and verifies they 
are routed to the correct DLQ
      * partition. The destination partition is {@code sourcePartition % 
numDlqPartitions}; with a DLQ topic that
@@ -537,13 +666,23 @@ public class ShareConsumerDLQTest extends 
ShareConsumerTestBase {
      */
     private void verifyDlqTopicRecords(String dlqTopic, String groupId, 
Set<Long> expectedSourceOffsets,
                                        boolean copyEnabled) throws 
InterruptedException {
+        verifyDlqTopicRecords(dlqTopic, groupId, tp.topic(), tp.partition(), 
expectedSourceOffsets, copyEnabled);
+    }
+
+    /**
+     * As {@link #verifyDlqTopicRecords(String, String, Set, boolean)}, but 
for an explicit source topic-partition
+     * (rather than the base {@code tp}). The DLQ records' context headers 
must reference {@code sourceTopic} /
+     * {@code sourcePartition}.
+     */
+    private void verifyDlqTopicRecords(String dlqTopic, String groupId, String 
sourceTopic, int sourcePartition,
+                                       Set<Long> expectedSourceOffsets, 
boolean copyEnabled) throws InterruptedException {
         List<ConsumerRecord<byte[], byte[]>> dlqRecords = 
readDlqPartition(dlqTopic, 0, expectedSourceOffsets.size());
 
         assertEquals(expectedSourceOffsets.size(), dlqRecords.size(), 
"Unexpected number of records on the DLQ topic");
         Set<Long> actualSourceOffsets = new HashSet<>();
         for (ConsumerRecord<byte[], byte[]> record : dlqRecords) {
             if (copyEnabled) {
-                // produceMessages() produces records with key "key" and value 
"value".
+                // produceMessages()/produceTo() produce records with key 
"key" and value "value".
                 assertEquals("key", new 
String(Objects.requireNonNull(record.key()), StandardCharsets.UTF_8),
                     "DLQ record key should be copied when record copy is 
enabled");
                 assertEquals("value", new 
String(Objects.requireNonNull(record.value()), StandardCharsets.UTF_8),
@@ -553,8 +692,8 @@ public class ShareConsumerDLQTest extends 
ShareConsumerTestBase {
                 assertNull(record.value(), "DLQ record value should be null 
when record copy is disabled");
             }
             assertEquals(groupId, headerValue(record, 
HEADER_DLQ_ERRORS_GROUP));
-            assertEquals(tp.topic(), headerValue(record, 
HEADER_DLQ_ERRORS_TOPIC));
-            assertEquals(Integer.toString(tp.partition()), headerValue(record, 
HEADER_DLQ_ERRORS_PARTITION));
+            assertEquals(sourceTopic, headerValue(record, 
HEADER_DLQ_ERRORS_TOPIC));
+            assertEquals(Integer.toString(sourcePartition), 
headerValue(record, HEADER_DLQ_ERRORS_PARTITION));
             
actualSourceOffsets.add(Long.parseLong(Objects.requireNonNull(headerValue(record,
 HEADER_DLQ_ERRORS_OFFSET))));
         }
         assertEquals(expectedSourceOffsets, actualSourceOffsets, "DLQ records 
should cover every expected source offset");
@@ -591,6 +730,36 @@ public class ShareConsumerDLQTest extends 
ShareConsumerTestBase {
         }, "Failed to create DLQ topic");
     }
 
+    // Creates a single-partition source topic with tiered storage enabled and 
one log segment per record (via
+    // per-record index entries). A short local retention (`localRetentionMs`) 
deletes inactive segments from
+    // local storage soon after they are offloaded to remote storage, so 
reading those offsets must hit remote
+    // storage; the total retention (`retentionMs`) is kept generous so the 
remote segments are not deleted while
+    // the test is still running.
+    private void createRemoteStorageSourceTopic(String topic, long 
retentionMs, long localRetentionMs) {
+        assertDoesNotThrow(() -> {
+            try (Admin admin = createAdminClient()) {
+                Map<String, String> configs = Map.of(
+                    TopicConfig.REMOTE_LOG_STORAGE_ENABLE_CONFIG, "true",
+                    TopicConfig.RETENTION_MS_CONFIG, 
Long.toString(retentionMs),
+                    TopicConfig.LOCAL_LOG_RETENTION_MS_CONFIG, 
Long.toString(localRetentionMs),
+                    // Roll a segment for every record so each inactive 
segment can be offloaded then deleted locally.
+                    TopicConfig.INDEX_INTERVAL_BYTES_CONFIG, "1",
+                    TopicConfig.SEGMENT_INDEX_BYTES_CONFIG, "12");
+                admin.createTopics(Set.of(new NewTopic(topic, 1, (short) 
1).configs(configs))).all().get();
+            }
+        }, "Failed to create remote-storage source topic");
+    }
+
+    // The earliest offset still held in local storage. Offsets below this 
have been removed locally (e.g. after
+    // being offloaded to remote storage), so reading them must hit remote 
storage. Used to confirm tiering.
+    private long earliestLocalOffset(String topic, int partition) throws 
Exception {
+        TopicPartition topicPartition = new TopicPartition(topic, partition);
+        try (Admin admin = createAdminClient()) {
+            return admin.listOffsets(Map.of(topicPartition, 
OffsetSpec.earliestLocal()))
+                .partitionResult(topicPartition).get().offset();
+        }
+    }
+
     // Produces `count` records (key "key", value "value") to a specific 
topic-partition.
     private void produceTo(String topic, int partition, int count) {
         try (Producer<byte[], byte[]> producer = createProducer()) {
diff --git a/core/src/main/java/kafka/server/share/ReplicaManagerLogReader.java 
b/core/src/main/java/kafka/server/share/ReplicaManagerLogReader.java
index acebdb6a735..70e798b6eb2 100644
--- a/core/src/main/java/kafka/server/share/ReplicaManagerLogReader.java
+++ b/core/src/main/java/kafka/server/share/ReplicaManagerLogReader.java
@@ -25,6 +25,7 @@ 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.server.util.timer.TimerTask;
 import org.apache.kafka.storage.internals.log.FetchDataInfo;
 import org.apache.kafka.storage.internals.log.LogReadResult;
 import org.apache.kafka.storage.internals.log.RemoteStorageFetchInfo;
@@ -37,6 +38,7 @@ import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CompletionException;
+import java.util.concurrent.TimeoutException;
 import java.util.stream.Collectors;
 
 import scala.Tuple2;
@@ -116,16 +118,10 @@ public class ReplicaManagerLogReader implements LogReader 
{
         LinkedHashMap<TopicIdPartition, LogReadResult> localReadResults =
             read(fetchParams, partitionsToFetch, topicPartitionFetchOffsets, 
partitionMaxBytes);
 
-        // One future per partition; combined into a single future once every 
partition has resolved.
+        // Only look at partitions with non-null read results.
         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;
@@ -144,10 +140,8 @@ public class ReplicaManagerLogReader implements LogReader {
                     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);
+                    // Sending an error here to caller is not useful so not 
setting it in the result. Above log should suffice.
+                    return logReadResult;
                 }
                 return withInfoAndError(logReadResult, remoteFetchDataInfo, 
Errors.NONE);
             }));
@@ -185,18 +179,19 @@ public class ReplicaManagerLogReader implements LogReader 
{
      * 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).
+     * configured, the read could not be completed, or the read does not 
finish within the configured
+     * remote fetch timeout ({@code remote.fetch.max.wait.ms}). Used 
internally by readAsync
+     * (package-private so it remains unit-testable).
      */
     // Visibility for testing
     CompletableFuture<FetchDataInfo> readRemote(RemoteStorageFetchInfo 
remoteStorageFetchInfo) {
-        CompletableFuture<FetchDataInfo> future = new CompletableFuture<>();
+        CompletableFuture<FetchDataInfo> remoteFuture = new 
CompletableFuture<>();
 
         Optional<RemoteLogManager> remoteLogManager = 
OptionConverters.toJava(replicaManager.remoteLogManager());
         if (remoteLogManager.isEmpty()) {
-            future.completeExceptionally(new IllegalStateException(
+            remoteFuture.completeExceptionally(new IllegalStateException(
                 "Cannot read " + remoteStorageFetchInfo + " from remote 
storage as remote log manager is not configured."));
-            return future;
+            return remoteFuture;
         }
 
         try {
@@ -204,20 +199,51 @@ public class ReplicaManagerLogReader implements LogReader 
{
             // 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());
+                    remoteFuture.completeExceptionally(result.error().get());
                 } else if (result.fetchDataInfo().isPresent()) {
-                    future.complete(result.fetchDataInfo().get());
+                    remoteFuture.complete(result.fetchDataInfo().get());
                 } else {
-                    future.completeExceptionally(new IllegalStateException(
+                    remoteFuture.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);
+            remoteFuture.completeExceptionally(e);
         }
 
-        return future;
+        // Bound the wait on the remote read so a stalled remote tier cannot 
pin the read (and the
+        // resources held while it is pending) indefinitely. Use the broker's 
timer wheel - as
+        // DelayedShareFetch does for its remote fetch - rather than 
CompletableFuture#orTimeout. On
+        // expiry the future completes exceptionally with a TimeoutException, 
which the caller treats
+        // as a (skippable) read error.
+        if (!remoteFuture.isDone()) {
+            long timeoutMs = remoteFetchMaxWaitMs();
+            TimerTask timeoutTask = new TimerTask(timeoutMs) {
+                @Override
+                public void run() {
+                    remoteFuture.completeExceptionally(new TimeoutException(
+                        "Remote read for " + remoteStorageFetchInfo + " did 
not complete within " + timeoutMs + " ms."));
+                }
+            };
+            // Cancel the timer task once the read completes (either outcome) 
so it does not linger in the wheel.
+            remoteFuture.whenComplete((info, exception) -> 
timeoutTask.cancel());
+
+            // ReplicaManager has a dedicated timer for consumption by the 
share fetch
+            // code, and it is exposed addShareFetchTimerRequest. The same is 
also used
+            // in DelayedShareFetch. Hence, re-using it.
+            replicaManager.addShareFetchTimerRequest(timeoutTask);
+        }
+
+        return remoteFuture;
+    }
+
+    /**
+     * The maximum time to wait for a remote-tier read, taken from the broker's
+     * {@code remote.fetch.max.wait.ms}. Read per call since the config is 
dynamically reconfigurable.
+     */
+    private long remoteFetchMaxWaitMs() {
+        return 
replicaManager.config().remoteLogManagerConfig().remoteFetchMaxWaitMs();
     }
 }
diff --git 
a/core/src/test/java/kafka/server/share/ReplicaManagerLogReaderTest.java 
b/core/src/test/java/kafka/server/share/ReplicaManagerLogReaderTest.java
index f0f75b329f2..fb93cba205b 100644
--- a/core/src/test/java/kafka/server/share/ReplicaManagerLogReaderTest.java
+++ b/core/src/test/java/kafka/server/share/ReplicaManagerLogReaderTest.java
@@ -16,16 +16,20 @@
  */
 package kafka.server.share;
 
+import kafka.server.KafkaConfig;
 import kafka.server.ReplicaManager;
 
 import org.apache.kafka.common.TopicIdPartition;
 import org.apache.kafka.common.Uuid;
+import org.apache.kafka.common.config.AbstractConfig;
 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.log.remote.storage.RemoteLogManagerConfig;
 import org.apache.kafka.server.storage.log.FetchIsolation;
 import org.apache.kafka.server.storage.log.FetchParams;
+import org.apache.kafka.server.util.timer.TimerTask;
 import org.apache.kafka.storage.internals.log.FetchDataInfo;
 import org.apache.kafka.storage.internals.log.LogOffsetMetadata;
 import org.apache.kafka.storage.internals.log.LogReadResult;
@@ -36,12 +40,14 @@ import org.junit.jupiter.api.Test;
 
 import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 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.concurrent.TimeoutException;
 import java.util.function.Consumer;
 
 import scala.Option;
@@ -70,6 +76,18 @@ public class ReplicaManagerLogReaderTest {
         new TopicIdPartition(Uuid.randomUuid(), 0, "topic");
     private static final TopicIdPartition TOPIC_ID_PARTITION_2 =
         new TopicIdPartition(Uuid.randomUuid(), 1, "topic");
+    // A real config with defaults; readRemote() reads 
remote.fetch.max.wait.ms from it for its timeout.
+    private static final RemoteLogManagerConfig REMOTE_LOG_MANAGER_CONFIG =
+        new RemoteLogManagerConfig(new 
AbstractConfig(RemoteLogManagerConfig.configDef(), Map.of(), false));
+
+    // A ReplicaManager mock whose config() chain resolves 
remote.fetch.max.wait.ms, as readRemote() needs.
+    private static ReplicaManager mockReplicaManager() {
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        KafkaConfig kafkaConfig = mock(KafkaConfig.class);
+        
when(kafkaConfig.remoteLogManagerConfig()).thenReturn(REMOTE_LOG_MANAGER_CONFIG);
+        when(replicaManager.config()).thenReturn(kafkaConfig);
+        return replicaManager;
+    }
 
     private static RemoteStorageFetchInfo remoteStorageFetchInfo() {
         return new RemoteStorageFetchInfo(
@@ -124,7 +142,7 @@ public class ReplicaManagerLogReaderTest {
 
     @Test
     public void testReadReturnsEmptyWhenNoPartitionsToFetch() {
-        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        ReplicaManager replicaManager = mockReplicaManager();
 
         ReplicaManagerLogReader logReader = new 
ReplicaManagerLogReader(replicaManager);
         LinkedHashMap<TopicIdPartition, LogReadResult> result =
@@ -136,7 +154,7 @@ public class ReplicaManagerLogReaderTest {
 
     @Test
     public void testReadReturnsResultsFromReplicaManager() {
-        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        ReplicaManager replicaManager = mockReplicaManager();
         LogReadResult logReadResult = mock(LogReadResult.class);
         Seq<Tuple2<TopicIdPartition, LogReadResult>> readFromLogResult =
             CollectionConverters.asScala(List.of(new 
Tuple2<>(TOPIC_ID_PARTITION, logReadResult))).toSeq();
@@ -157,7 +175,7 @@ public class ReplicaManagerLogReaderTest {
 
     @Test
     public void 
testReadRemoteCompletesExceptionallyWhenRemoteLogManagerNotConfigured() {
-        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        ReplicaManager replicaManager = mockReplicaManager();
         when(replicaManager.remoteLogManager()).thenReturn(Option.empty());
 
         ReplicaManagerLogReader logReader = new 
ReplicaManagerLogReader(replicaManager);
@@ -170,7 +188,7 @@ public class ReplicaManagerLogReaderTest {
 
     @Test
     public void testReadRemoteCompletesWithFetchedData() throws Exception {
-        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        ReplicaManager replicaManager = mockReplicaManager();
         RemoteLogManager remoteLogManager = mock(RemoteLogManager.class);
         
when(replicaManager.remoteLogManager()).thenReturn(Option.apply(remoteLogManager));
 
@@ -190,7 +208,7 @@ public class ReplicaManagerLogReaderTest {
 
     @Test
     public void testReadRemoteCompletesExceptionallyWhenReadResultHasError() {
-        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        ReplicaManager replicaManager = mockReplicaManager();
         RemoteLogManager remoteLogManager = mock(RemoteLogManager.class);
         
when(replicaManager.remoteLogManager()).thenReturn(Option.apply(remoteLogManager));
 
@@ -211,7 +229,7 @@ public class ReplicaManagerLogReaderTest {
 
     @Test
     public void testReadRemoteCompletesExceptionallyWhenReadResultIsEmpty() {
-        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        ReplicaManager replicaManager = mockReplicaManager();
         RemoteLogManager remoteLogManager = mock(RemoteLogManager.class);
         
when(replicaManager.remoteLogManager()).thenReturn(Option.apply(remoteLogManager));
 
@@ -231,7 +249,7 @@ public class ReplicaManagerLogReaderTest {
 
     @Test
     public void testReadRemoteCompletesExceptionallyWhenSchedulingRejected() {
-        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        ReplicaManager replicaManager = mockReplicaManager();
         RemoteLogManager remoteLogManager = mock(RemoteLogManager.class);
         
when(replicaManager.remoteLogManager()).thenReturn(Option.apply(remoteLogManager));
 
@@ -246,9 +264,29 @@ public class ReplicaManagerLogReaderTest {
         assertSame(rejected, exception.getCause());
     }
 
+    @Test
+    public void testReadRemoteTimesOutWhenRemoteReadNeverCompletes() {
+        ReplicaManager replicaManager = mockReplicaManager();
+        RemoteLogManager remoteLogManager = mock(RemoteLogManager.class);
+        
when(replicaManager.remoteLogManager()).thenReturn(Option.apply(remoteLogManager));
+        // asyncRead never invokes the callback, so the remote read never 
completes on its own.
+        // Simulate the timer wheel firing by running the scheduled timeout 
task immediately.
+        doAnswer(invocation -> {
+            invocation.<TimerTask>getArgument(0).run();
+            return null;
+        }).when(replicaManager).addShareFetchTimerRequest(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(TimeoutException.class, exception.getCause());
+    }
+
     @Test
     public void testReadAsyncReturnsEmptyWhenNoPartitionsToFetch() {
-        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        ReplicaManager replicaManager = mockReplicaManager();
 
         ReplicaManagerLogReader logReader = new 
ReplicaManagerLogReader(replicaManager);
         LinkedHashMap<TopicIdPartition, LogReadResult> result =
@@ -260,7 +298,7 @@ public class ReplicaManagerLogReaderTest {
 
     @Test
     public void testReadAsyncReturnsLocalDataWhenNotTiered() throws Exception {
-        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        ReplicaManager replicaManager = mockReplicaManager();
         FetchDataInfo localData = localData();
         stubReadFromLog(replicaManager,
             List.of(new Tuple2<>(TOPIC_ID_PARTITION, 
localReadResult(localData, Errors.NONE))));
@@ -280,7 +318,7 @@ public class ReplicaManagerLogReaderTest {
 
     @Test
     public void testReadAsyncFollowsRemoteWhenTieredAndReadRemoteTrue() throws 
Exception {
-        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        ReplicaManager replicaManager = mockReplicaManager();
         RemoteLogManager remoteLogManager = mock(RemoteLogManager.class);
         
when(replicaManager.remoteLogManager()).thenReturn(Option.apply(remoteLogManager));
 
@@ -308,7 +346,7 @@ public class ReplicaManagerLogReaderTest {
 
     @Test
     public void testReadAsyncSkipsRemoteWhenReadRemoteFalse() throws Exception 
{
-        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        ReplicaManager replicaManager = mockReplicaManager();
         FetchDataInfo tieredData = tieredData(remoteStorageFetchInfo());
         stubReadFromLog(replicaManager,
             List.of(new Tuple2<>(TOPIC_ID_PARTITION, 
localReadResult(tieredData, Errors.NONE))));
@@ -327,7 +365,7 @@ public class ReplicaManagerLogReaderTest {
 
     @Test
     public void testReadAsyncReturnsErrorFromLocalRead() throws Exception {
-        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        ReplicaManager replicaManager = mockReplicaManager();
         FetchDataInfo localData = localData();
         stubReadFromLog(replicaManager,
             List.of(new Tuple2<>(TOPIC_ID_PARTITION, 
localReadResult(localData, Errors.UNKNOWN_SERVER_ERROR))));
@@ -344,8 +382,8 @@ public class ReplicaManagerLogReaderTest {
     }
 
     @Test
-    public void testReadAsyncReturnsErrorWhenRemoteReadFails() throws 
Exception {
-        ReplicaManager replicaManager = mock(ReplicaManager.class);
+    public void 
testReadAsyncReturnsNoneErrorWhenRemoteReadFailsButLocalSucceeds() throws 
Exception {
+        ReplicaManager replicaManager = mockReplicaManager();
         RemoteLogManager remoteLogManager = mock(RemoteLogManager.class);
         
when(replicaManager.remoteLogManager()).thenReturn(Option.apply(remoteLogManager));
 
@@ -367,12 +405,12 @@ public class ReplicaManagerLogReaderTest {
         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());
+        assertEquals(Errors.NONE, logReadResult.error());
     }
 
     @Test
     public void testReadAsyncResolvesPartitionsIndependently() throws 
Exception {
-        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        ReplicaManager replicaManager = mockReplicaManager();
         RemoteLogManager remoteLogManager = mock(RemoteLogManager.class);
         
when(replicaManager.remoteLogManager()).thenReturn(Option.apply(remoteLogManager));
 
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
index df6f381dec9..4d832423b02 100644
--- 
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
@@ -126,8 +126,12 @@ public class ShareGroupDLQRecordFetcher {
 
             if (!future.isDone()) {
                 // A remote read is in flight: resume from the callback so the 
calling thread is unblocked.
+                // Uses whenCompleteAsync (not whenComplete) to close a race 
between the isDone() check
+                // above and callback registration: if the future completes in 
that gap, whenComplete
+                // would run the callback inline on this thread, letting 
resume() recurse into runFrom()
+                // on the same stack. whenCompleteAsync always dispatches 
off-thread, so that can't happen.
                 long readFrom = nextOffset;
-                future.whenComplete((results, exception) -> resume(readFrom, 
logReadResult(results), exception));
+                future.whenCompleteAsync((results, exception) -> 
resume(readFrom, logReadResult(results), exception));
                 return;
             }
 
@@ -169,8 +173,8 @@ public class ShareGroupDLQRecordFetcher {
                 runFrom(advanced);  // resume the loop
             }
         } catch (Exception e) {
-            log.warn("Unexpected error processing records for {}. Skipping 
record copy.", param, e);
-            result.complete(Map.of());
+            log.warn("Unexpected error processing records for {}. Returning 
records fetched so far.", param, e);
+            complete();
         }
     }
 


Reply via email to