This is an automated email from the ASF dual-hosted git repository.

clintropolis pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new 9e17b1c48a1 fix: cache entries use self hold to avoid eviction while 
mounting, and other fixes (#20182)
9e17b1c48a1 is described below

commit 9e17b1c48a11ffee625eea19e2fe88c483352713
Author: Clint Wylie <[email protected]>
AuthorDate: Fri Aug 28 15:03:01 2026 -0700

    fix: cache entries use self hold to avoid eviction while mounting, and 
other fixes (#20182)
---
 .../segment/file/PartialSegmentFileMapperV10.java  | 194 +++++++++++++-------
 .../file/PartialSegmentFileMapperV10Test.java      |  56 ++++++
 .../loading/PartialSegmentBundleCacheEntry.java    |  98 +++++++---
 .../loading/PartialSegmentMetadataCacheEntry.java  |  15 +-
 .../segment/loading/SegmentLocalCacheManager.java  |  25 ++-
 .../PartialSegmentBundleCacheEntryTest.java        | 149 +++++++++++++++
 .../PartialSegmentMetadataCacheEntryTest.java      |  99 ++++++++++
 .../SegmentLocalCacheManagerConcurrencyTest.java   | 199 ++++++++++++---------
 ...SegmentLocalCacheManagerPartialAcquireTest.java | 136 ++++++++++++++
 9 files changed, 791 insertions(+), 180 deletions(-)

diff --git 
a/processing/src/main/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10.java
 
b/processing/src/main/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10.java
index 83e373080e6..5f2af4699e8 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10.java
@@ -312,6 +312,11 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
   private final ReentrantLock[] containerLocks;
   // per-container eviction generation; see getBundleGeneration
   private final AtomicLongArray containerGenerations;
+  /**
+   * Number of fetches currently writing into each container, and whether an 
eviction is waiting on them.
+   */
+  private final int[] containerFetchesInFlight;
+  private final boolean[] containerEvictionPending;
 
   // bundle name -> indices (into metadata.getContainers()) of this single 
mapper's containers in that bundle.
   // Computed once at construction from the immutable container metadata. 
Single-mapper scope only: stitching bundles
@@ -372,6 +377,8 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
     this.containerFiles = new File[numContainers];
     this.containerLocks = new ReentrantLock[numContainers];
     this.containerGenerations = new AtomicLongArray(numContainers);
+    this.containerFetchesInFlight = new int[numContainers];
+    this.containerEvictionPending = new boolean[numContainers];
     final Map<String, List<Integer>> bundleIndices = new HashMap<>();
     for (int i = 0; i < numContainers; i++) {
       this.containerLocks[i] = new ReentrantLock();
@@ -531,12 +538,24 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
    * read (its files tile back-to-back), and a partially-downloaded container 
skips its already-resident spans.
    */
   public void ensureAllDownloaded() throws IOException
+  {
+    fetchAllContainers();
+    if (!isFullyDownloaded()) {
+      throw DruidException.defensive(
+          "Failed to download every file of [%s]; residency was cleared 
mid-download, which means a container was "
+          + "evicted while a fetch was writing into it",
+          targetFilename
+      );
+    }
+  }
+
+  private void fetchAllContainers() throws IOException
   {
     for (int containerIndex = 0; containerIndex < containers.length; 
containerIndex++) {
       fetchFiles(containerFileNames.get(containerIndex));
     }
     for (PartialSegmentFileMapperV10 external : externalMappers.values()) {
-      external.ensureAllDownloaded();
+      external.fetchAllContainers();
     }
   }
 
@@ -823,40 +842,46 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
       }
       checkClosed();
 
-      int from = 0;
-      int to = runFiles.size();
-      while (from < to && downloadedFiles.contains(runFiles.get(from))) {
-        from++;
-      }
-      while (to > from && downloadedFiles.contains(runFiles.get(to - 1))) {
-        to--;
-      }
-      if (from == to) {
-        // the whole run became resident while we were waiting on the locks
-        return;
-      }
-      final List<String> remaining = runFiles.subList(from, to);
-      final SegmentInternalFileMetadata first = 
metadata.getFiles().get(remaining.get(0));
-      final long startOffset = first.getStartOffset();
-      // scan for the span end rather than assuming the last file ends last: 
shrinking the read below any covered
-      // file's end would mark that file downloaded without its bytes on disk 
(zero-length files share start offsets)
-      long endOffset = startOffset;
-      for (String name : remaining) {
-        final SegmentInternalFileMetadata fileMeta = 
metadata.getFiles().get(name);
-        endOffset = Math.max(endOffset, fileMeta.getStartOffset() + 
fileMeta.getSize());
+      beginContainerFetch(containerIndex);
+      try {
+        int from = 0;
+        int to = runFiles.size();
+        while (from < to && downloadedFiles.contains(runFiles.get(from))) {
+          from++;
+        }
+        while (to > from && downloadedFiles.contains(runFiles.get(to - 1))) {
+          to--;
+        }
+        if (from == to) {
+          // the whole run became resident while we were waiting on the locks
+          return;
+        }
+        final List<String> remaining = runFiles.subList(from, to);
+        final SegmentInternalFileMetadata first = 
metadata.getFiles().get(remaining.get(0));
+        final long startOffset = first.getStartOffset();
+        // scan for the span end rather than assuming the last file ends last: 
shrinking the read below any covered
+        // file's end would mark that file downloaded without its bytes on 
disk (zero-length files share start offsets)
+        long endOffset = startOffset;
+        for (String name : remaining) {
+          final SegmentInternalFileMetadata fileMeta = 
metadata.getFiles().get(name);
+          endOffset = Math.max(endOffset, fileMeta.getStartOffset() + 
fileMeta.getSize());
+        }
+        final long length = endOffset - startOffset;
+
+        ensureContainerInitialized(containerIndex);
+        streamRangeIntoContainer(
+            containerIndex,
+            computeAbsoluteOffset(first),
+            startOffset,
+            length,
+            StringUtils.format("files[%d] in container[%d]", remaining.size(), 
containerIndex)
+        );
+        for (String name : remaining) {
+          markDownloaded(name, metadata.getFiles().get(name).getSize());
+        }
       }
-      final long length = endOffset - startOffset;
-
-      ensureContainerInitialized(containerIndex);
-      streamRangeIntoContainer(
-          containerIndex,
-          computeAbsoluteOffset(first),
-          startOffset,
-          length,
-          StringUtils.format("files[%d] in container[%d]", remaining.size(), 
containerIndex)
-      );
-      for (String name : remaining) {
-        markDownloaded(name, metadata.getFiles().get(name).getSize());
+      finally {
+        endContainerFetch(containerIndex);
       }
     }
     finally {
@@ -964,22 +989,63 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
     ensureContainerInitialized(containerIndex);
   }
 
+  /**
+   * Register that a fetch is about to write into this container, so a 
concurrent {@link #evictContainer} defers
+   * rather than deleting the file out from under it. Paired with {@link 
#endContainerFetch} in a finally.
+   */
+  private void beginContainerFetch(int containerIndex)
+  {
+    containerLocks[containerIndex].lock();
+    try {
+      containerFetchesInFlight[containerIndex]++;
+    }
+    finally {
+      containerLocks[containerIndex].unlock();
+    }
+  }
+
+  /**
+   * Drop this fetch's claim on the container and, if it was the last one and 
an eviction was deferred while it ran,
+   * carry that eviction out now.
+   */
+  private void endContainerFetch(int containerIndex)
+  {
+    boolean evictNow = false;
+    containerLocks[containerIndex].lock();
+    try {
+      containerFetchesInFlight[containerIndex]--;
+      if (containerFetchesInFlight[containerIndex] == 0 && 
containerEvictionPending[containerIndex]) {
+        containerEvictionPending[containerIndex] = false;
+        evictNow = true;
+      }
+    }
+    finally {
+      containerLocks[containerIndex].unlock();
+    }
+    if (evictNow) {
+      // Runs from fetchRun's finally, so it must not throw over whatever 
brought us here.
+      try {
+        evictContainer(containerIndex);
+      }
+      catch (Throwable t) {
+        LOG.warn(t, "Failed to run deferred eviction of container[%d] for 
[%s]", containerIndex, targetFilename);
+      }
+    }
+  }
+
   /**
    * Reverse of {@link #initializeContainer(int)}: unmap the in-memory view of 
the container, delete the local
    * container file, and clear the bitmap bits + {@link #downloadedFiles} 
entries for every internal file that lived
    * in this container.
    * <p>
-   * Used by per-bundle cache entries on unmount/eviction to release the disk 
and memory footprint of one bundle
-   * without affecting other bundles sharing the same {@link 
PartialSegmentFileMapperV10}. After eviction, the
-   * container's files are non-resident again: {@link #mapFile} throws for 
them until a subsequent fetch re-downloads
-   * them (re-initializing the container and repopulating the bitmap 
incrementally).
-   * <p>
-   * <b>Concurrency contract.</b> The caller is responsible for ensuring no 
concurrent {@link #mapFile} (or
-   * {@link #fetchFiles}/{@link #fetchRun}) call is in flight for any file in 
this container. This is enforced one layer up
-   * by the cache-entry refcount: {@code PartialSegmentBundleCacheEntry} only 
invokes {@code evictContainer} from its
-   * {@code doActualUnmount} callback, which fires only after every reference 
acquired via {@code acquireReference()}
-   * has been closed. Bypassing that gate is dangerous, {@link 
ByteBufferUtils#unmap} frees the off-heap mapping, so a
-   * {@link ByteBuffer#slice} from a concurrent reader is a JVM SIGSEGV, not a 
recoverable error.
+   * <b>Concurrency contract.</b> No concurrent {@link #mapFile} may be in 
flight for any file in this container;
+   * that is enforced one layer up by the cache-entry refcount, since {@code 
PartialSegmentBundleCacheEntry} evicts
+   * from its {@code doActualUnmount} callback, which fires only after every 
reference acquired via
+   * {@code acquireReference()} has been closed. Bypassing that gate is 
dangerous: {@link ByteBufferUtils#unmap} frees
+   * the off-heap mapping, so a {@link ByteBuffer#slice} from a concurrent 
reader is a JVM SIGSEGV, not a recoverable
+   * error. An in-flight {@link #fetchFiles}/{@link #fetchRun} is handled here 
instead of by the caller: the eviction
+   * is deferred to whichever fetch finishes last, because callers can hold 
the storage location's write lock and must
+   * not block on a deep-storage read.
    * <p>
    * No-op if the container has not been initialized.
    */
@@ -988,6 +1054,12 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
     checkClosed();
     containerLocks[containerIndex].lock();
     try {
+      if (containerFetchesInFlight[containerIndex] > 0) {
+        // A fetch is writing into this container. Deleting the file now would 
leave it writing to a null File, so
+        // hand the eviction to whichever fetch finishes last rather than 
blocking here.
+        containerEvictionPending[containerIndex] = true;
+        return;
+      }
       final MappedByteBuffer existing = containers[containerIndex];
       if (existing != null) {
         ByteBufferUtils.unmap(existing);
@@ -1012,27 +1084,25 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
         );
       }
       containerFiles[containerIndex] = null;
+
+      // clear bitmap bits + downloadedFiles entries for files that lived in 
this container.
+      for (Map.Entry<String, SegmentInternalFileMetadata> entry : 
metadata.getFiles().entrySet()) {
+        if (entry.getValue().getContainer() != containerIndex) {
+          continue;
+        }
+        final String fileName = entry.getKey();
+        if (downloadedFiles.remove(fileName)) {
+          downloadedBytes.addAndGet(-entry.getValue().getSize());
+        }
+        clearBitmapBit(fileName);
+      }
+
+      // last: readers that observe the bumped generation must also observe 
the cleared residency above
+      containerGenerations.incrementAndGet(containerIndex);
     }
     finally {
       containerLocks[containerIndex].unlock();
     }
-
-    // clear bitmap bits + downloadedFiles entries for files that lived in 
this container. Iterates
-    // metadata.getFiles() without external synchronization: 
SegmentFileMetadata is constructed once at mapper
-    // creation and its file map is effectively immutable for the mapper's 
lifetime, so concurrent iteration is safe.
-    for (Map.Entry<String, SegmentInternalFileMetadata> entry : 
metadata.getFiles().entrySet()) {
-      if (entry.getValue().getContainer() != containerIndex) {
-        continue;
-      }
-      final String fileName = entry.getKey();
-      if (downloadedFiles.remove(fileName)) {
-        downloadedBytes.addAndGet(-entry.getValue().getSize());
-      }
-      clearBitmapBit(fileName);
-    }
-
-    // last: readers that observe the bumped generation must also observe the 
cleared residency above
-    containerGenerations.incrementAndGet(containerIndex);
   }
 
   /**
diff --git 
a/processing/src/test/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10Test.java
 
b/processing/src/test/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10Test.java
index 6060d887aac..b3d4ba182b7 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10Test.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/file/PartialSegmentFileMapperV10Test.java
@@ -1317,6 +1317,62 @@ class PartialSegmentFileMapperV10Test
     return new File(baseDir, IndexIO.V10_FILE_NAME);
   }
 
+  @Test
+  void testEvictContainerDefersWhileAFetchIsWritingIntoIt() throws Exception
+  {
+    final File segmentFile = buildTestSegment(20, CompressionStrategy.NONE);
+    final File cacheDir = newCacheDir("evict-defer");
+    final CountDownLatch fetching = new CountDownLatch(1);
+    final CountDownLatch release = new CountDownLatch(1);
+    final AtomicBoolean armed = new AtomicBoolean(false);
+    final AtomicBoolean gated = new AtomicBoolean(false);
+    final SegmentRangeReader delegate = new 
CountingRangeReader(segmentFile.getParentFile());
+    // park the first read once armed, so a fetch is provably mid-flight when 
the eviction arrives. Armed only after
+    // the mapper exists, since create() does its own header read on this 
thread.
+    final SegmentRangeReader gatedReader = (filename, offset, length) -> {
+      if (armed.get() && gated.compareAndSet(false, true)) {
+        fetching.countDown();
+        try {
+          Assertions.assertTrue(release.await(30, TimeUnit.SECONDS));
+        }
+        catch (InterruptedException e) {
+          Thread.currentThread().interrupt();
+          throw new IOException(e);
+        }
+      }
+      return delegate.readRange(filename, offset, length);
+    };
+
+    final ExecutorService exec = Execs.multiThreaded(1, "evict-defer-test-%d");
+    try (PartialSegmentFileMapperV10 mapper = createMapper(gatedReader, 
cacheDir)) {
+      final String fileName = 
mapper.getSegmentFileMetadata().getFiles().keySet().iterator().next();
+      final int containerIndex = 
mapper.getSegmentFileMetadata().getFiles().get(fileName).getContainer();
+      armed.set(true);
+
+      final Future<?> fetch = exec.submit(() -> {
+        mapper.fetchFiles(List.of(fileName));
+        return null;
+      });
+      Assertions.assertTrue(fetching.await(30, TimeUnit.SECONDS), "fetch must 
reach the gated read");
+
+      // Evicting now would delete the file the fetch is streaming into. It 
must defer instead of tearing it down.
+      mapper.evictContainer(containerIndex);
+
+      release.countDown();
+      fetch.get(30, TimeUnit.SECONDS);
+
+      // The fetch completed, and the deferred eviction then ran: the file is 
no longer resident.
+      Assertions.assertFalse(
+          mapper.getDownloadedFiles().contains(fileName),
+          "the deferred eviction should have run once the fetch finished"
+      );
+    }
+    finally {
+      release.countDown();
+      exec.shutdownNow();
+    }
+  }
+
   private static PartialSegmentFileMapperV10 createMapper(
       SegmentRangeReader rangeReader,
       File localCacheDir
diff --git 
a/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentBundleCacheEntry.java
 
b/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentBundleCacheEntry.java
index 00d7f90170d..ccf2acab3f9 100644
--- 
a/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentBundleCacheEntry.java
+++ 
b/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentBundleCacheEntry.java
@@ -281,7 +281,12 @@ public class PartialSegmentBundleCacheEntry implements 
CacheEntry
       if (!mountFuture.compareAndSet(null, ours)) {
         continue;
       }
-      try {
+      // Hold this entry against reclaim while the mount establishes state; 
reclaim passes over held entries only,
+      // and an entry evicted mid-mount finishes into a location that no 
longer knows about it. Internal, since a
+      // mount is not somebody waiting on the bundle, and null when the entry 
is already gone - nothing to protect.
+      final StorageLocation.ReservationHold<PartialSegmentBundleCacheEntry> 
selfHold =
+          mountLocation.addInternalWeakReservationHoldIfExists(id);
+      try (selfHold) {
         doMount(mountLocation);
         ours.set(null);
       }
@@ -289,6 +294,10 @@ public class PartialSegmentBundleCacheEntry implements 
CacheEntry
         // clear the gate so the next caller gets a fresh attempt
         mountFuture.set(null);
         ours.setException(t);
+        // If it is already gone, no unmount() will follow to sweep them up, 
so reap them here.
+        if (!isStillRegistered(mountLocation)) {
+          reapContainersOfUncommittedMount();
+        }
         switch (t) {
           case IOException ioException -> throw ioException;
           case RuntimeException runtimeException -> throw runtimeException;
@@ -311,7 +320,7 @@ public class PartialSegmentBundleCacheEntry implements 
CacheEntry
    */
   private void verifyStillReservedOrRollback(StorageLocation mountLocation)
   {
-    if (!mountLocation.isReserved(id) && !mountLocation.isWeakReserved(id)) {
+    if (!isStillRegistered(mountLocation)) {
       LOG.debug(
           "Aborting mount of bundle[%s] in location[%s]; entry was evicted 
while mounting",
           id,
@@ -321,6 +330,65 @@ public class PartialSegmentBundleCacheEntry implements 
CacheEntry
     }
   }
 
+  /**
+   * Whether the cache still knows about this entry.
+   */
+  private boolean isStillRegistered(StorageLocation mountLocation)
+  {
+    return mountLocation.isReserved(id) || mountLocation.isWeakReserved(id);
+  }
+
+  /**
+   * Evict containers left behind by a mount that never committed. A failed 
mount deliberately keeps the containers it
+   * had already initialized so a retry can reuse them (see {@link #doMount}); 
this entry being torn down is that retry
+   * never coming, and nothing else will clean them up since {@link 
#doActualUnmount} runs only for a mount that
+   * committed.
+   */
+  private void reapContainersOfUncommittedMount()
+  {
+    final SettableFuture<Void> ours = SettableFuture.create();
+    if (!mountFuture.compareAndSet(null, ours)) {
+      // something else is mounting, bail out
+      return;
+    }
+    try {
+      // Null once the metadata entry has unmounted, which closes the file 
mapper; evictContainer would throw on it.
+      final PartialSegmentFileMapperV10 fileMapper = 
metadataEntry.getFileMapper();
+      if (fileMapper != null) {
+        evictOwnedContainers(fileMapper);
+      }
+    }
+    finally {
+      // Clear the gate before completing it, so a mount that joined while we 
held it finds a fresh gate to claim
+      // rather than this one. It fails instead of believing a mount happened, 
which is accurate: it asked to mount an
+      // entry that was being torn down.
+      mountFuture.set(null);
+      ours.setException(DruidException.defensive("Bundle[%s] was torn down 
while mounting", id));
+    }
+  }
+
+  /**
+   * Evict every container this bundle owns, routing each ref to the mapper 
that owns it. Best-effort: a container that
+   * fails to evict is logged rather than aborting the rest, since every 
caller is already tearing down.
+   */
+  private void evictOwnedContainers(PartialSegmentFileMapperV10 fileMapper)
+  {
+    for (BundleContainerRef ref : containerRefs) {
+      try {
+        
fileMapper.mapperForContainer(ref.externalFilename()).evictContainer(ref.containerIndex());
+      }
+      catch (Throwable t) {
+        LOG.warn(
+            t,
+            "Failed to evict container[%s/%d] for bundle[%s]",
+            ref.externalFilename(),
+            ref.containerIndex(),
+            id
+        );
+      }
+    }
+  }
+
   private void doMount(StorageLocation mountLocation) throws IOException
   {
     // Pre-check inside entryLock; after this we release entryLock so the 
hold-acquisition + container-init work below
@@ -432,19 +500,6 @@ public class PartialSegmentBundleCacheEntry implements 
CacheEntry
     }
     finally {
       if (!committed) {
-        // Evict any containers that were successfully initialized before the 
failure. Mirrors the eager
-        // SegmentCacheEntry behavior: retry from a clean slate is simpler 
than reasoning about partial on-disk state.
-        // evictContainer is a no-op for containers that were never 
initialized, so we can iterate the full set
-        // without tracking how far the initialization loop got.
-        for (BundleContainerRef ref : containerRefs) {
-          try {
-            
fileMapper.mapperForContainer(ref.externalFilename()).evictContainer(ref.containerIndex());
-          }
-          catch (Throwable t) {
-            LOG.warn(t, "Failed to evict container[%s/%d] for bundle[%s] 
during mount rollback",
-                     ref.externalFilename(), ref.containerIndex(), id);
-          }
-        }
         if (registered) {
           try {
             metadataEntry.unregisterBundle(this);
@@ -485,7 +540,11 @@ public class PartialSegmentBundleCacheEntry implements 
CacheEntry
     final ReferenceCountingCloseableObject<Closeable> current = 
references.get();
     if (current != null && !current.isClosed()) {
       current.close();
+      return;
     }
+    // Nothing mounted to tear down, so doActualUnmount will not run for this 
entry; sweep up after any mount that
+    // failed partway and left containers behind.
+    reapContainersOfUncommittedMount();
   }
 
   /**
@@ -534,14 +593,7 @@ public class PartialSegmentBundleCacheEntry implements 
CacheEntry
       final PartialSegmentFileMapperV10 fileMapper = 
metadataEntry.getFileMapper();
       // file mapper may be null if metadata was already unmounted 
(out-of-order shutdown); evictContainer would NPE
       if (fileMapper != null) {
-        for (BundleContainerRef ref : containerRefs) {
-          try {
-            
fileMapper.mapperForContainer(ref.externalFilename()).evictContainer(ref.containerIndex());
-          }
-          catch (Throwable t) {
-            LOG.warn(t, "Failed to evict container[%s/%d] for bundle[%s]", 
ref.externalFilename(), ref.containerIndex(), id);
-          }
-        }
+        evictOwnedContainers(fileMapper);
       }
       refsToRelease = new ArrayList<>(dependencyReferences);
       dependencyReferences.clear();
diff --git 
a/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntry.java
 
b/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntry.java
index 1e11d950fe1..96dde6cba21 100644
--- 
a/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntry.java
+++ 
b/server/src/main/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntry.java
@@ -757,6 +757,13 @@ public class PartialSegmentMetadataCacheEntry implements 
SegmentCacheEntry, Resi
     // blocked on it. adjustReservation also runs outside entryLock: 
StorageLocation.release goes
     // writeLock -> entryLock (via release -> unmount), so entryLock -> 
writeLock here would be a deadlock-prone
     // lock-order inversion.
+    // Hold this entry against reclaim for as long as the mount is 
establishing state. reclaim passes over held
+    // entries only, and an entry evicted mid-mount finishes into a location 
that no longer knows about it: the
+    // post-mount check then rolls the whole thing back, or worse commits it 
if a fresh entry has taken the id in the
+    // meantime, leaving a mapper nothing will ever unmount. Released before 
the cleanup in the catch below, which only
+    // acts on an entry no one holds.
+    final StorageLocation.ReservationHold<SegmentCacheEntry> selfHold =
+        mountLocation.addInternalWeakReservationHoldIfExists(id);
     try {
       entryLock.lock();
       try {
@@ -870,8 +877,9 @@ public class PartialSegmentMetadataCacheEntry implements 
SegmentCacheEntry, Resi
     catch (Throwable t) {
       // Reclaim the reservation of an entry that is still registered here but 
no longer held, which the rollbacks
       // above have just left with a closed mapper and no header on disk. 
No-op if anything holds this (including
-      // bundle entries). Runs outside entryLock (the inner blocks released 
it) so the writeLock -> entryLock order
-      // inside removeUnheldWeakEntry is respected.
+      // bundle entries), so the mount's own hold has to go first. Runs 
outside entryLock (the inner blocks released
+      // it) so the writeLock -> entryLock order inside removeUnheldWeakEntry 
is respected.
+      CloseableUtils.closeAndSuppressExceptions(selfHold, e -> LOG.warn(e, 
"Failed to release mount hold[%s]", id));
       try {
         mountLocation.removeUnheldWeakEntry(id);
       }
@@ -880,6 +888,9 @@ public class PartialSegmentMetadataCacheEntry implements 
SegmentCacheEntry, Resi
       }
       throw t;
     }
+    finally {
+      CloseableUtils.closeAndSuppressExceptions(selfHold, e -> LOG.warn(e, 
"Failed to release mount hold[%s]", id));
+    }
   }
 
   /**
diff --git 
a/server/src/main/java/org/apache/druid/segment/loading/SegmentLocalCacheManager.java
 
b/server/src/main/java/org/apache/druid/segment/loading/SegmentLocalCacheManager.java
index f441515e1e6..91fd583d4db 100644
--- 
a/server/src/main/java/org/apache/druid/segment/loading/SegmentLocalCacheManager.java
+++ 
b/server/src/main/java/org/apache/druid/segment/loading/SegmentLocalCacheManager.java
@@ -690,11 +690,20 @@ public class SegmentLocalCacheManager implements 
SegmentCacheManager
                   if (fullDownload) {
                     // Delta of internal-file bytes downloaded by this task
                     final long downloadedBefore = mapper.getDownloadedBytes();
-                    // Mount every bundle so the containers it owns are 
reserved on the location
-                    for (String bundleName : 
PartialSegmentBundleCacheEntry.bundleNames(mapper)) {
-                      
holdHolder.add(reserved.metadata.getBundleAcquirer().acquire(bundleName));
+                    // Mount every bundle so the containers it owns are 
reserved on the location, and keep those
+                    // references here for the duration of the download 
instead of handing them straight to
+                    // holdHolder so that the caller abandoning a load doesn't 
release the holds until load is
+                    // finished.
+                    final List<Closeable> bundleRefs = new ArrayList<>();
+                    try {
+                      for (String bundleName : 
PartialSegmentBundleCacheEntry.bundleNames(mapper)) {
+                        
bundleRefs.add(reserved.metadata.getBundleAcquirer().acquire(bundleName));
+                      }
+                      mapper.ensureAllDownloaded();
+                    }
+                    finally {
+                      bundleRefs.forEach(holdHolder::add);
                     }
-                    mapper.ensureAllDownloaded();
                     loadSizeBytes = mapper.getDownloadedBytes() - 
downloadedBefore;
                   } else {
                     // Lazy mount: the header bytes when this task caused the 
mount; 0 when the entry was already
@@ -2162,6 +2171,11 @@ public class SegmentLocalCacheManager implements 
SegmentCacheManager
         return;
       }
 
+      // Hold this entry against reclaim for the duration of the mount, 
including the post-mount reservation check
+      // below. reclaim passes over held entries only, and evicting one 
mid-mount unmounts a storage directory this
+      // mount is filling, or when the directory was already resident, one it 
is about to serve.
+      final StorageLocation.ReservationHold<SegmentCacheEntry> selfHold =
+          mountLocation.addInternalWeakReservationHoldIfExists(this.id);
       try {
         entryLock.lock();
         try {
@@ -2262,6 +2276,9 @@ public class SegmentLocalCacheManager implements 
SegmentCacheManager
         unmount();
         throw t;
       }
+      finally {
+        CloseableUtils.closeAndSuppressExceptions(selfHold, e -> log.warn(e, 
"Failed to release mount hold[%s]", id));
+      }
     }
 
     @Override
diff --git 
a/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentBundleCacheEntryTest.java
 
b/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentBundleCacheEntryTest.java
index 27974a919dc..3d08c548cc8 100644
--- 
a/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentBundleCacheEntryTest.java
+++ 
b/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentBundleCacheEntryTest.java
@@ -30,6 +30,7 @@ import org.apache.druid.data.input.impl.LongDimensionSchema;
 import org.apache.druid.data.input.impl.StringDimensionSchema;
 import org.apache.druid.error.DruidException;
 import org.apache.druid.java.util.common.DateTimes;
+import org.apache.druid.java.util.common.FileUtils;
 import org.apache.druid.java.util.common.Intervals;
 import org.apache.druid.java.util.common.StringUtils;
 import org.apache.druid.java.util.common.concurrent.Execs;
@@ -75,6 +76,8 @@ class PartialSegmentBundleCacheEntryTest
   private static final ObjectMapper JSON_MAPPER = TestHelper.makeJsonMapper();
   private static final SegmentId SEGMENT_ID = SegmentId.of("test", 
Intervals.of("2025/2026"), "v1", 0);
   private static final String AGG_BUNDLE = "dim1_metric1_sum";
+  private static final String TWO_CONTAINER_BUNDLE = "two_container";
+  private static final String TWO_CONTAINER_EXTERNAL = 
"two_container_ext.segment";
   private static final long ESTIMATE = 16 * 1024 * 1024L;
 
   private static final DateTime TIME = DateTimes.of("2025-01-01");
@@ -745,6 +748,152 @@ class PartialSegmentBundleCacheEntryTest
     );
   }
 
+  @Test
+  void testFailedMountKeepsInitializedContainersForRetry() throws IOException
+  {
+    final StorageLocation location = new StorageLocation(cacheDir, ESTIMATE * 
8, null);
+    final PartialSegmentMetadataCacheEntry metadata = 
newTwoContainerBundleMetadata();
+    Assertions.assertTrue(location.reserve(metadata));
+    metadata.mount(location);
+
+    final PartialSegmentBundleCacheEntry bundle =
+        PartialSegmentBundleCacheEntry.forBundle(metadata, 
TWO_CONTAINER_BUNDLE, List.of());
+    Assertions.assertNotNull(location.addWeakReservationHold(bundle.getId(), 
() -> bundle));
+
+    final List<PartialSegmentBundleCacheEntry.BundleContainerRef> refs = 
bundle.getContainerRefs();
+    Assertions.assertEquals(2, refs.size());
+    final File firstContainerFile = containerFileFor(refs.getFirst());
+    final File blocked = containerFileFor(refs.getLast());
+    // Fail the container-initialization loop on its last container by parking 
a directory where that container's file
+    // needs to go, so RandomAccessFile(..., "rw") can't open it. The one 
before it initializes normally.
+    FileUtils.mkdirp(blocked);
+
+    Assertions.assertThrows(IOException.class, () -> bundle.mount(location));
+    Assertions.assertFalse(bundle.isMounted());
+
+    // The entry is still in the cache, so the container that did initialize 
stays put for a retry to reuse rather
+    // than being torn down and re-created.
+    Assertions.assertTrue(
+        firstContainerFile.exists(),
+        "a failed mount should leave already-initialized containers in place"
+    );
+
+    // ...and the retry succeeds against that reused state once the failure 
clears.
+    Assertions.assertTrue(blocked.delete());
+    bundle.mount(location);
+    Assertions.assertTrue(bundle.isMounted());
+    for (PartialSegmentBundleCacheEntry.BundleContainerRef ref : refs) {
+      Assertions.assertTrue(containerFileFor(ref).exists(), "container " + ref 
+ " should be allocated after retry");
+    }
+  }
+
+  @Test
+  void testFailedMountEvictsContainersWhenEntryIsNoLongerReserved() throws 
IOException
+  {
+    final StorageLocation location = new StorageLocation(cacheDir, ESTIMATE * 
8, null);
+    // ephemeral mode: releasing the last hold drops the weak entry 
immediately, so the mount below runs for an entry
+    // the cache no longer knows about.
+    location.setAreWeakEntriesEphemeral(true);
+    final PartialSegmentMetadataCacheEntry metadata = 
newTwoContainerBundleMetadata();
+    Assertions.assertTrue(location.reserve(metadata));
+    metadata.mount(location);
+
+    final PartialSegmentBundleCacheEntry bundle =
+        PartialSegmentBundleCacheEntry.forBundle(metadata, 
TWO_CONTAINER_BUNDLE, List.of());
+    try (StorageLocation.ReservationHold<?> hold = 
location.addWeakReservationHold(bundle.getId(), () -> bundle)) {
+      Assertions.assertNotNull(hold);
+    }
+    Assertions.assertFalse(location.isWeakReserved(bundle.getId()), "ephemeral 
release should have evicted");
+
+    final List<PartialSegmentBundleCacheEntry.BundleContainerRef> refs = 
bundle.getContainerRefs();
+    Assertions.assertEquals(2, refs.size());
+    final File firstContainerFile = containerFileFor(refs.getFirst());
+    FileUtils.mkdirp(containerFileFor(refs.getLast()));
+
+    Assertions.assertThrows(IOException.class, () -> bundle.mount(location));
+    Assertions.assertFalse(bundle.isMounted());
+
+    // Nothing will ever mount this entry again, and unmount only cleans up a 
mount that committed, so the container
+    // this attempt initialized would have no owner. It has to be reaped by 
the failed mount itself.
+    Assertions.assertFalse(
+        firstContainerFile.exists(),
+        "containers of an entry the cache has dropped should be evicted by the 
failed mount"
+    );
+  }
+
+  @Test
+  void testUnmountReapsContainersLeftBehindByAFailedMount() throws IOException
+  {
+    final StorageLocation location = new StorageLocation(cacheDir, ESTIMATE * 
8, null);
+    final PartialSegmentMetadataCacheEntry metadata = 
newTwoContainerBundleMetadata();
+    Assertions.assertTrue(location.reserve(metadata));
+    metadata.mount(location);
+
+    final PartialSegmentBundleCacheEntry bundle =
+        PartialSegmentBundleCacheEntry.forBundle(metadata, 
TWO_CONTAINER_BUNDLE, List.of());
+    Assertions.assertNotNull(location.addWeakReservationHold(bundle.getId(), 
() -> bundle));
+
+    final List<PartialSegmentBundleCacheEntry.BundleContainerRef> refs = 
bundle.getContainerRefs();
+    Assertions.assertEquals(2, refs.size());
+    final File firstContainerFile = containerFileFor(refs.getFirst());
+    FileUtils.mkdirp(containerFileFor(refs.getLast()));
+
+    Assertions.assertThrows(IOException.class, () -> bundle.mount(location));
+    Assertions.assertFalse(bundle.isMounted());
+    Assertions.assertTrue(firstContainerFile.exists(), "the failed mount 
should have kept its container");
+
+    // The entry is dropped without anyone retrying the mount. doActualUnmount 
never runs for a mount that did not
+    // commit, and nothing else deletes container files, so unmount() has to 
reap them or they outlive the entry with
+    // their reservation already released.
+    bundle.unmount();
+    Assertions.assertFalse(
+        firstContainerFile.exists(),
+        "unmount should evict containers left behind by a mount that never 
committed"
+    );
+  }
+
+  /**
+   * A metadata entry for a segment whose {@link #TWO_CONTAINER_BUNDLE} bundle 
spans two containers, one in the main
+   * file and one in an external file. Two containers is what lets a test fail 
a mount partway through the
+   * container-initialization loop, with one container already initialized 
behind it.
+   */
+  private PartialSegmentMetadataCacheEntry newTwoContainerBundleMetadata() 
throws IOException
+  {
+    final int salt = ThreadLocalRandom.current().nextInt(Integer.MAX_VALUE);
+    final File deepDir = temporaryFolder.newFolder("two_container_deep_" + 
salt);
+    try (SegmentFileBuilderV10 builder = 
SegmentFileBuilderV10.create(JSON_MAPPER, deepDir)) {
+      // Attach the external builder BEFORE startFileBundle so the bundle 
propagates to it.
+      final SegmentFileBuilder external = 
builder.getExternalBuilder(TWO_CONTAINER_EXTERNAL);
+      builder.startFileBundle(TWO_CONTAINER_BUNDLE);
+
+      final File mainTmp = temporaryFolder.newFile("two-container-main-" + 
salt + ".bin");
+      Files.write(Ints.toByteArray(1), mainTmp);
+      builder.add(TWO_CONTAINER_BUNDLE + "/main_col", mainTmp);
+
+      final File extTmp = temporaryFolder.newFile("two-container-ext-" + salt 
+ ".bin");
+      Files.write(Ints.toByteArray(2), extTmp);
+      external.add(TWO_CONTAINER_BUNDLE + "/ext_col", extTmp);
+    }
+    return new PartialSegmentMetadataCacheEntry(
+        SEGMENT_ID,
+        cacheDir,
+        IndexIO.V10_FILE_NAME,
+        List.of(TWO_CONTAINER_EXTERNAL),
+        new DirectoryBackedRangeReader(deepDir),
+        JSON_MAPPER,
+        null,
+        ESTIMATE,
+        PartialSegmentFileMapperV10.DEFAULT_COALESCE_GAP_BYTES,
+        PartialSegmentFileMapperV10.DEFAULT_MAX_FETCH_RUN_BYTES
+    );
+  }
+
+  private File 
containerFileFor(PartialSegmentBundleCacheEntry.BundleContainerRef ref)
+  {
+    final String mapperFilename = ref.externalFilename() != null ? 
ref.externalFilename() : IndexIO.V10_FILE_NAME;
+    return new File(cacheDir, StringUtils.format("%s.container.%05d", 
mapperFilename, ref.containerIndex()));
+  }
+
   @Test
   void testAcquireReferenceBeforeMountThrows() throws IOException
   {
diff --git 
a/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntryTest.java
 
b/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntryTest.java
index 36f7162cc9a..0d0b588747f 100644
--- 
a/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntryTest.java
+++ 
b/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentMetadataCacheEntryTest.java
@@ -49,7 +49,9 @@ import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicReference;
 
@@ -57,6 +59,7 @@ class PartialSegmentMetadataCacheEntryTest
 {
   private static final ObjectMapper JSON_MAPPER = TestHelper.makeJsonMapper();
   private static final SegmentId SEGMENT_ID = SegmentId.of("test", 
Intervals.of("2025/2026"), "v1", 0);
+  private static final SegmentId OTHER_SEGMENT_ID = SegmentId.of("other", 
Intervals.of("2025/2026"), "v1", 0);
   private static final long ESTIMATE = 16 * 1024 * 1024L;
 
   @RegisterExtension
@@ -122,6 +125,66 @@ class PartialSegmentMetadataCacheEntryTest
     Assertions.assertFalse(headerFile.exists(), "mount failure must delete the 
on-disk header file");
   }
 
+  @Test
+  void testReclaimCannotEvictAnEntryWhileItIsMounting() throws Exception
+  {
+    // Room for one entry's reservation and no more, so a second reservation 
can only succeed by reclaiming the first.
+    final StorageLocation location = new StorageLocation(cacheDir, ESTIMATE, 
null);
+    final CountDownLatch entered = new CountDownLatch(1);
+    final CountDownLatch release = new CountDownLatch(1);
+    final PartialSegmentMetadataCacheEntry entry = newGatedEntry(ESTIMATE, 
entered, release);
+
+    final StorageLocation.ReservationHold<SegmentCacheEntry> reserver =
+        location.addWeakReservationHold(entry.getId(), () -> entry);
+    Assertions.assertNotNull(reserver);
+
+    final ExecutorService exec = Execs.multiThreaded(1, 
"mount-self-hold-test-%d");
+    try {
+      final Future<?> mounting = exec.submit(() -> {
+        entry.mount(location);
+        return null;
+      });
+      Assertions.assertTrue(entered.await(30, TimeUnit.SECONDS), "mount must 
reach the gated read");
+
+      // The acquire that started this mount gives up. Nothing outside the 
mount holds the entry now, which is
+      // exactly when reclaim would previously have been free to take it.
+      reserver.close();
+
+      // A reservation that only fits if this entry is evicted. It must fail 
rather than pull the entry out from
+      // under the mount that is still filling it.
+      final PartialSegmentMetadataCacheEntry other = new 
PartialSegmentMetadataCacheEntry(
+          OTHER_SEGMENT_ID,
+          cacheDir,
+          IndexIO.V10_FILE_NAME,
+          List.of(),
+          new DirectoryBackedRangeReader(segmentFile.getParentFile()),
+          JSON_MAPPER,
+          null,
+          ESTIMATE,
+          PartialSegmentFileMapperV10.DEFAULT_COALESCE_GAP_BYTES,
+          PartialSegmentFileMapperV10.DEFAULT_MAX_FETCH_RUN_BYTES
+      );
+      final StorageLocation.ReservationHold<SegmentCacheEntry> contender =
+          location.addWeakReservationHold(other.getId(), () -> other);
+      Assertions.assertNull(contender, "reclaim must not evict an entry whose 
mount is still in flight");
+      Assertions.assertNotNull(
+          location.getCacheEntry(entry.getId()),
+          "the mounting entry must still be registered"
+      );
+
+      release.countDown();
+      mounting.get(30, TimeUnit.SECONDS);
+    }
+    finally {
+      release.countDown();
+      exec.shutdownNow();
+    }
+
+    // The mount ran to completion against an entry the location still knows 
about.
+    Assertions.assertTrue(entry.isMounted());
+    Assertions.assertNotNull(location.getCacheEntry(entry.getId()));
+  }
+
   @Test
   void testMountIsIdempotentInSameLocation() throws IOException
   {
@@ -581,6 +644,42 @@ class PartialSegmentMetadataCacheEntryTest
     Assertions.assertEquals(List.of(), 
entry.inferBundleDependencies("some_projection"));
   }
 
+  /**
+   * A range reader that parks the first read until the test lets it through, 
so a mount can be held mid-flight.
+   */
+  private PartialSegmentMetadataCacheEntry newGatedEntry(long estimate, 
CountDownLatch entered, CountDownLatch release)
+  {
+    final SegmentRangeReader delegate = new 
DirectoryBackedRangeReader(segmentFile.getParentFile());
+    final AtomicBoolean gated = new AtomicBoolean(false);
+    final SegmentRangeReader gatedReader = (filename, offset, length) -> {
+      if (gated.compareAndSet(false, true)) {
+        entered.countDown();
+        try {
+          if (!release.await(30, TimeUnit.SECONDS)) {
+            throw new IOException("timed out waiting for the test to release 
the mount");
+          }
+        }
+        catch (InterruptedException e) {
+          Thread.currentThread().interrupt();
+          throw new IOException(e);
+        }
+      }
+      return delegate.readRange(filename, offset, length);
+    };
+    return new PartialSegmentMetadataCacheEntry(
+        SEGMENT_ID,
+        cacheDir,
+        IndexIO.V10_FILE_NAME,
+        List.of(),
+        gatedReader,
+        JSON_MAPPER,
+        null,
+        estimate,
+        PartialSegmentFileMapperV10.DEFAULT_COALESCE_GAP_BYTES,
+        PartialSegmentFileMapperV10.DEFAULT_MAX_FETCH_RUN_BYTES
+    );
+  }
+
   private PartialSegmentMetadataCacheEntry newEntry(long estimate)
   {
     return new PartialSegmentMetadataCacheEntry(
diff --git 
a/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerConcurrencyTest.java
 
b/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerConcurrencyTest.java
index 921d1597cc2..18f6a086626 100644
--- 
a/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerConcurrencyTest.java
+++ 
b/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerConcurrencyTest.java
@@ -434,15 +434,15 @@ class SegmentLocalCacheManagerConcurrencyTest
           Assertions.assertTrue(t instanceof TimeoutException || t instanceof 
ExecutionException, t.toString());
         }
         Thread.sleep(20);
-        Assertions.assertEquals(0, location.getWeakStats().getHoldCount());
-        Assertions.assertEquals(0, location2.getWeakStats().getHoldCount());
+        awaitNoHolds(location);
+        awaitNoHolds(location2);
 
         currentBatch.clear();
       }
     }
 
-    Assertions.assertEquals(0, location.getWeakStats().getHoldCount());
-    Assertions.assertEquals(0, location2.getWeakStats().getHoldCount());
+    awaitNoHolds(location);
+    awaitNoHolds(location2);
     Assertions.assertTrue(4 >= location.getWeakEntryCount());
     Assertions.assertTrue(4 >= location2.getWeakEntryCount());
     // 5 because __drop path
@@ -500,8 +500,8 @@ class SegmentLocalCacheManagerConcurrencyTest
           }
           Thread.sleep(5);
         }
-        Assertions.assertEquals(0, location.getWeakStats().getHoldCount());
-        Assertions.assertEquals(0, location2.getWeakStats().getHoldCount());
+        awaitNoHolds(location);
+        awaitNoHolds(location2);
         currentBatch.clear();
       }
     }
@@ -569,8 +569,8 @@ class SegmentLocalCacheManagerConcurrencyTest
           }
         }
 
-        Assertions.assertEquals(0, location.getWeakStats().getHoldCount());
-        Assertions.assertEquals(0, location2.getWeakStats().getHoldCount());
+        awaitNoHolds(location);
+        awaitNoHolds(location2);
         totalSuccess += success;
         totalEmpty += empty;
         totalRows += rows;
@@ -592,6 +592,85 @@ class SegmentLocalCacheManagerConcurrencyTest
     assertNoLooseEnds();
   }
 
+  @Test
+  public void testAcquireSegmentOnDemandRandomSegmentWithInterrupt() throws 
IOException, InterruptedException
+  {
+    final int segmentCount = 8;
+    final int iterations = 2000;
+    final int concurrentReads = 10;
+    final File localStorageFolder = new File(tempDir, "local_storage_folder");
+
+    final Interval interval = Intervals.of("2019-01-01/P1D");
+
+    makeSegmentsToLoad(segmentCount, localStorageFolder, interval, 
segmentsToWeakLoad);
+
+    final List<DataSegment> currentBatch = new ArrayList<>();
+    for (int i = 0; i < iterations; i++) {
+      
currentBatch.add(segmentsToWeakLoad.get(ThreadLocalRandom.current().nextInt(segmentCount)));
+      // process batches of 10 requests at a time
+      if (currentBatch.size() == concurrentReads) {
+        final List<InterruptedLoad> weakLoads = currentBatch
+            .stream()
+            .map(segment -> new InterruptedLoad(virtualStorageManager, 
segment))
+            .collect(Collectors.toList());
+        final List<Future<Integer>> futures = new ArrayList<>();
+        for (InterruptedLoad weakLoad : weakLoads) {
+          futures.add(executorService.submit(weakLoad));
+        }
+        for (Future<Integer> future : futures) {
+          try {
+            future.get(20L, TimeUnit.MILLISECONDS);
+          }
+          catch (Throwable t) {
+          }
+        }
+        while (true) {
+          boolean allDone = true;
+          for (Future<?> f : futures) {
+            allDone = allDone && f.isDone();
+          }
+          if (allDone) {
+            break;
+          }
+          Thread.sleep(5);
+        }
+        awaitNoHolds(location);
+        awaitNoHolds(location2);
+        currentBatch.clear();
+      }
+    }
+
+    Assertions.assertTrue(location.getWeakStats().getHitCount() >= 0);
+    Assertions.assertTrue(location.getWeakStats().getHitBytes() >= 0);
+    Assertions.assertTrue(location2.getWeakStats().getHitCount() >= 0);
+    Assertions.assertTrue(location2.getWeakStats().getHitBytes() >= 0);
+
+    // now ensure that we can successfully do stuff after all those interrupts
+    int totalSuccess = 0;
+    int totalFailures = 0;
+    for (int i = 0; i < iterations; i++) {
+      int segment = ThreadLocalRandom.current().nextInt(segmentCount);
+      currentBatch.add(segmentsToWeakLoad.get(segment));
+      // process batches of 10 requests at a time
+      if (currentBatch.size() == concurrentReads) {
+
+        BatchResult result = testWeakBatch(i, currentBatch, false);
+        totalSuccess += result.success;
+        totalFailures += result.exceptions.size();
+        currentBatch.clear();
+      }
+    }
+    Assertions.assertEquals(iterations, totalSuccess);
+    Assertions.assertEquals(0, totalFailures);
+    awaitNoHolds(location);
+    awaitNoHolds(location2);
+    Assertions.assertTrue(4 >= location.getWeakEntryCount());
+    Assertions.assertTrue(4 >= location2.getWeakEntryCount());
+    // 5 because __drop path
+    Assertions.assertTrue(5 >= location.getPath().listFiles().length);
+    Assertions.assertTrue(5 >= location2.getPath().listFiles().length);
+  }
+
   private void testWeakLoad(
       int iterations,
       int segmentCount,
@@ -735,89 +814,10 @@ class SegmentLocalCacheManagerConcurrencyTest
     return new BatchResult(exceptions, success, rows);
   }
 
-  @Test
-  public void testAcquireSegmentOnDemandRandomSegmentWithInterrupt() throws 
IOException, InterruptedException
-  {
-    final int segmentCount = 8;
-    final int iterations = 2000;
-    final int concurrentReads = 10;
-    final File localStorageFolder = new File(tempDir, "local_storage_folder");
-
-    final Interval interval = Intervals.of("2019-01-01/P1D");
-
-    makeSegmentsToLoad(segmentCount, localStorageFolder, interval, 
segmentsToWeakLoad);
-
-    final List<DataSegment> currentBatch = new ArrayList<>();
-    for (int i = 0; i < iterations; i++) {
-      
currentBatch.add(segmentsToWeakLoad.get(ThreadLocalRandom.current().nextInt(segmentCount)));
-      // process batches of 10 requests at a time
-      if (currentBatch.size() == concurrentReads) {
-        final List<InterruptedLoad> weakLoads = currentBatch
-            .stream()
-            .map(segment -> new InterruptedLoad(virtualStorageManager, 
segment))
-            .collect(Collectors.toList());
-        final List<Future<Integer>> futures = new ArrayList<>();
-        for (InterruptedLoad weakLoad : weakLoads) {
-          futures.add(executorService.submit(weakLoad));
-        }
-        for (Future<Integer> future : futures) {
-          try {
-            future.get(20L, TimeUnit.MILLISECONDS);
-          }
-          catch (Throwable t) {
-          }
-        }
-        while (true) {
-          boolean allDone = true;
-          for (Future<?> f : futures) {
-            allDone = allDone && f.isDone();
-          }
-          if (allDone) {
-            break;
-          }
-          Thread.sleep(5);
-        }
-        Assertions.assertEquals(0, location.getWeakStats().getHoldCount());
-        Assertions.assertEquals(0, location2.getWeakStats().getHoldCount());
-        currentBatch.clear();
-      }
-    }
-
-    Assertions.assertTrue(location.getWeakStats().getHitCount() >= 0);
-    Assertions.assertTrue(location.getWeakStats().getHitBytes() >= 0);
-    Assertions.assertTrue(location2.getWeakStats().getHitCount() >= 0);
-    Assertions.assertTrue(location2.getWeakStats().getHitBytes() >= 0);
-
-    // now ensure that we can successfully do stuff after all those interrupts
-    int totalSuccess = 0;
-    int totalFailures = 0;
-    for (int i = 0; i < iterations; i++) {
-      int segment = ThreadLocalRandom.current().nextInt(segmentCount);
-      currentBatch.add(segmentsToWeakLoad.get(segment));
-      // process batches of 10 requests at a time
-      if (currentBatch.size() == concurrentReads) {
-
-        BatchResult result = testWeakBatch(i, currentBatch, false);
-        totalSuccess += result.success;
-        totalFailures += result.exceptions.size();
-        currentBatch.clear();
-      }
-    }
-    Assertions.assertEquals(iterations, totalSuccess);
-    Assertions.assertEquals(0, totalFailures);
-    Assertions.assertEquals(0, location.getWeakStats().getHoldCount());
-    Assertions.assertEquals(0, location2.getWeakStats().getHoldCount());
-    Assertions.assertTrue(4 >= location.getWeakEntryCount());
-    Assertions.assertTrue(4 >= location2.getWeakEntryCount());
-    // 5 because __drop path
-    Assertions.assertTrue(5 >= location.getPath().listFiles().length);
-    Assertions.assertTrue(5 >= location2.getPath().listFiles().length);
-  }
-
   private void assertNoLooseEnds()
   {
-    Assertions.assertEquals(0, location.getWeakStats().getHoldCount());
-    Assertions.assertEquals(0, location2.getWeakStats().getHoldCount());
+    awaitNoHolds(location);
+    awaitNoHolds(location2);
     Assertions.assertTrue(4 >= location.getWeakEntryCount());
     Assertions.assertTrue(4 >= location2.getWeakEntryCount());
     // 5 because __drop path
@@ -922,6 +922,27 @@ class SegmentLocalCacheManagerConcurrencyTest
                       .build();
   }
 
+  /**
+   * Waits for every hold on a location to be released, then asserts there are 
none.
+   * <p>
+   * A mount holds its own entry until it has finished establishing state, and 
abandoning the acquire that triggered
+   * it does not stop that mount, so a hold can briefly outlive the caller 
that asked for it. Anything that leaks a
+   * hold still fails here, it just takes the timeout to say so.
+   */
+  private static void awaitNoHolds(StorageLocation location)
+  {
+    for (int i = 0; i < 300 && location.getWeakStats().getHoldCount() > 0; 
i++) {
+      try {
+        Thread.sleep(10);
+      }
+      catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        break;
+      }
+    }
+    Assertions.assertEquals(0, location.getWeakStats().getHoldCount());
+  }
+
   private static class BatchResult
   {
     public final List<Throwable> exceptions;
diff --git 
a/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialAcquireTest.java
 
b/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialAcquireTest.java
index 4a5be4714b3..63c42d1e1c6 100644
--- 
a/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialAcquireTest.java
+++ 
b/server/src/test/java/org/apache/druid/segment/loading/SegmentLocalCacheManagerPartialAcquireTest.java
@@ -19,6 +19,9 @@
 
 package org.apache.druid.segment.loading;
 
+import com.fasterxml.jackson.annotation.JacksonInject;
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
 import com.fasterxml.jackson.databind.InjectableValues;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.jsontype.NamedType;
@@ -79,6 +82,7 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
 
+import javax.annotation.Nullable;
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
@@ -95,6 +99,7 @@ import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ThreadLocalRandom;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Collectors;
 
 class SegmentLocalCacheManagerPartialAcquireTest
@@ -305,6 +310,59 @@ class SegmentLocalCacheManagerPartialAcquireTest
     }
   }
 
+  @Test
+  void testCancellingFullAcquireMidDownloadDoesNotEvictContainersUnderIt() 
throws Exception
+  {
+    // Ephemeral weak entries, as an MSQ/Dart worker runs: releasing the last 
hold on a bundle drops it from the cache
+    // then and there, which is what turns an abandoned acquire into an 
unmount of the bundles it was downloading.
+    manager.getLocations().get(0).setAreWeakEntriesEphemeral(true);
+
+    jsonMapper.registerSubtypes(new NamedType(GatedLocalLoadSpec.class, 
GatedLocalLoadSpec.TYPE));
+    final DataSegment gatedSegment = DataSegment.builder(SEGMENT_ID)
+                                                
.shardSpec(NoneShardSpec.instance())
+                                                .loadSpec(Map.of(
+                                                    "type", 
GatedLocalLoadSpec.TYPE,
+                                                    "path", 
DEEP_STORAGE_DIR.getAbsolutePath()
+                                                ))
+                                                .size(0)
+                                                .build();
+
+    // Warm the metadata entry first, and keep it held for the rest of the 
test. Mounting metadata range-reads the
+    // header through the same reader the download uses, so warming it means 
the gate below can only be reached from
+    // inside ensureAllDownloaded. A partial acquire downloads nothing else, 
so no container is resident yet.
+    try (AcquireSegmentAction warm = manager.acquireSegment(gatedSegment, 
AcquireMode.PARTIAL)) {
+      warm.getSegmentFuture().get();
+
+      final DownloadGate gate = new DownloadGate();
+      DOWNLOAD_GATE.set(gate);
+      try {
+        final AcquireSegmentAction full = manager.acquireSegment(gatedSegment, 
AcquireMode.FULL);
+        // Held across the close() below: getSegmentFuture() refuses to hand 
out the future once the action is closed,
+        // and the load task keeps running either way - closing an action 
cancels the caller's interest, not the task.
+        final ListenableFuture<AcquireSegmentResult> future = 
full.getSegmentFuture();
+
+        Assertions.assertTrue(
+            gate.entered.await(60, TimeUnit.SECONDS),
+            "the full download should have reached a container fetch"
+        );
+
+        // Abandon the acquire while a fetch is parked mid-write. This closes 
the action's HoldHolder on THIS thread,
+        // which is what used to unmount the bundles - and evict the 
containers - out from under the running download.
+        full.close();
+        gate.release.countDown();
+
+        // The download must still finish against containers that are all 
still there. Before the download owned its
+        // own bundle references, this failed: either an NPE from a fetch 
whose container file had been deleted, or
+        // ensureAllDownloaded's post-condition once the eviction cleared 
residency behind it.
+        final AcquireSegmentResult result = future.get(60, TimeUnit.SECONDS);
+        Assertions.assertNotNull(result);
+      }
+      finally {
+        DOWNLOAD_GATE.set(null);
+      }
+    }
+  }
+
   @Test
   void testAcquirePartialSegmentReturnsPartialAwareSegment() throws 
ExecutionException, InterruptedException, IOException
   {
@@ -1172,4 +1230,82 @@ class SegmentLocalCacheManagerPartialAcquireTest
       }
     }
   }
+
+  /**
+   * Parks the first range read that reaches it until the test lets it go, so 
a download can be caught mid-write.
+   * Consulted by every reader {@link GatedLocalLoadSpec} hands out, since the 
reader doing the download is the one the
+   * file mapper was created with, not the one the acquire being tested opened.
+   */
+  private static final AtomicReference<DownloadGate> DOWNLOAD_GATE = new 
AtomicReference<>();
+
+  private static final class DownloadGate
+  {
+    private final CountDownLatch entered = new CountDownLatch(1);
+    private final CountDownLatch release = new CountDownLatch(1);
+
+    private void arrive() throws IOException
+    {
+      entered.countDown();
+      try {
+        if (!release.await(60, TimeUnit.SECONDS)) {
+          throw new IOException("Timed out waiting for the test to release the 
download gate");
+        }
+      }
+      catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        throw new IOException(e);
+      }
+    }
+  }
+
+  /**
+   * A {@link LoadSpec} that loads exactly like {@link LocalLoadSpec} but 
hands out range readers that consult
+   * {@link #DOWNLOAD_GATE} first. Registered as a Jackson subtype by the test 
that needs it.
+   */
+  public static class GatedLocalLoadSpec implements LoadSpec
+  {
+    static final String TYPE = "gated-local";
+
+    private final LocalLoadSpec delegate;
+    private final String path;
+
+    @JsonCreator
+    public GatedLocalLoadSpec(
+        @JacksonInject LocalDataSegmentPuller puller,
+        @JsonProperty(value = "path", required = true) String path
+    )
+    {
+      this.delegate = new LocalLoadSpec(puller, path);
+      this.path = path;
+    }
+
+    @JsonProperty
+    public String getPath()
+    {
+      return path;
+    }
+
+    @Override
+    public LoadSpecResult loadSegment(File destDir) throws 
SegmentLoadingException
+    {
+      return delegate.loadSegment(destDir);
+    }
+
+    @Nullable
+    @Override
+    public SegmentRangeReader openRangeReader() throws IOException
+    {
+      final SegmentRangeReader reader = delegate.openRangeReader();
+      if (reader == null) {
+        return null;
+      }
+      return (filename, offset, length) -> {
+        final DownloadGate gate = DOWNLOAD_GATE.get();
+        if (gate != null) {
+          gate.arrive();
+        }
+        return reader.readRange(filename, offset, length);
+      };
+    }
+  }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to