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 2cf3c3ad9b4 KAFKA-20783: Release locks on exception for local reads 
within remote calls (5/N) (#22830)
2cf3c3ad9b4 is described below

commit 2cf3c3ad9b47df75724b0cd6f17029bb702691ef
Author: Apoorv Mittal <[email protected]>
AuthorDate: Wed Jul 15 13:21:06 2026 +0100

    KAFKA-20783: Release locks on exception for local reads within remote calls 
(5/N) (#22830)
    
    This PR adds exception handling when the future for local reads raise
    exception from calls within remote calls processing i.e. min bytes is
    not satisfied and additional data needs to be fetched from local log.
    
    PR also makes some tests to utilize common code.
    
    Reviewers: Andrew Schofield <[email protected]>, Abhinav Dixit
    <[email protected]>
---
 checkstyle/suppressions.xml                        |   2 +-
 .../java/kafka/server/share/DelayedShareFetch.java |   8 +
 .../kafka/server/share/DelayedShareFetchTest.java  | 172 ++++++++++++++-------
 3 files changed, 127 insertions(+), 55 deletions(-)

diff --git a/checkstyle/suppressions.xml b/checkstyle/suppressions.xml
index ecf36ae6484..b1c71df9861 100644
--- a/checkstyle/suppressions.xml
+++ b/checkstyle/suppressions.xml
@@ -39,7 +39,7 @@
     <suppress checks="NPathComplexity" 
files="(ClusterTestExtensions|KafkaApisBuilder|SharePartition|SharePartitionManager).java"/>
     <suppress checks="NPathComplexity" files="TestKitNodes.java"/>
     <suppress checks="JavaNCSS"
-              
files="(SharePartitionManagerTest|SharePartitionTest|ShareConsumerTest).java"/>
+              
files="(SharePartitionManagerTest|SharePartitionTest|ShareConsumerTest|DelayedShareFetchTest).java"/>
     <suppress checks="ClassDataAbstractionCoupling|ClassFanOutComplexity" 
files="SharePartitionManagerTest"/>
     <suppress checks="CyclomaticComplexity" files="SharePartition.java"/>
 
diff --git a/core/src/main/java/kafka/server/share/DelayedShareFetch.java 
b/core/src/main/java/kafka/server/share/DelayedShareFetch.java
index a554f92f2f9..9c410261a29 100644
--- a/core/src/main/java/kafka/server/share/DelayedShareFetch.java
+++ b/core/src/main/java/kafka/server/share/DelayedShareFetch.java
@@ -1198,6 +1198,10 @@ public class DelayedShareFetch extends DelayedOperation {
             // Handle synchronous exception from readFromLog()
             log.error("Error initiating async fetch in remote completion for 
group {}, member {}",
                 shareFetch.groupId(), shareFetch.memberId(), e);
+            // processFetchResultAndComplete won't be invoked in this error 
path, so it won't release the
+            // locks for partitionsToFetch. Release them here to avoid leaking 
the local partition locks
+            // before completing with just the remote data.
+            
releasePartitionLocksAndAddToActionQueue(partitionsToFetch.keySet(), Set.of());
             // Continue to complete with just remote data.
             completionConsumer.accept(List.of());
             return;
@@ -1210,6 +1214,10 @@ public class DelayedShareFetch extends DelayedOperation {
             if (throwable != null) {
                 log.error("Async fetch failed in remote completion for group 
{}, member {}",
                     shareFetch.groupId(), shareFetch.memberId(), throwable);
+                // processFetchResultAndComplete won't be invoked in this 
error path, so it won't release
+                // the locks for partitionsToFetch. Release them here to avoid 
leaking the local partition
+                // locks before completing with just the remote data.
+                
releasePartitionLocksAndAddToActionQueue(partitionsToFetch.keySet(), Set.of());
                 // Continue to complete with just remote data.
                 completionConsumer.accept(List.of());
                 return;
diff --git a/core/src/test/java/kafka/server/share/DelayedShareFetchTest.java 
b/core/src/test/java/kafka/server/share/DelayedShareFetchTest.java
index 55219fa14c3..ff2bb171dae 100644
--- a/core/src/test/java/kafka/server/share/DelayedShareFetchTest.java
+++ b/core/src/test/java/kafka/server/share/DelayedShareFetchTest.java
@@ -2653,6 +2653,20 @@ public class DelayedShareFetchTest {
     @SuppressWarnings("unchecked")
     @Test
     public void testAsyncReadMinBytesNotSatisfiedClearsAcquiredStateForRetry() 
throws Exception {
+        // On next re-try, tryComplete() must not assume the released locks 
are still held. Since the acquired
+        // state was cleared, it re-acquires the partitions afresh (a second 
maybeAcquireFetchLock).
+        verifyAsyncReadMinBytesNotSatisfiedClearsAcquiredState(false);
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    public void 
testAsyncReadMinBytesNotSatisfiedClearsAcquiredStateForOnComplete() throws 
Exception {
+        // On timeout, onComplete() must not assume the released locks are 
still held. Since the acquired
+        // state was cleared, it re-acquires the partitions afresh (a second 
maybeAcquireFetchLock).
+        verifyAsyncReadMinBytesNotSatisfiedClearsAcquiredState(true);
+    }
+
+    private void 
verifyAsyncReadMinBytesNotSatisfiedClearsAcquiredState(boolean requestTimedOut) 
throws Exception {
         String groupId = "grp";
         Uuid topicId = Uuid.randomUuid();
         LogReader logReader = mock(LogReader.class);
@@ -2713,79 +2727,117 @@ public class DelayedShareFetchTest {
         assertNull(delayedShareFetch.pendingFetch());
         Mockito.verify(sp0, times(1)).releaseFetchLock(fetchId);
 
-        // On next re-try, tryComplete() must not assume the released locks 
are still held. Since the acquired
-        // state was cleared, it re-acquires the partitions afresh (a second 
maybeAcquireFetchLock).
-        delayedShareFetch.tryComplete();
+        // Trigger onComplete() if requestTimedOut else tryComplete(). The 
re-try must not assume the
+        // released locks are still held. Since the acquired state was 
cleared, it re-acquires the
+        // partitions afresh (a second maybeAcquireFetchLock).
+        if (requestTimedOut) {
+            delayedShareFetch.forceComplete();
+        } else {
+            delayedShareFetch.tryComplete();
+        }
         Mockito.verify(sp0, times(2)).maybeAcquireFetchLock(fetchId);
     }
 
-    @SuppressWarnings("unchecked")
     @Test
-    public void 
testAsyncReadMinBytesNotSatisfiedClearsAcquiredStateForOnComplete() throws 
Exception {
-        String groupId = "grp";
-        Uuid topicId = Uuid.randomUuid();
-        LogReader logReader = mock(LogReader.class);
+    public void testRemoteStorageFetchLocalReadFailureReleasesLocks() {
+        verifyRemoteStorageFetchLocalReadFailureReleasesLocks(true);
+    }
+
+    @Test
+    public void 
testRemoteStorageFetchLocalReadCompletionFailureReleasesLocks() {
+        verifyRemoteStorageFetchLocalReadFailureReleasesLocks(false);
+    }
+
+    private void verifyRemoteStorageFetchLocalReadFailureReleasesLocks(boolean 
syncFailure) {
         ReplicaManager replicaManager = mock(ReplicaManager.class);
+        LogReader logReader = mock(LogReader.class);
         PartitionMetadataProvider metadataProvider = 
mock(PartitionMetadataProvider.class);
-        TopicIdPartition tp0 = new TopicIdPartition(topicId, new 
TopicPartition("foo", 0));
+        TopicIdPartition tp0 = new TopicIdPartition(Uuid.randomUuid(), new 
TopicPartition("foo", 0));
+        TopicIdPartition tp1 = new TopicIdPartition(Uuid.randomUuid(), new 
TopicPartition("foo", 1));
+        TopicIdPartition tp2 = new TopicIdPartition(Uuid.randomUuid(), new 
TopicPartition("foo", 2));
 
         SharePartition sp0 = mock(SharePartition.class);
+        SharePartition sp1 = mock(SharePartition.class);
+        SharePartition sp2 = mock(SharePartition.class);
+        when(sp0.canAcquireRecords()).thenReturn(true);
+        when(sp1.canAcquireRecords()).thenReturn(true);
+        when(sp2.canAcquireRecords()).thenReturn(true);
+
         LinkedHashMap<TopicIdPartition, SharePartition> sharePartitions = new 
LinkedHashMap<>();
         sharePartitions.put(tp0, sp0);
+        sharePartitions.put(tp1, sp1);
+        sharePartitions.put(tp2, sp2);
 
-        // Set a high minBytes so that the async read result does not satisfy 
minBytes and the request
-        // is returned to the purgatory.
-        FetchParams fetchParams = new 
FetchParams(FetchRequest.ORDINARY_CONSUMER_ID, -1, MAX_WAIT_MS, 5000, 10000,
-            FetchIsolation.HIGH_WATERMARK, Optional.empty());
         CompletableFuture<Map<TopicIdPartition, 
ShareFetchResponseData.PartitionData>> future = new CompletableFuture<>();
-        ShareFetch shareFetch = new ShareFetch(fetchParams, groupId, 
Uuid.randomUuid().toString(),
-            future, List.of(tp0), BATCH_OPTIMIZED, BATCH_SIZE, 
MAX_FETCH_RECORDS, BROKER_TOPIC_STATS);
+        ShareFetch shareFetch = new ShareFetch(FETCH_PARAMS, "grp", 
Uuid.randomUuid().toString(),
+            future, List.of(tp0, tp1, tp2), BATCH_OPTIMIZED, BATCH_SIZE, 
MAX_FETCH_RECORDS, BROKER_TOPIC_STATS);
 
-        CompletableFuture<LinkedHashMap<TopicIdPartition, LogReadResult>> 
asyncReadFuture = mock(CompletableFuture.class);
-        when(logReader.readAsync(any(), any(), any(), any(), 
anyBoolean())).thenReturn(asyncReadFuture);
-        // isDone returns false for the first tryComplete (store pending 
fetch) and true for the second
-        // tryComplete (process the completed result).
-        when(asyncReadFuture.isDone()).thenReturn(false).thenReturn(true);
-        
when(asyncReadFuture.get()).thenReturn(buildLogReadResultMap(List.of(tp0)));
+        when(sp0.nextFetchOffset()).thenReturn(10L);
+        when(sp1.nextFetchOffset()).thenReturn(20L);
+        when(sp2.nextFetchOffset()).thenReturn(30L);
+        when(sp0.fetchOffsetMetadata(anyLong())).thenReturn(Optional.empty());
+        when(sp1.fetchOffsetMetadata(anyLong())).thenReturn(Optional.empty());
+        when(sp2.fetchOffsetMetadata(anyLong())).thenReturn(Optional.empty());
 
-        when(sp0.maybeAcquireFetchLock(any())).thenReturn(true);
-        when(sp0.canAcquireRecords()).thenReturn(true);
-        when(sp0.nextFetchOffset()).thenReturn(0L);
-        when(sp0.fetchOffsetMetadata(anyLong()))
-            .thenReturn(Optional.empty())
-            .thenReturn(Optional.of(new LogOffsetMetadata(0, 1, 0)));
-        when(metadataProvider.endOffsetMetadata(any(), any())).thenReturn(new 
LogOffsetMetadata(1, 1, 1));
+        // First read (tryComplete): tp0, tp1 local and tp2 remote fetch. The 
second read (additional local
+        // read in onComplete) fails, exercising an error path in 
readAndProcessFetchResultCompletion:
+        // either synchronously (readAsync throws) or asynchronously (an 
exceptionally-completed future).
+        LinkedHashMap<TopicIdPartition, LogReadResult> firstReadResult = 
buildLocalAndRemoteFetchResultMap(Set.of(tp0, tp1), Set.of(tp2));
+        if (syncFailure) {
+            when(logReader.readAsync(any(), any(), any(), any(), anyBoolean()))
+                .thenReturn(CompletableFuture.completedFuture(firstReadResult))
+                .thenThrow(new RuntimeException("sync local read failure"));
+        } else {
+            when(logReader.readAsync(any(), any(), any(), any(), anyBoolean()))
+                .thenReturn(CompletableFuture.completedFuture(firstReadResult))
+                .thenReturn(CompletableFuture.failedFuture(new 
RuntimeException("async local read failure")));
+        }
 
-        PartitionMaxBytesStrategy partitionMaxBytesStrategy = 
mockPartitionMaxBytes(Set.of(tp0));
+        try (MockedStatic<ShareFetchUtils> mockedShareFetchUtils = 
Mockito.mockStatic(ShareFetchUtils.class)) {
+            mockedShareFetchUtils.when(() -> 
ShareFetchUtils.processFetchResponse(any(), any(), any(), any(), any()))
+                .thenReturn(Map.of(tp2, 
mock(ShareFetchResponseData.PartitionData.class)));
 
-        Uuid fetchId = Uuid.randomUuid();
-        DelayedShareFetch delayedShareFetch = 
DelayedShareFetchBuilder.builder()
-            .withShareFetchData(shareFetch)
-            .withLogReader(logReader)
-            .withMetadataProvider(metadataProvider)
-            .withPartitionMaxBytesStrategy(partitionMaxBytesStrategy)
-            .withSharePartitions(sharePartitions)
-            .withReplicaManager(replicaManager)
-            .withFetchId(fetchId)
-            .build();
+            // Remote fetch for tp2 completes within tryComplete so the 
request moves on to
+            // onComplete where the additional local read is attempted.
+            RemoteLogReadResult remoteFetchResult = new 
RemoteLogReadResult(Optional.of(REMOTE_FETCH_INFO), Optional.empty());
+            RemoteLogManager remoteLogManager = mock(RemoteLogManager.class);
+            doAnswer(invocationOnMock -> {
+                Consumer<RemoteLogReadResult> callback = 
invocationOnMock.getArgument(1);
+                callback.accept(remoteFetchResult);
+                return CompletableFuture.completedFuture(remoteFetchResult);
+            }).when(remoteLogManager).asyncRead(any(), any());
+            
when(replicaManager.remoteLogManager()).thenReturn(Option.apply(remoteLogManager));
 
-        // First tryComplete: async read not done, so a pending fetch is 
created and tp0 lock is held
-        // (partitionsAcquired is populated internally).
-        assertNull(delayedShareFetch.pendingFetch());
-        assertFalse(delayedShareFetch.tryComplete());
-        assertNotNull(delayedShareFetch.pendingFetch());
-        Mockito.verify(sp0, times(1)).maybeAcquireFetchLock(fetchId);
+            // maybeCompletePendingRemoteFetch checks partition leadership for 
the remote fetch partition.
+            Partition p2 = mock(Partition.class);
+            when(p2.isLeader()).thenReturn(true);
+            
when(replicaManager.getPartitionOrException(tp2.topicPartition())).thenReturn(p2);
 
-        // Second tryComplete (purgatory re-check): async read is done but 
minBytes is not satisfied, so
-        // the lock is released, the pending fetch is cleared and the request 
returns to the purgatory.
-        assertFalse(delayedShareFetch.tryComplete());
-        assertNull(delayedShareFetch.pendingFetch());
-        Mockito.verify(sp0, times(1)).releaseFetchLock(fetchId);
+            Uuid fetchId = Uuid.randomUuid();
+            DelayedShareFetch delayedShareFetch = 
spy(DelayedShareFetchBuilder.builder()
+                .withShareFetchData(shareFetch)
+                .withReplicaManager(replicaManager)
+                .withLogReader(logReader)
+                .withMetadataProvider(metadataProvider)
+                .withSharePartitions(sharePartitions)
+                
.withPartitionMaxBytesStrategy(mockPartitionMaxBytes(Set.of(tp0, tp1, tp2)))
+                .withFetchId(fetchId)
+                .build());
 
-        // On timeout, onComplete() must not assume the released locks are 
still held. Since the acquired
-        // state was cleared, it re-acquires the partitions afresh (a second 
maybeAcquireFetchLock).
-        delayedShareFetch.forceComplete();
-        Mockito.verify(sp0, times(2)).maybeAcquireFetchLock(fetchId);
+            when(sp0.maybeAcquireFetchLock(fetchId)).thenReturn(true);
+            when(sp1.maybeAcquireFetchLock(fetchId)).thenReturn(true);
+            when(sp2.maybeAcquireFetchLock(fetchId)).thenReturn(true);
+
+            assertTrue(delayedShareFetch.tryComplete());
+            assertTrue(delayedShareFetch.isCompleted());
+            assertTrue(shareFetch.isCompleted());
+
+            // tp0 and tp1 (local partitions being fetched) locks must be 
released even though the
+            // additional local read failed: once when the remote fetch is 
prepared (as non-remote
+            // partitions) and once in the error path. tp2 (remote fetch) lock 
is released once.
+            Mockito.verify(delayedShareFetch, 
times(2)).releasePartitionLocks(Set.of(tp0, tp1));
+            Mockito.verify(delayedShareFetch, 
times(1)).releasePartitionLocks(Set.of(tp2));
+        }
     }
 
     static void mockTopicIdPartitionToReturnDataEqualToMinBytes(ReplicaManager 
replicaManager, TopicIdPartition topicIdPartition, int minBytes) {
@@ -2844,6 +2896,18 @@ public class DelayedShareFetchTest {
         return CollectionConverters.asScala(logReadResults).toSeq();
     }
 
+    private LinkedHashMap<TopicIdPartition, LogReadResult> 
buildLocalAndRemoteFetchResultMap(
+        Set<TopicIdPartition> localLogReadTopicIdPartitions,
+        Set<TopicIdPartition> remoteReadTopicIdPartitions) {
+        return 
CollectionConverters.asJava(buildLocalAndRemoteFetchResult(localLogReadTopicIdPartitions,
 remoteReadTopicIdPartitions)).stream()
+            .collect(Collectors.toMap(
+                Tuple2::_1,
+                Tuple2::_2,
+                (oldValue, newValue) -> oldValue,
+                LinkedHashMap::new
+            ));
+    }
+
     @SuppressWarnings("unchecked")
     private static BiConsumer<SharePartitionKey, Throwable> 
mockExceptionHandler() {
         return mock(BiConsumer.class);

Reply via email to