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 45443a6a6f2 KAFKA-20783: Fixes for locks release for async handling in 
DelayedShareFetch (6/N) (#22881)
45443a6a6f2 is described below

commit 45443a6a6f21d6d25b5a1104f4c0d644c6d2dd6a
Author: Apoorv Mittal <[email protected]>
AuthorDate: Mon Jul 20 18:29:53 2026 +0100

    KAFKA-20783: Fixes for locks release for async handling in 
DelayedShareFetch (6/N) (#22881)
    
    Hardens partition fetch-lock release across the async/remote completion
    paths so an acquired lock is released exactly once on every success,
    timeout, and exception path.
    
    Tests: added DelayedShareFetchTest cases for each fix covering fast-path
    failure, slow-path remote transition and
    exception-during-error-handling.
    
    Reviewers: Abhinav Dixit <[email protected]>, Andrew Schofield
    <[email protected]>
---
 .../java/kafka/server/share/DelayedShareFetch.java |  63 ++-
 .../kafka/server/share/DelayedShareFetchTest.java  | 423 +++++++++++++++++++++
 2 files changed, 469 insertions(+), 17 deletions(-)

diff --git a/core/src/main/java/kafka/server/share/DelayedShareFetch.java 
b/core/src/main/java/kafka/server/share/DelayedShareFetch.java
index 9c410261a29..35483252bbb 100644
--- a/core/src/main/java/kafka/server/share/DelayedShareFetch.java
+++ b/core/src/main/java/kafka/server/share/DelayedShareFetch.java
@@ -265,11 +265,14 @@ public class DelayedShareFetch extends DelayedOperation {
         } catch (Exception e) {
             log.error("Error getting async fetch result for group {}, member 
{}",
                 shareFetch.groupId(), shareFetch.memberId(), e);
-            recordTopicPartitionsFetchRatioMetric(partitionsAcquired);
-            handleFetchException(shareFetch, partitionsAcquired.keySet(), e);
-            // No data could be fetched as the async read failed, hence pass 
an empty set of partitions
-            // with data to the action queue.
-            
releasePartitionLocksAndAddToActionQueue(partitionsAcquired.keySet(), Set.of());
+            try {
+                recordTopicPartitionsFetchRatioMetric(partitionsAcquired);
+                handleFetchException(shareFetch, partitionsAcquired.keySet(), 
e);
+            } finally {
+                // Release the locks in a finally block so that a throw from 
the metric recording or the
+                // exception handling above cannot leak the partition locks.
+                releasePartitionLocks(partitionsAcquired.keySet());
+            }
             return;
         } finally {
             // Clear the pending future regardless of completion status
@@ -336,7 +339,7 @@ public class DelayedShareFetch extends DelayedOperation {
             log.error("Error initiating async fetch during request completion 
for group {}, member {}",
                 shareFetch.groupId(), shareFetch.memberId(), e);
             handleFetchException(shareFetch, topicPartitionData.keySet(), e);
-            
releasePartitionLocksAndAddToActionQueue(topicPartitionData.keySet(), new 
HashSet<>());
+            releasePartitionLocks(topicPartitionData.keySet());
             return;
         }
         // If the future is successfully created then complete the request 
asynchronously. The completion
@@ -346,7 +349,7 @@ public class DelayedShareFetch extends DelayedOperation {
                 log.error("Async fetch failed during request completion for 
group {}, member {}",
                     shareFetch.groupId(), shareFetch.memberId(), throwable);
                 handleFetchException(shareFetch, topicPartitionData.keySet(), 
throwable);
-                
releasePartitionLocksAndAddToActionQueue(topicPartitionData.keySet(), new 
HashSet<>());
+                releasePartitionLocks(topicPartitionData.keySet());
                 return;
             }
             // Complete the share fetch request and release the locks as 
completion handler will complete
@@ -724,9 +727,17 @@ public class DelayedShareFetch extends DelayedOperation {
             // Remove pending fetch as the request is completed now.
             pendingFetch = null;
         } catch (Exception e) {
-            // Force complete to send error response to client as the pending 
future is not marked null
-            // yet hence force complete will try to retrieve the data again 
which shall fail and complete
-            // the request.
+            // On the slow path pendingFetch is set, so forceComplete() -> 
onComplete() re-enters
+            // completeShareFetchAsyncRequest(), which retrieves the data 
again (failing the same way),
+            // surfaces the error and releases the partition locks held under 
partitionsAcquired.
+            //
+            // On the fast path (invoked directly from tryComplete() when the 
read completed
+            // synchronously) set the partitionsAcquired so onComplete() can 
release the held partitions.
+            if (pendingFetch == null) {
+                log.error("Error retrieving async fetch result for group {}, 
member {}",
+                    shareFetch.groupId(), shareFetch.memberId(), e);
+                partitionsAcquired = topicPartitionData;
+            }
             return forceComplete();
         }
         // Process the successful read response - check for remote fetch, 
minBytes, etc.
@@ -757,7 +768,10 @@ public class DelayedShareFetch extends DelayedOperation {
         if (anyPartitionHasLogReadError(readResponse) || 
isMinBytesSatisfied(topicPartitionData,
             
partitionMaxBytesStrategy.maxBytes(shareFetch.fetchParams().maxBytes,
                 topicPartitionData.keySet(), topicPartitionData.size()))) {
-            // Request can be completed - set state and force completion
+            // Request can be completed - set state and force completion. 
Though on-slow path when
+            // readFuture is not syncronously completed the partitionsAcquired 
will be already set
+            // but on fast-path when readFuture is completed synchronously, we 
need to set the
+            // partitionsAcquired.
             partitionsAcquired = topicPartitionData;
             localPartitionsAlreadyFetched = readResponse;
             return forceComplete();
@@ -797,7 +811,7 @@ public class DelayedShareFetch extends DelayedOperation {
         } finally {
             // Release locks for all partitions acquired for this request. The 
async fetch did not
             // complete, so no partition has data, hence pass an empty set as 
the partitions with data.
-            
releasePartitionLocksAndAddToActionQueue(topicPartitionData.keySet(), Set.of());
+            releasePartitionLocks(topicPartitionData.keySet());
         }
     }
 
@@ -893,6 +907,15 @@ public class DelayedShareFetch extends DelayedOperation {
         // Release fetch lock for the topic partitions that were acquired but 
were not a part of remote fetch and add
         // them to the delayed actions queue.
         
releasePartitionLocksAndAddToActionQueue(nonRemoteFetchTopicPartitions, 
nonRemoteFetchTopicPartitions);
+        // Remove the just-released non-remote partitions from 
partitionsAcquired so it tracks only the
+        // remote-fetch partitions whose locks are still held. On the slow 
path partitionsAcquired was
+        // populated in tryComplete with ALL acquired partitions (remote and 
non-remote); without this
+        // removal the remote completion path would later release these 
already-released locks a second
+        // time (releaseFetchLock force-clears the lock, so this can steal a 
lock re-acquired by another
+        // request) and would skip the additional local read for these 
partitions (they would still look
+        // acquired). On the fast path partitionsAcquired only holds the 
remote partitions, so this is a
+        // no-op there.
+        nonRemoteFetchTopicPartitions.forEach(partitionsAcquired::remove);
         processRemoteFetchOrException(remoteStorageFetchInfoMap);
         // Check if remote fetch can be completed.
         return maybeCompletePendingRemoteFetch();
@@ -1142,14 +1165,14 @@ public class DelayedShareFetch extends DelayedOperation 
{
             // Release locks on error
             Set<TopicIdPartition> topicIdPartitions = new 
LinkedHashSet<>(partitionsAcquired.keySet());
             
topicIdPartitions.addAll(acquiredNonRemoteFetchTopicPartitionData.keySet());
-            releasePartitionLocksAndAddToActionQueue(topicIdPartitions, new 
HashSet<>());
+            releasePartitionLocks(topicIdPartitions);
         } catch (Exception e) {
             log.error("Unexpected error in processing delayed share fetch 
request", e);
             
handleExceptionInCompletingRemoteStorageShareFetchRequest(acquiredNonRemoteFetchTopicPartitionData.keySet(),
 e);
             // Release locks on error
             Set<TopicIdPartition> topicIdPartitions = new 
LinkedHashSet<>(partitionsAcquired.keySet());
             
topicIdPartitions.addAll(acquiredNonRemoteFetchTopicPartitionData.keySet());
-            releasePartitionLocksAndAddToActionQueue(topicIdPartitions, new 
HashSet<>());
+            releasePartitionLocks(topicIdPartitions);
         }
     }
 
@@ -1176,7 +1199,13 @@ public class DelayedShareFetch extends DelayedOperation {
             // locks are release in finally block in 
readAndProcessFetchResultCompletion hence share
             // fetch will be completed in the current try block prior locks 
getting released. Hence,
             // need to release the locks for the remote fetch partitions here.
-            
releasePartitionLocksAndAddToActionQueue(partitionsAcquired.keySet(), 
partitionsWithData(shareFetchPartitionDataList));
+            //
+            // shareFetchPartitionDataList can also contain the local 
partitions read alongside the remote
+            // fetch (added via the readAndProcessFetchResultCompletion 
callback), whose action queue
+            // entries were already handled by processFetchResultAndComplete.
+            Set<TopicIdPartition> remotePartitionsWithData = new 
HashSet<>(partitionsWithData(shareFetchPartitionDataList));
+            remotePartitionsWithData.retainAll(partitionsAcquired.keySet());
+            
releasePartitionLocksAndAddToActionQueue(partitionsAcquired.keySet(), 
remotePartitionsWithData);
         }
     }
 
@@ -1201,7 +1230,7 @@ public class DelayedShareFetch extends DelayedOperation {
             // 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());
+            releasePartitionLocks(partitionsToFetch.keySet());
             // Continue to complete with just remote data.
             completionConsumer.accept(List.of());
             return;
@@ -1217,7 +1246,7 @@ public class DelayedShareFetch extends DelayedOperation {
                 // 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());
+                releasePartitionLocks(partitionsToFetch.keySet());
                 // 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 ff2bb171dae..de997acf1e8 100644
--- a/core/src/test/java/kafka/server/share/DelayedShareFetchTest.java
+++ b/core/src/test/java/kafka/server/share/DelayedShareFetchTest.java
@@ -77,6 +77,7 @@ import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.Future;
 import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.BiConsumer;
 import java.util.function.Consumer;
 import java.util.stream.Collectors;
@@ -96,6 +97,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
+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;
@@ -953,6 +955,63 @@ public class DelayedShareFetchTest {
         Mockito.verify(exceptionHandler, times(1)).accept(any(), any());
     }
 
+    @Test
+    public void testTryCompleteReleasesLocksWhenAsyncReadImmediatelyThrows() {
+        // Reproduces the fast-path lock check: when maybeReadFromLog's future 
is already completed
+        // but exceptionally.
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        TopicIdPartition tp0 = new TopicIdPartition(Uuid.randomUuid(), new 
TopicPartition("foo", 0));
+
+        SharePartition sp0 = mock(SharePartition.class);
+        when(sp0.canAcquireRecords()).thenReturn(true);
+        // Empty fetch offset metadata forces maybeReadFromLog to actually 
read from the log.
+        when(sp0.fetchOffsetMetadata(anyLong())).thenReturn(Optional.empty());
+
+        LinkedHashMap<TopicIdPartition, SharePartition> sharePartitions = new 
LinkedHashMap<>();
+        sharePartitions.put(tp0, sp0);
+
+        CompletableFuture<Map<TopicIdPartition, 
ShareFetchResponseData.PartitionData>> future = new CompletableFuture<>();
+        ShareFetch shareFetch = new ShareFetch(FETCH_PARAMS, "grp", 
Uuid.randomUuid().toString(),
+            future, List.of(tp0), BATCH_OPTIMIZED, BATCH_SIZE, 
MAX_FETCH_RECORDS,
+            BROKER_TOPIC_STATS);
+
+        // A synchronous throw from readFromLog makes 
ReplicaManagerLogReader.readAsync return an
+        // already-completed (failed) future, so tryComplete takes the fast 
path with a completed future.
+        when(replicaManager.readFromLog(any(), any(), any(ReplicaQuota.class), 
anyBoolean()))
+            .thenThrow(new RuntimeException("Read failed"));
+
+        PartitionMaxBytesStrategy partitionMaxBytesStrategy = 
mockPartitionMaxBytes(Set.of(tp0));
+        BiConsumer<SharePartitionKey, Throwable> exceptionHandler = 
mockExceptionHandler();
+        Uuid fetchId = Uuid.randomUuid();
+        DelayedShareFetch delayedShareFetch = 
spy(DelayedShareFetchBuilder.builder()
+            .withShareFetchData(shareFetch)
+            .withSharePartitions(sharePartitions)
+            .withReplicaManagerLogReader(replicaManager)
+            .withExceptionHandler(exceptionHandler)
+            .withPartitionMaxBytesStrategy(partitionMaxBytesStrategy)
+            .withFetchId(fetchId)
+            .build());
+
+        // The first acquire (in tryComplete) succeeds; a subsequent acquire 
attempt fails, mirroring the
+        // real SharePartition where the lock is already held by this fetchId 
and cannot be re-acquired.
+        
when(sp0.maybeAcquireFetchLock(fetchId)).thenReturn(true).thenReturn(false);
+
+        assertFalse(delayedShareFetch.isCompleted());
+        // The fast-path exception handling completes the request with the 
error.
+        assertTrue(delayedShareFetch.tryComplete());
+        assertTrue(delayedShareFetch.isCompleted());
+        assertTrue(shareFetch.errorInAllPartitions());
+        assertTrue(future.isDone());
+
+        // The fetch lock must be released (exactly once).
+        Mockito.verify(sp0, times(1)).releaseFetchLock(fetchId);
+        Mockito.verify(exceptionHandler, times(1)).accept(any(), any());
+        Map<TopicIdPartition, ShareFetchResponseData.PartitionData> response = 
future.join();
+        assertEquals(Set.of(tp0), response.keySet());
+        assertEquals(Errors.UNKNOWN_SERVER_ERROR.code(), 
response.get(tp0).errorCode());
+        assertTrue(response.get(tp0).errorMessage().contains("Read failed"));
+    }
+
     @Test
     public void testTryCompleteLocksReleasedOnCompleteException() {
         ReplicaManager replicaManager = mock(ReplicaManager.class);
@@ -1488,6 +1547,87 @@ public class DelayedShareFetchTest {
         delayedShareFetch.lock().unlock();
     }
 
+    /**
+     * Validates the tryComplete() that handles an in-flight remote fetch: 
when tryComplete() is
+     * re-invoked (e.g. via a purgatory checkAndComplete) while a remote fetch 
initiated by an earlier
+     * tryComplete() is still pending, it must route to 
maybeCompletePendingRemoteFetch() rather than
+     * re-acquiring partitions or starting a new read. It stays in purgatory 
while the remote fetch is
+     * pending and completes once the remote fetch is done.
+     */
+    @Test
+    public void testTryCompleteWithPendingRemoteFetchInFlight() {
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        TopicIdPartition tp0 = new TopicIdPartition(Uuid.randomUuid(), new 
TopicPartition("foo", 0));
+        SharePartition sp0 = mock(SharePartition.class);
+        when(sp0.canAcquireRecords()).thenReturn(true);
+        when(sp0.nextFetchOffset()).thenReturn(10L);
+        when(sp0.fetchOffsetMetadata(anyLong())).thenReturn(Optional.empty());
+
+        LinkedHashMap<TopicIdPartition, SharePartition> sharePartitions = new 
LinkedHashMap<>();
+        sharePartitions.put(tp0, sp0);
+
+        CompletableFuture<Map<TopicIdPartition, 
ShareFetchResponseData.PartitionData>> future = new CompletableFuture<>();
+        ShareFetch shareFetch = new ShareFetch(FETCH_PARAMS, "grp", 
Uuid.randomUuid().toString(),
+            future, List.of(tp0), BATCH_OPTIMIZED, BATCH_SIZE, 
MAX_FETCH_RECORDS, BROKER_TOPIC_STATS);
+
+        // tp0 is served from remote storage.
+        doAnswer(invocation -> buildLocalAndRemoteFetchResult(Set.of(), 
Set.of(tp0)))
+            .when(replicaManager).readFromLog(any(), any(), 
any(ReplicaQuota.class), anyBoolean());
+
+        // Capture the remote read completion callback but do not invoke it 
yet, so the remote fetch stays
+        // in flight after the first tryComplete().
+        AtomicReference<Consumer<RemoteLogReadResult>> remoteCallback = new 
AtomicReference<>();
+        RemoteLogManager remoteLogManager = mock(RemoteLogManager.class);
+        when(remoteLogManager.asyncRead(any(), any())).thenAnswer(invocation 
-> {
+            remoteCallback.set(invocation.getArgument(1));
+            return mock(Future.class);
+        });
+        
when(replicaManager.remoteLogManager()).thenReturn(Option.apply(remoteLogManager));
+
+        Partition p0 = mock(Partition.class);
+        when(p0.isLeader()).thenReturn(true);
+        
when(replicaManager.getPartitionOrException(tp0.topicPartition())).thenReturn(p0);
+
+        Uuid fetchId = Uuid.randomUuid();
+        DelayedShareFetch delayedShareFetch = 
spy(DelayedShareFetchBuilder.builder()
+            .withShareFetchData(shareFetch)
+            .withSharePartitions(sharePartitions)
+            .withReplicaManagerLogReader(replicaManager)
+            .withPartitionMaxBytesStrategy(mockPartitionMaxBytes(Set.of(tp0)))
+            .withFetchId(fetchId)
+            .build());
+
+        when(sp0.maybeAcquireFetchLock(fetchId)).thenReturn(true);
+
+        try (MockedStatic<ShareFetchUtils> mockedShareFetchUtils = 
Mockito.mockStatic(ShareFetchUtils.class)) {
+            mockedShareFetchUtils.when(() -> 
ShareFetchUtils.processFetchResponse(any(), any(), any(), any(), any()))
+                .thenReturn(Map.of(tp0, 
mock(ShareFetchResponseData.PartitionData.class)));
+
+            // First tryComplete initiates the remote fetch which does not 
complete synchronously.
+            assertFalse(delayedShareFetch.tryComplete());
+            assertNotNull(delayedShareFetch.pendingRemoteFetches());
+            assertFalse(delayedShareFetch.isCompleted());
+
+            // Second tryComplete re-enters with a pending remote fetch in 
flight (the branch under test).
+            // The remote fetch is still not done, so it stays in purgatory 
and does not re-acquire the
+            // partition or start a new read.
+            assertFalse(delayedShareFetch.tryComplete());
+            assertFalse(delayedShareFetch.isCompleted());
+            Mockito.verify(sp0, times(1)).maybeAcquireFetchLock(fetchId);
+            Mockito.verify(sp0, times(0)).releaseFetchLock(fetchId);
+
+            // Complete the in-flight remote fetch, then a subsequent 
tryComplete completes via the same
+            // branch (maybeCompletePendingRemoteFetch now sees the remote 
fetch as done).
+            remoteCallback.get().accept(new 
RemoteLogReadResult(Optional.of(REMOTE_FETCH_INFO), Optional.empty()));
+            assertTrue(delayedShareFetch.tryComplete());
+            assertTrue(delayedShareFetch.isCompleted());
+            assertTrue(shareFetch.isCompleted());
+            assertEquals(Set.of(tp0), future.join().keySet());
+            Mockito.verify(sp0, times(1)).maybeAcquireFetchLock(fetchId);
+            Mockito.verify(sp0, times(1)).releaseFetchLock(fetchId);
+        }
+    }
+
     @SuppressWarnings("unchecked")
     @Test
     public void testRemoteStorageFetchPartitionLeaderChanged() {
@@ -2020,6 +2160,224 @@ public class DelayedShareFetchTest {
         }
     }
 
+    /**
+     * Tests that when a remote fetch completes alongside a successful 
additional local read (with data),
+     * the remote-fetch completion path does not re-enqueue the local 
partitions to the action queue.
+     * The local partitions' completion is separately triggered by 
processFetchResultAndComplete, so
+     * completeRemoteFetchRequest must only enqueue the remote (acquired) 
partitions.
+     */
+    @Test
+    public void 
testRemoteStorageFetchCompletionDoesNotDuplicateLocalPartitionsInActionQueue() {
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        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);
+
+        CompletableFuture<Map<TopicIdPartition, 
ShareFetchResponseData.PartitionData>> future = new CompletableFuture<>();
+        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);
+
+        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());
+
+        try (MockedStatic<ShareFetchUtils> mockedShareFetchUtils = 
Mockito.mockStatic(ShareFetchUtils.class)) {
+            Map<TopicIdPartition, ShareFetchResponseData.PartitionData> 
partitionDataMap = new LinkedHashMap<>();
+            partitionDataMap.put(tp0, 
mock(ShareFetchResponseData.PartitionData.class));
+            partitionDataMap.put(tp1, 
mock(ShareFetchResponseData.PartitionData.class));
+            partitionDataMap.put(tp2, 
mock(ShareFetchResponseData.PartitionData.class));
+            mockedShareFetchUtils.when(() -> 
ShareFetchUtils.processFetchResponse(any(), any(), any(), any(), 
any())).thenReturn(partitionDataMap);
+
+            // First read (tryComplete): tp0, tp1 local and tp2 remote. Second 
read (additional local read
+            // in onComplete): tp0, tp1 returned WITH data so that they appear 
in partitionsWithData.
+            doAnswer(invocation -> buildLocalAndRemoteFetchResult(Set.of(tp0, 
tp1), Set.of(tp2))
+            ).doAnswer(invocation -> buildLogReadResult(List.of(tp0, tp1))
+            ).when(replicaManager).readFromLog(any(), any(), 
any(ReplicaQuota.class), anyBoolean());
+
+            // Remote fetch object completes within tryComplete so the request 
moves on to onComplete.
+            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));
+
+            Partition p0 = mock(Partition.class);
+            when(p0.isLeader()).thenReturn(true);
+            Partition p1 = mock(Partition.class);
+            when(p1.isLeader()).thenReturn(true);
+            Partition p2 = mock(Partition.class);
+            when(p2.isLeader()).thenReturn(true);
+            
when(replicaManager.getPartitionOrException(tp0.topicPartition())).thenReturn(p0);
+            
when(replicaManager.getPartitionOrException(tp1.topicPartition())).thenReturn(p1);
+            
when(replicaManager.getPartitionOrException(tp2.topicPartition())).thenReturn(p2);
+
+            // Execute the action queue runnables synchronously so 
completeDelayedShareFetchRequest is invoked.
+            doAnswer(invocation -> {
+                Runnable runnable = invocation.getArgument(0);
+                runnable.run();
+                return null;
+            }).when(replicaManager).addToActionQueue(any());
+
+            Uuid fetchId = Uuid.randomUuid();
+            DelayedShareFetch delayedShareFetch = 
spy(DelayedShareFetchBuilder.builder()
+                .withShareFetchData(shareFetch)
+                .withReplicaManagerLogReader(replicaManager)
+                .withSharePartitions(sharePartitions)
+                
.withPartitionMaxBytesStrategy(mockPartitionMaxBytes(Set.of(tp0, tp1, tp2)))
+                .withFetchId(fetchId)
+                .build());
+
+            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());
+
+            // The local partitions tp0 and tp1 are enqueued twice: once when 
they are released as
+            // non-remote partitions while the remote fetch is prepared in 
tryComplete, and again
+            // after they are re-acquired for the additional local read in 
onComplete (via
+            // processFetchResultAndComplete). The remote-fetch completion 
(completeRemoteFetchRequest)
+            // must not enqueue them a third time - it only enqueues the 
remote (acquired) partitions.
+            Mockito.verify(replicaManager, 
times(2)).completeDelayedShareFetchRequest(
+                new DelayedShareFetchGroupKey(shareFetch.groupId(), 
tp0.topicId(), tp0.partition()));
+            Mockito.verify(replicaManager, 
times(2)).completeDelayedShareFetchRequest(
+                new DelayedShareFetchGroupKey(shareFetch.groupId(), 
tp1.topicId(), tp1.partition()));
+            // The remote partition tp2 is enqueued once, by 
completeRemoteFetchRequest.
+            Mockito.verify(replicaManager, 
times(1)).completeDelayedShareFetchRequest(
+                new DelayedShareFetchGroupKey(shareFetch.groupId(), 
tp2.topicId(), tp2.partition()));
+
+            // Validate acquire and release locks for the partitions.
+            Mockito.verify(sp0, times(2)).maybeAcquireFetchLock(fetchId);
+            Mockito.verify(sp1, times(2)).maybeAcquireFetchLock(fetchId);
+            Mockito.verify(sp2, times(1)).maybeAcquireFetchLock(fetchId);
+            Mockito.verify(sp0, times(2)).releaseFetchLock(fetchId);
+            Mockito.verify(sp1, times(2)).releaseFetchLock(fetchId);
+            Mockito.verify(sp2, times(1)).releaseFetchLock(fetchId);
+        }
+    }
+
+    /**
+     * On the slow path, tryComplete records partitionsAcquired = ALL acquired 
partitions (remote and
+     * non-remote) before the async local read completes. When the completed 
read then routes some
+     * partitions to a remote fetch, maybeProcessRemoteFetch releases the 
non-remote partitions and must
+     * also remove them from partitionsAcquired.
+     */
+    @Test
+    public void 
testAsyncReadRemoteFetchRemovesNonRemotePartitionsFromAcquiredOnRelease() {
+        String groupId = "grp";
+        LogReader logReader = mock(LogReader.class);
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        PartitionMetadataProvider metadataProvider = 
mock(PartitionMetadataProvider.class);
+        TopicIdPartition tp0 = new TopicIdPartition(Uuid.randomUuid(), new 
TopicPartition("foo", 0)); // remote
+        TopicIdPartition tp1 = new TopicIdPartition(Uuid.randomUuid(), new 
TopicPartition("foo", 1)); // non-remote
+
+        SharePartition sp0 = mock(SharePartition.class);
+        SharePartition sp1 = mock(SharePartition.class);
+        when(sp0.canAcquireRecords()).thenReturn(true);
+        when(sp1.canAcquireRecords()).thenReturn(true);
+        when(sp0.nextFetchOffset()).thenReturn(10L);
+        when(sp1.nextFetchOffset()).thenReturn(20L);
+        when(sp0.fetchOffsetMetadata(anyLong())).thenReturn(Optional.empty());
+        when(sp1.fetchOffsetMetadata(anyLong())).thenReturn(Optional.empty());
+
+        LinkedHashMap<TopicIdPartition, SharePartition> sharePartitions = new 
LinkedHashMap<>();
+        sharePartitions.put(tp0, sp0);
+        sharePartitions.put(tp1, sp1);
+
+        CompletableFuture<Map<TopicIdPartition, 
ShareFetchResponseData.PartitionData>> future = new CompletableFuture<>();
+        ShareFetch shareFetch = new ShareFetch(FETCH_PARAMS, groupId, 
Uuid.randomUuid().toString(),
+            future, List.of(tp0, tp1), BATCH_OPTIMIZED, BATCH_SIZE, 
MAX_FETCH_RECORDS, BROKER_TOPIC_STATS);
+
+        // Slow path: the first readAsync stays pending, so tryComplete 
records partitionsAcquired = {tp0, tp1}
+        // and stores the pending fetch. The second readAsync is the 
additional local read for tp1 in onComplete.
+        CompletableFuture<LinkedHashMap<TopicIdPartition, LogReadResult>> 
asyncReadFuture = new CompletableFuture<>();
+        when(logReader.readAsync(any(), any(), any(), any(), anyBoolean()))
+            .thenReturn(asyncReadFuture)
+            
.thenReturn(CompletableFuture.completedFuture(buildLogReadResultMap(List.of(tp1))));
+
+        Uuid fetchId = Uuid.randomUuid();
+        when(sp0.maybeAcquireFetchLock(fetchId)).thenReturn(true);
+        when(sp1.maybeAcquireFetchLock(fetchId)).thenReturn(true);
+
+        // Remote fetch for tp0 completes synchronously so onComplete runs 
during the second tryComplete.
+        RemoteLogReadResult remoteFetchResult = new 
RemoteLogReadResult(Optional.of(REMOTE_FETCH_INFO), Optional.empty());
+        RemoteLogManager remoteLogManager = mock(RemoteLogManager.class);
+        doAnswer(inv -> {
+            Consumer<RemoteLogReadResult> callback = inv.getArgument(1);
+            callback.accept(remoteFetchResult);
+            return CompletableFuture.completedFuture(remoteFetchResult);
+        }).when(remoteLogManager).asyncRead(any(), any());
+        
when(replicaManager.remoteLogManager()).thenReturn(Option.apply(remoteLogManager));
+
+        Partition p0 = mock(Partition.class);
+        when(p0.isLeader()).thenReturn(true);
+        
when(replicaManager.getPartitionOrException(tp0.topicPartition())).thenReturn(p0);
+
+        DelayedShareFetch delayedShareFetch = 
spy(DelayedShareFetchBuilder.builder()
+            .withShareFetchData(shareFetch)
+            .withSharePartitions(sharePartitions)
+            .withReplicaManager(replicaManager)
+            .withLogReader(logReader)
+            .withMetadataProvider(metadataProvider)
+            .withPartitionMaxBytesStrategy(mockPartitionMaxBytes(Set.of(tp0, 
tp1)))
+            .withFetchId(fetchId)
+            .build());
+
+        try (MockedStatic<ShareFetchUtils> mockedShareFetchUtils = 
Mockito.mockStatic(ShareFetchUtils.class)) {
+            Map<TopicIdPartition, ShareFetchResponseData.PartitionData> 
partitionDataMap = new LinkedHashMap<>();
+            partitionDataMap.put(tp0, 
mock(ShareFetchResponseData.PartitionData.class));
+            partitionDataMap.put(tp1, 
mock(ShareFetchResponseData.PartitionData.class));
+            mockedShareFetchUtils.when(() -> 
ShareFetchUtils.processFetchResponse(any(), any(), any(), any(), 
any())).thenReturn(partitionDataMap);
+
+            // First tryComplete: slow path, records partitionsAcquired = 
{tp0, tp1}.
+            assertFalse(delayedShareFetch.tryComplete());
+            assertNotNull(delayedShareFetch.pendingFetch());
+
+            Mockito.verify(sp0, times(1)).maybeAcquireFetchLock(fetchId);
+            Mockito.verify(sp1, times(1)).maybeAcquireFetchLock(fetchId);
+            Mockito.verify(sp0, times(0)).releaseFetchLock(fetchId);
+            Mockito.verify(sp1, times(0)).releaseFetchLock(fetchId);
+            // Complete the async local read: tp0 is tiered (remote), tp1 is a 
local partition.
+            
asyncReadFuture.complete(buildLocalAndRemoteFetchResultMap(Set.of(tp1), 
Set.of(tp0)));
+
+            // Next tryComplete transitions tp0 to a remote fetch (releasing 
tp1 as a
+            // non-remote partition and removing it from partitionsAcquired), 
and completes.
+            assertTrue(delayedShareFetch.tryComplete());
+            assertTrue(delayedShareFetch.isCompleted());
+            assertTrue(shareFetch.isCompleted());
+
+            Mockito.verify(sp0, times(1)).maybeAcquireFetchLock(fetchId);
+            // tp1 was released as a non-remote partition and removed from 
partitionsAcquired, so the remote
+            // completion re-acquires it for the additional local read (a 
second maybeAcquireFetchLock) and
+            // performs a second readAsync - rather than leaving tp1 in 
partitionsAcquired.
+            Mockito.verify(sp1, times(2)).maybeAcquireFetchLock(fetchId);
+            Mockito.verify(sp0, times(1)).releaseFetchLock(fetchId);
+            Mockito.verify(sp1, times(2)).releaseFetchLock(fetchId);
+            Mockito.verify(logReader, times(2)).readAsync(any(), any(), any(), 
any(), anyBoolean());
+        }
+    }
+
     @Test
     public void testRemoteStorageFetchHappensForAllTopicPartitions() {
         ReplicaManager replicaManager = mock(ReplicaManager.class);
@@ -2505,6 +2863,71 @@ public class DelayedShareFetchTest {
         Mockito.verify(sp0).releaseFetchLock(fetchId);
     }
 
+    /**
+     * Tests that when the async read fails and the error handling in 
completeShareFetchAsyncRequest
+     * itself throws (here the exception handler callback), the partition 
locks are still released
+     * This guards the try-finally around the metric recording and exception 
handling in the get()-failure
+     * path.
+     */
+    @Test
+    public void 
testCompleteShareFetchAsyncRequestReleasesLocksWhenExceptionHandlingThrows() {
+        String groupId = "grp";
+        Uuid topicId = Uuid.randomUuid();
+        LogReader logReader = mock(LogReader.class);
+        PartitionMetadataProvider metadataProvider = 
mock(PartitionMetadataProvider.class);
+        ReplicaManager replicaManager = mock(ReplicaManager.class);
+        TopicIdPartition tp0 = new TopicIdPartition(topicId, new 
TopicPartition("foo", 0));
+
+        SharePartition sp0 = mock(SharePartition.class);
+        LinkedHashMap<TopicIdPartition, SharePartition> sharePartitions = new 
LinkedHashMap<>();
+        sharePartitions.put(tp0, sp0);
+
+        CompletableFuture<Map<TopicIdPartition, 
ShareFetchResponseData.PartitionData>> future = new CompletableFuture<>();
+        ShareFetch shareFetch = new ShareFetch(FETCH_PARAMS, groupId, 
Uuid.randomUuid().toString(),
+            future, List.of(tp0), BATCH_OPTIMIZED, BATCH_SIZE, 
MAX_FETCH_RECORDS, BROKER_TOPIC_STATS);
+
+        CompletableFuture<LinkedHashMap<TopicIdPartition, LogReadResult>> 
asyncReadFuture = new CompletableFuture<>();
+        when(logReader.readAsync(any(), any(), any(), any(), 
anyBoolean())).thenReturn(asyncReadFuture);
+
+        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));
+
+        PartitionMaxBytesStrategy partitionMaxBytesStrategy = 
mockPartitionMaxBytes(Set.of(tp0));
+
+        // The exception handler throws while handling the async read failure, 
simulating a failure
+        // inside the catch block's error handling. Locks must still be 
released via the finally block.
+        BiConsumer<SharePartitionKey, Throwable> exceptionHandler = 
mockExceptionHandler();
+        doThrow(new RuntimeException("handler 
boom")).when(exceptionHandler).accept(any(), any());
+
+        Uuid fetchId = Uuid.randomUuid();
+        DelayedShareFetch delayedShareFetch = 
DelayedShareFetchBuilder.builder()
+            .withShareFetchData(shareFetch)
+            .withReplicaManager(replicaManager)
+            .withLogReader(logReader)
+            .withMetadataProvider(metadataProvider)
+            .withPartitionMaxBytesStrategy(partitionMaxBytesStrategy)
+            .withSharePartitions(sharePartitions)
+            .withExceptionHandler(exceptionHandler)
+            .withFetchId(fetchId)
+            .build();
+
+        // First tryComplete triggers the async read, which stays pending 
(slow path).
+        assertFalse(delayedShareFetch.tryComplete());
+        assertNotNull(delayedShareFetch.pendingFetch());
+        // Complete the async read exceptionally.
+        asyncReadFuture.completeExceptionally(new RuntimeException("Simulated 
read failure"));
+
+        // Purgatory re-invokes tryComplete; get() throws, and the error 
handling (exception handler)
+        // then throws too. The finally block must still release the partition 
locks before the throw propagates.
+        assertThrows(RuntimeException.class, delayedShareFetch::tryComplete);
+        Mockito.verify(sp0).releaseFetchLock(fetchId);
+    }
+
     /**
      * Tests that onComplete handles the case where async fetch is still 
pending.
      * This simulates timeout scenario where async read hasn't completed yet.


Reply via email to