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 aedca205992 feat: PartialSegmentFileMapper no longer lazy loads 
(#19693)
aedca205992 is described below

commit aedca2059923a1906f8382b9984cd3dd4c2acfc2
Author: Clint Wylie <[email protected]>
AuthorDate: Thu Jul 16 22:06:17 2026 -0700

    feat: PartialSegmentFileMapper no longer lazy loads (#19693)
---
 .../druid/segment/PartialQueryableIndex.java       | 156 ++++++++++++---
 .../PartialQueryableIndexCursorFactory.java        |   3 +-
 .../segment/file/PartialSegmentFileMapperV10.java  | 118 +++++++-----
 .../PartialQueryableIndexCursorFactoryTest.java    |  98 ++++++++++
 .../druid/segment/PartialQueryableIndexTest.java   | 122 ++++++++----
 .../file/PartialSegmentFileMapperV10Test.java      | 213 +++++++--------------
 .../PartialSegmentBundleCacheEntryTest.java        |   2 +
 .../loading/PartialSegmentCacheBootstrapTest.java  |   8 +-
 8 files changed, 458 insertions(+), 262 deletions(-)

diff --git 
a/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndex.java 
b/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndex.java
index 4b95af14fb6..e36d5916d0e 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndex.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndex.java
@@ -70,10 +70,13 @@ import java.util.function.Function;
 import java.util.stream.Collectors;
 
 /**
- * A {@link QueryableIndex} that loads projection and base table columns on 
demand from a
- * {@link PartialSegmentFileMapperV10}. Schema queries (column names, types, 
intervals, metadata) are answered from
- * the {@link SegmentFileMetadata} alone without triggering any downloads. 
Column data is only downloaded when a column
- * is accessed via {@link #getColumnHolder(String)} or {@link 
#getProjection(CursorBuildSpec)}.
+ * A {@link QueryableIndex} over a {@link PartialSegmentFileMapperV10} whose 
columns are made resident by explicit,
+ * planned downloads. Schema queries (column names, types, intervals, 
metadata) are answered from the
+ * {@link SegmentFileMetadata} alone without touching column data. {@link 
#planCursorPrefetch} resolves what a
+ * {@link CursorBuildSpec} needs and plans the coalesced range reads that make 
it resident; only after those fetches
+ * execute may columns be read. Column deserialization stays lazy (memoized 
per-column suppliers), but the underlying
+ * files must already be resident: mapping a non-resident file throws rather 
than downloading, so a read outside a
+ * plan fails loudly instead of blocking a processing thread on deep-storage 
I/O.
  * <p>
  * Projection matching uses only metadata ({@link 
SegmentFileMetadata#getColumnDescriptors()} keys) to determine if
  * a projection can satisfy a query, avoiding downloads of projection data 
that won't be used.
@@ -102,9 +105,9 @@ public class PartialQueryableIndex implements QueryableIndex
   // segment-internal file prefix for the base table projection, used to 
translate column names to descriptor keys
   private final String baseProjectionPrefix;
 
-  // base table columns, built at construction time. each entry's supplier 
defers both mapFile() and column
-  // deserialization until the column is actually accessed, so queries only 
trigger downloads for the specific
-  // columns they use.
+  // base table columns, built at construction time. each entry's supplier 
defers mapFile() and column
+  // deserialization until the column is actually accessed; the files must 
already be resident by then (made so by a
+  // planned fetch), since mapFile() throws rather than downloads.
   private final Map<String, Supplier<BaseColumnHolder>> baseColumns;
 
   // projection columns, keyed by projection name. built on demand 
(per-projection) when the projection is matched.
@@ -304,8 +307,9 @@ public class PartialQueryableIndex implements QueryableIndex
   }
 
   /**
-   * Answers from metadata without triggering column downloads. The default 
implementation in {@link QueryableIndex}
-   * calls {@link #getColumnHolder(String)}, which would force a base table 
load.
+   * Answers from metadata without touching column data. The default 
implementation in {@link QueryableIndex}
+   * calls {@link #getColumnHolder(String)}, which would force base table 
column deserialization (and throw if the
+   * column isn't resident).
    */
   @Nullable
   @Override
@@ -324,7 +328,7 @@ public class PartialQueryableIndex implements QueryableIndex
    * resolve from the summary's typed clustering signature; data columns (and 
{@code __time}) resolve from the first
    * cluster group's {@link ColumnDescriptor} (all groups share the same 
per-group shape). Mirrors the eager
    * {@link SimpleQueryableIndex} clustered branch, but reads the descriptor 
directly rather than routing through a
-   * group sub-index's {@code getColumnHolder} (which would trigger a 
download).
+   * group sub-index's {@code getColumnHolder} (which would deserialize the 
column, throwing if it isn't resident).
    */
   @Nullable
   private ColumnCapabilities getClusteredColumnCapabilities(String column)
@@ -474,6 +478,7 @@ public class PartialQueryableIndex implements QueryableIndex
     final Map<String, Supplier<BaseColumnHolder>> baseColumns = 
clusterGroupColumnsByIndex.computeIfAbsent(
         groupIndex,
         i -> buildColumnSuppliers(
+            
Projections.getClusterGroupBundleName(groupSpec.getClusteringValueIds()),
             clusteredBaseSummary.getTimeColumnName(),
             groupSpec.getNumRows(),
             clusteredBaseSummary.getGroupColumnNames(),
@@ -549,7 +554,7 @@ public class PartialQueryableIndex implements QueryableIndex
                 
Projections.getClusterGroupBundleName(group.getClusteringValueIds()),
                 groupIndex,
                 required,
-                planClusterGroupPrefetch(group, required)
+                () -> planClusterGroupPrefetch(group, required)
             )
         );
       }
@@ -562,25 +567,32 @@ public class PartialQueryableIndex implements 
QueryableIndex
     final QueryableIndex rowSelector = matched != null ? 
matched.getRowSelector() : this;
     final String bundleName = matched != null ? matched.getName() : 
Projections.BASE_TABLE_PROJECTION_NAME;
     final Set<String> required = requiredColumns(rowSelector, matched, spec);
-    final List<PartialSegmentFileMapperV10.PlannedFetch> fetches =
-        matched != null ? planProjectionPrefetch(matched.getName(), required) 
: planBaseTablePrefetch(required);
     final List<PrefetchBundle> bundles = new ArrayList<>(2);
-    bundles.add(new PrefetchBundle(bundleName, rowSelector, required, 
fetches));
+    bundles.add(
+        new PrefetchBundle(
+            bundleName,
+            rowSelector,
+            required,
+            matched != null
+            ? () -> planProjectionPrefetch(matched.getName(), required)
+            : () -> planBaseTablePrefetch(required)
+        )
+    );
     if (matched != null) {
       final Set<String> parents = projectionParentColumns(matched.getName(), 
required);
       if (!parents.isEmpty()) {
         // Materializing a projection column also materializes its same-named 
base column when one exists
         // (buildColumnSuppliers reads through the parent, e.g. for dictionary 
reuse), so those parents are part of
         // this query's working set: plan their files and hold the base bundle 
so the parent mmaps are
-        // eviction-excluded for the holder's whole lifetime. When the 
metadata predates recorded column file lists
-        // the parent files can't be enumerated; the bundle is then hold-only 
(no planned fetches) and the parents
-        // download lazily during materialization, under the hold, on the 
download executor.
+        // eviction-excluded for the holder's whole lifetime. Nothing 
downloads lazily (mapFile throws on
+        // non-resident files), so when the metadata predates recorded column 
file lists and the parent files can't
+        // be enumerated, planBaseTablePrefetch's fallback covers the whole 
base bundle.
         bundles.add(
             new PrefetchBundle(
                 Projections.BASE_TABLE_PROJECTION_NAME,
                 this,
                 parents,
-                metadata.getColumnFiles() == null ? List.of() : 
planBaseTablePrefetch(parents)
+                () -> planBaseTablePrefetch(parents)
             )
         );
       }
@@ -636,8 +648,8 @@ public class PartialQueryableIndex implements QueryableIndex
 
   /**
    * Build a map of column name to per-column supplier for the given 
projection. Each supplier defers both
-   * {@link SegmentFileMapper#mapFile} and {@link ColumnDescriptor#read} until 
the column is actually accessed, so
-   * queries only trigger downloads for the specific columns they use.
+   * {@link SegmentFileMapper#mapFile} and {@link ColumnDescriptor#read} until 
the column is actually accessed, by
+   * which point the column's files must be resident (made so by a planned 
fetch).
    */
   private Map<String, Supplier<BaseColumnHolder>> 
buildProjectionColumnSuppliers(
       ProjectionMetadata projectionSpec,
@@ -645,6 +657,7 @@ public class PartialQueryableIndex implements QueryableIndex
   )
   {
     return buildColumnSuppliers(
+        projectionSpec.getSchema().getName(),
         projectionSpec.getSchema().getTimeColumnName(),
         projectionSpec.getNumRows(),
         projectionSpec.getSchema().getColumnNames(),
@@ -654,12 +667,15 @@ public class PartialQueryableIndex implements 
QueryableIndex
   }
 
   /**
-   * Shared builder for lazy per-column suppliers. Each supplier is memoized 
and defers both
-   * {@link SegmentFileMapper#mapFile} and {@link ColumnDescriptor#read} until 
the column is actually accessed, so
-   * queries only trigger downloads for the specific columns they use. {@code 
fileNameFn} maps a logical column name to
-   * its segment-internal (smoosh) file name in the right bundle namespace.
+   * Shared builder for lazy per-column suppliers. Each supplier memoizes with 
eviction awareness (see
+   * {@link EvictionAwareColumnSupplier}) and defers both {@link 
SegmentFileMapper#mapFile} and
+   * {@link ColumnDescriptor#read} until the column is actually accessed, by 
which point the column's files must be
+   * resident (made so by a planned fetch, mapFile throws otherwise). {@code 
bundleName} is the cache-layer bundle the
+   * columns live in (their eviction unit); {@code fileNameFn} maps a logical 
column name to its segment-internal
+   * (smoosh) file name in that bundle's namespace.
    */
   private Map<String, Supplier<BaseColumnHolder>> buildColumnSuppliers(
+      String bundleName,
       @Nullable String timeColumnName,
       int numRows,
       List<String> columnNames,
@@ -678,7 +694,10 @@ public class PartialQueryableIndex implements 
QueryableIndex
       }
 
       final String internedColumnName = 
SmooshedFileMapper.STRING_INTERNER.intern(column);
-      final Supplier<BaseColumnHolder> columnSupplier = Suppliers.memoize(() 
-> {
+      // a column with a same-named base parent deserializes through the 
parent's buffers (dictionary reuse), so its
+      // memoization must also invalidate when the parent's (__base) bundle is 
evicted
+      final boolean readsParent = parentColumns.containsKey(column);
+      final Supplier<BaseColumnHolder> columnSupplier = new 
EvictionAwareColumnSupplier(bundleName, readsParent, () -> {
         try {
           final ByteBuffer colBuffer = fileMapper.mapFile(smooshName);
           final BaseColumnHolder parentColumn =
@@ -930,17 +949,94 @@ public class PartialQueryableIndex implements 
QueryableIndex
   }
 
   /**
-   * One bundle's worth of planned download work: the cache-layer {@code 
bundleName} to mount, the row selector whose
-   * {@code getColumnHolder} materializes the columns, the columns to 
materialize once resident, and the planned
-   * coalesced range reads that make them resident, each paired with the (main 
or external) mapper that executes it
-   * and each executable concurrently via {@link 
PartialSegmentFileMapperV10.PlannedFetch#fetch()}.
+   * One bundle's worth of download work: the cache-layer {@code bundleName} 
to mount, the row selector whose
+   * {@code getColumnHolder} materializes the columns, the columns to 
materialize once resident, and a planner for
+   * the coalesced range reads that make them resident, each paired with the 
(main or external) mapper that executes
+   * it and each executable concurrently via {@link 
PartialSegmentFileMapperV10.PlannedFetch#fetch()}.
    */
   record PrefetchBundle(
       String bundleName,
       QueryableIndex rowSelector,
       Set<String> requiredColumns,
-      List<PartialSegmentFileMapperV10.PlannedFetch> fetches
+      Supplier<List<PartialSegmentFileMapperV10.PlannedFetch>> fetchPlanner
   )
   {
+    /**
+     * Plan this bundle's range reads against CURRENT residency. Call only 
once the bundle is protected from
+     * eviction (its cache-layer hold is acquired); planning drops 
already-resident files from the runs, so a
+     * residency snapshot taken while the bundle is unheld can be invalidated 
by a concurrent eviction
+     * ({@code evictContainer} clears the residency bitmap), leaving a plan 
whose runs never restore the
+     * formerly-resident files. Files that become resident after a held plan 
only shrink the work
+     * ({@code fetchRun} re-checks under its locks); they can never grow it.
+     */
+    List<PartialSegmentFileMapperV10.PlannedFetch> planFetches()
+    {
+      return fetchPlanner.get();
+    }
+  }
+
+  /**
+   * Memoizing column supplier that invalidates on bundle eviction. This index 
(and its supplier maps) is cached
+   * across acquisitions, but the deserialized {@link BaseColumnHolder}s wrap 
slices of container mmaps, and a bundle
+   * eviction between queries unmaps those containers; a plainly-memoized 
holder would then be a dangling reference
+   * into freed memory that never consults {@link SegmentFileMapper#mapFile} 
again (so the non-resident throw can't
+   * catch it). Each {@code get()} compares the {@link 
PartialSegmentFileMapperV10#getBundleGeneration bundle
+   * generation} captured at deserialization time against the current one and 
rebuilds on mismatch; a column that
+   * reads through a base-table parent also captures the parent bundle's 
generation, since its holder references the
+   * parent's buffers (dictionary reuse) and must be rebuilt when only {@code 
__base} was evicted.
+   * <p>
+   * The comparison is only meaningful under the bundle's eviction exclusion 
(the caller's cache-layer hold), which
+   * is where the cursor factory materializes columns: under the hold the 
generation cannot advance, so a holder
+   * returned here stays valid for as long as the hold is held.
+   */
+  private final class EvictionAwareColumnSupplier implements 
Supplier<BaseColumnHolder>
+  {
+    private final String bundleName;
+    private final boolean readsParent;
+    private final Supplier<BaseColumnHolder> delegate;
+
+    @Nullable
+    private volatile Memoized memoized = null;
+
+    private EvictionAwareColumnSupplier(String bundleName, boolean 
readsParent, Supplier<BaseColumnHolder> delegate)
+    {
+      this.bundleName = bundleName;
+      this.readsParent = readsParent;
+      this.delegate = delegate;
+    }
+
+    @Override
+    public BaseColumnHolder get()
+    {
+      final long generation = currentGeneration();
+      Memoized current = memoized;
+      if (current != null && current.generation == generation) {
+        return current.holder;
+      }
+      synchronized (this) {
+        // re-read both under the lock: a concurrent get() may have rebuilt 
already
+        final long lockedGeneration = currentGeneration();
+        current = memoized;
+        if (current != null && current.generation == lockedGeneration) {
+          return current.holder;
+        }
+        final BaseColumnHolder holder = delegate.get();
+        memoized = new Memoized(holder, lockedGeneration);
+        return holder;
+      }
+    }
+
+    private long currentGeneration()
+    {
+      long generation = fileMapper.getBundleGeneration(bundleName);
+      if (readsParent) {
+        generation += 
fileMapper.getBundleGeneration(Projections.BASE_TABLE_PROJECTION_NAME);
+      }
+      return generation;
+    }
+
+    private record Memoized(BaseColumnHolder holder, long generation)
+    {
+    }
   }
 }
diff --git 
a/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndexCursorFactory.java
 
b/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndexCursorFactory.java
index c4b69bc7db3..31edef76062 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndexCursorFactory.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/PartialQueryableIndexCursorFactory.java
@@ -143,7 +143,8 @@ public class PartialQueryableIndexCursorFactory implements 
CursorFactory
       for (PartialQueryableIndex.PrefetchBundle bundle : bundles) {
         final BundleHoldRelease holdRelease = new 
BundleHoldRelease(bundleAcquirer.acquire(bundle.bundleName()));
         holdReleases.add(holdRelease);
-        for (PartialSegmentFileMapperV10.PlannedFetch fetch : 
bundle.fetches()) {
+        // Plan the bundle's range reads only now, under its freshly-acquired 
hold
+        for (PartialSegmentFileMapperV10.PlannedFetch fetch : 
bundle.planFetches()) {
           runDownloads.add(submitRunFetch(bundle.bundleName(), fetch, 
holdRelease));
         }
       }
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 5a32513a8c6..df571ff2647 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
@@ -58,12 +58,20 @@ import java.util.TreeSet;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicLongArray;
 import java.util.concurrent.locks.ReentrantLock;
 
 /**
- * A {@link SegmentFileMapper} that downloads internal files on demand from 
deep storage via a
- * {@link SegmentRangeReader}. This enables partial segment downloads where 
only the files needed for a query are
- * fetched, rather than downloading the entire segment.
+ * A {@link SegmentFileMapper} that fetches internal files from deep storage 
via a {@link SegmentRangeReader},
+ * enabling partial segment downloads where only the files needed for a query 
are fetched rather than the entire
+ * segment.
+ * <p>
+ * <b>Downloads are always explicit.</b> Callers decide what to load and when: 
plan coalesced range reads with
+ * {@link #planFetch}/{@link #planParallelFetch}/{@link 
#planParallelFetchBundle} and execute them via
+ * {@link #fetchFiles}/{@link FetchRun}/{@link PlannedFetch}, or force whole 
units resident with
+ * {@link #ensureBundleDownloaded}/{@link #ensureAllDownloaded}. {@link 
#mapFile} never downloads: it only slices
+ * already-resident files and throws for a file that hasn't been fetched, so a 
caller that forgot to plan fails
+ * loudly instead of triggering a synchronous deep-storage read from whatever 
thread touched the column.
  * <p>
  * Locally, this mapper mirrors the original V10 container structure: each 
container from the segment file is
  * represented as a local sparse file at its original size, and only the byte 
ranges for downloaded internal files are
@@ -83,7 +91,7 @@ import java.util.concurrent.locks.ReentrantLock;
  * different file in the segment's storage location.
  * <p>
  * Thread-safe for concurrent access from multiple queries. Per-file locks 
prevent duplicate downloads of the same
- * internal file.
+ * internal file across concurrently executing runs.
  *
  * @see SegmentFileMapperV10
  * @see SegmentRangeReader
@@ -277,6 +285,8 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
   private final MappedByteBuffer[] containers;
   private final File[] containerFiles;
   private final ReentrantLock[] containerLocks;
+  // per-container eviction generation; see getBundleGeneration
+  private final AtomicLongArray containerGenerations;
 
   // 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
@@ -338,6 +348,7 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
     this.containers = new MappedByteBuffer[numContainers];
     this.containerFiles = new File[numContainers];
     this.containerLocks = new ReentrantLock[numContainers];
+    this.containerGenerations = new AtomicLongArray(numContainers);
     final Map<String, List<Integer>> bundleIndices = new HashMap<>();
     for (int i = 0; i < numContainers; i++) {
       this.containerLocks[i] = new ReentrantLock();
@@ -458,7 +469,16 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
       return null;
     }
 
-    ensureFileDownloaded(name, fileMetadata);
+    // Mapping never downloads, every caller is expected to have made the file 
resident through an explicit plan before
+    // reading it
+    if (!downloadedFiles.contains(name)) {
+      throw DruidException.defensive(
+          "Internal file[%s] of segment file[%s] is not resident; downloads 
must be planned and fetched explicitly "
+          + "before the file can be mapped",
+          name,
+          targetFilename
+      );
+    }
 
     // slice from the container mmap
     final MappedByteBuffer container = containers[fileMetadata.getContainer()];
@@ -755,12 +775,11 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
 
   /**
    * Execute one planned run: take every member's per-file lock in offset 
order (the canonical acquisition order
-   * shared by all {@link #fetchFiles} callers, so overlapping concurrent runs 
can't deadlock, and
-   * {@link #ensureFileDownloaded} holds at most one of these locks so it 
can't participate in a cycle either), trim
-   * files that became resident since planning off the run's edges, then 
stream the remaining span in a single range
-   * read and mark each covered file downloaded after the bytes are on disk 
(bytes-before-bits, preserving the bitmap
-   * corruption invariant). Interior files that became resident mid-plan are 
re-fetched with byte-identical data;
-   * {@link #markDownloaded}'s add-gate keeps the accounting straight.
+   * shared by all {@link #fetchFiles} callers; this is the only code path 
that takes file locks, so overlapping
+   * concurrent runs can't deadlock), trim files that became resident since 
planning off the run's edges, then stream
+   * the remaining span in a single range read and mark each covered file 
downloaded after the bytes are on disk
+   * (bytes-before-bits, preserving the bitmap corruption invariant). Interior 
files that became resident mid-plan are
+   * re-fetched with byte-identical data; {@link #markDownloaded}'s add-gate 
keeps the accounting straight.
    * <p>
    * Runs from one {@link #planFetch} cover disjoint files, so callers may 
execute them concurrently (each is one
    * deep-storage request); the caller must hold the same eviction-exclusion 
{@link #mapFile} requires for the
@@ -918,43 +937,11 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
     return headerSize + container.getStartOffset() + 
fileMetadata.getStartOffset();
   }
 
-  private void ensureFileDownloaded(String name, SegmentInternalFileMetadata 
fileMetadata) throws IOException
-  {
-    // already downloaded, nothing to do
-    if (downloadedFiles.contains(name)) {
-      return;
-    }
-
-    final ReentrantLock lock = fileLocks.computeIfAbsent(name, k -> new 
ReentrantLock());
-    lock.lock();
-    try {
-      checkClosed();
-
-      if (downloadedFiles.contains(name)) {
-        return;
-      }
-
-      ensureContainerInitialized(fileMetadata.getContainer());
-      streamRangeIntoContainer(
-          fileMetadata.getContainer(),
-          computeAbsoluteOffset(fileMetadata),
-          fileMetadata.getStartOffset(),
-          fileMetadata.getSize(),
-          StringUtils.format("file[%s]", name)
-      );
-      markDownloaded(name, fileMetadata.getSize());
-    }
-    finally {
-      lock.unlock();
-      fileLocks.remove(name, lock);
-    }
-  }
-
   /**
    * Public entry point for cache-layer code that wants to ensure a container 
is materialized before any data is
    * downloaded into it (e.g. when a per-bundle cache entry is mounted, the 
entry pre-allocates its container files
-   * so that subsequent {@link #mapFile} calls have somewhere to write into 
and the cache layer can charge the
-   * reservation up front).
+   * so that subsequent fetches have somewhere to write into and the cache 
layer can charge the reservation up
+   * front).
    */
   public void initializeContainer(int containerIndex) throws IOException
   {
@@ -968,9 +955,9 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
    * 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, subsequent
-   * {@link #mapFile} calls for files in this container will re-trigger 
downloads via {@link #initializeContainer}
-   * and the bitmap will be repopulated incrementally.
+   * 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
@@ -1028,6 +1015,41 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
       }
       clearBitmapBit(fileName);
     }
+
+    // last: readers that observe the bumped generation must also observe the 
cleared residency above
+    containerGenerations.incrementAndGet(containerIndex);
+  }
+
+  /**
+   * Monotonic eviction generation for {@code bundleName}: increments every 
time one of the bundle's containers is
+   * evicted ({@link #evictContainer}), summed across this mapper and every 
attached external mapper (a bundle's
+   * containers can span them). Readers that cache objects deserialized from 
container mmaps (e.g.
+   * {@code PartialQueryableIndex}'s memoized column holders) compare 
generations to detect that a cached object may
+   * reference since-unmapped memory and must be rebuilt. A comparison is only 
meaningful while the caller holds the
+   * bundle's eviction exclusion (its cache-layer hold): under the hold the 
generation cannot advance, so a matching
+   * value stays valid for as long as the hold is held.
+   * <p>
+   * A bundle name with no containers in a mapper conservatively reflects 
every container of that mapper (the name
+   * may resolve to a catch-all bundle at the cache layer, e.g. untagged 
legacy segments, so any eviction must
+   * invalidate).
+   */
+  public long getBundleGeneration(String bundleName)
+  {
+    long generation = 0;
+    final List<Integer> indices = bundleToContainerIndices.get(bundleName);
+    if (indices == null) {
+      for (int i = 0; i < containerGenerations.length(); i++) {
+        generation += containerGenerations.get(i);
+      }
+    } else {
+      for (int i : indices) {
+        generation += containerGenerations.get(i);
+      }
+    }
+    for (PartialSegmentFileMapperV10 external : externalMappers.values()) {
+      generation += external.getBundleGeneration(bundleName);
+    }
+    return generation;
   }
 
   private void clearBitmapBit(String name)
diff --git 
a/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexCursorFactoryTest.java
 
b/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexCursorFactoryTest.java
index 6e539530b8e..7b940fd7c30 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexCursorFactoryTest.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexCursorFactoryTest.java
@@ -399,6 +399,104 @@ class PartialQueryableIndexCursorFactoryTest extends 
PartialQueryableIndexCursor
     }
   }
 
+  @Test
+  void testResidentBundleEvictedBeforeHoldAcquisitionIsRefetched() throws 
IOException
+  {
+    // A resident-but-unheld bundle can be evicted between 
makeCursorHolderAsync's plan resolution and the bundle
+    // hold acquisition; evictContainer clears the residency bitmap, so a 
residency snapshot taken before the hold
+    // would omit the formerly-resident files and never restore them, making 
materialization throw on their
+    // non-resident mapFile. Fetch planning is deferred until the bundle's 
hold is acquired (PrefetchBundle
+    // .planFetches), so the plan sees post-eviction residency and re-fetches. 
Model the worst-case timing by
+    // evicting inside acquire() itself, immediately before the hold exists.
+    final CountingRangeReader rangeReader = new 
CountingRangeReader(segmentDir);
+    try (IndexAndMapper opened = openIndex(rangeReader, "evict_window")) {
+      final PartialQueryableIndex index = opened.index();
+      final PartialSegmentFileMapperV10 mapper = opened.mapper();
+
+      // the query's columns are resident before the query arrives
+      mapper.fetchFiles(Set.of("__base/__time", "__base/dim1"));
+      
Assertions.assertTrue(mapper.getDownloadedFiles().containsAll(Set.of("__base/__time",
 "__base/dim1")));
+
+      final ListeningExecutorService exec = directExec();
+      final PartialBundleAcquirer evictingAcquirer = new 
PartialBundleAcquirer()
+      {
+        @Override
+        public Closeable acquire(String bundleName)
+        {
+          // the eviction lands in the unheld window: every container's 
residency is wiped just before the hold
+          for (int i = 0; i < 
mapper.getSegmentFileMetadata().getContainers().size(); i++) {
+            mapper.evictContainer(i);
+          }
+          return () -> {};
+        }
+
+        @Override
+        public <T> AsyncResource<T> submitDownload(Callable<T> task)
+        {
+          return AsyncResources.fromFutureUnmanaged(exec.submit(task));
+        }
+      };
+      final PartialQueryableIndexCursorFactory factory = new 
PartialQueryableIndexCursorFactory(
+          index,
+          QueryableIndexTimeBoundaryInspector.create(index),
+          evictingAcquirer
+      );
+
+      final CursorBuildSpec spec = 
CursorBuildSpec.builder().setPhysicalColumns(Set.of("dim1")).build();
+      try (AsyncCursorHolder asyncHolder = factory.makeCursorHolderAsync(spec);
+           CursorHolder holder = asyncHolder.release()) {
+        Assertions.assertNotNull(holder);
+        Assertions.assertTrue(
+            mapper.getDownloadedFiles().containsAll(Set.of("__base/__time", 
"__base/dim1")),
+            "evicted files must be re-fetched by the post-hold plan; got: " + 
mapper.getDownloadedFiles()
+        );
+      }
+    }
+  }
+
+  @Test
+  void testMemoizedColumnsSurviveBundleEvictionBetweenQueries() throws 
IOException
+  {
+    // Query 1 materializes (and memoizes) its columns; with all holds 
released, the bundle evicts; query 2
+    // re-acquires and re-fetches. The holders memoized by query 1 wrap the 
unmapped old container mmap — serving
+    // them to query 2 would read freed memory without ever consulting mapFile 
(so the non-resident throw can't
+    // catch it). The eviction-aware suppliers detect the bundle-generation 
change and rebuild against the fresh
+    // container, so query 2 reads correct values.
+    final CountingRangeReader rangeReader = new 
CountingRangeReader(segmentDir);
+    try (IndexAndMapper opened = openIndex(rangeReader, 
"evict_between_queries")) {
+      final PartialQueryableIndex index = opened.index();
+      final PartialSegmentFileMapperV10 mapper = opened.mapper();
+      final PartialQueryableIndexCursorFactory factory = new 
PartialQueryableIndexCursorFactory(
+          index,
+          QueryableIndexTimeBoundaryInspector.create(index),
+          noOpAcquirer(directExec())
+      );
+      final CursorBuildSpec spec = 
CursorBuildSpec.builder().setPhysicalColumns(Set.of("dim1")).build();
+
+      try (AsyncCursorHolder asyncHolder = factory.makeCursorHolderAsync(spec);
+           CursorHolder holder = asyncHolder.release()) {
+        Assertions.assertNotNull(holder.asCursor());
+      }
+
+      // every hold is released; the bundle evicts
+      for (int i = 0; i < 
mapper.getSegmentFileMetadata().getContainers().size(); i++) {
+        mapper.evictContainer(i);
+      }
+
+      try (AsyncCursorHolder asyncHolder = factory.makeCursorHolderAsync(spec);
+           CursorHolder holder = asyncHolder.release()) {
+        final Cursor cursor = holder.asCursor();
+        final ColumnValueSelector<?> dim1 = 
cursor.getColumnSelectorFactory().makeColumnValueSelector("dim1");
+        final List<Object> values = new ArrayList<>();
+        while (!cursor.isDone()) {
+          values.add(dim1.getObject());
+          cursor.advance();
+        }
+        Assertions.assertEquals(List.of("a", "a", "b", "b"), values);
+      }
+    }
+  }
+
   @Test
   void testMatchedProjectionDownloadsOnlyRequestedColumns() throws IOException
   {
diff --git 
a/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexTest.java
 
b/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexTest.java
index 64fd5b0b244..b2953cfec24 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexTest.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexTest.java
@@ -219,7 +219,7 @@ class PartialQueryableIndexTest extends 
InitializedNullHandlingTest
   }
 
   @Test
-  void testGetColumnHolderTriggersBaseTableLoad() throws IOException
+  void testGetColumnHolderRequiresResidentFiles() throws IOException
   {
     final CountingRangeReader rangeReader = new 
CountingRangeReader(segmentDir);
     final File cacheDir = newCacheDir("colholder");
@@ -233,13 +233,14 @@ class PartialQueryableIndexTest extends 
InitializedNullHandlingTest
           COLUMN_CONFIG
       );
 
-      // no downloads yet
+      // no downloads yet, and column access never triggers one: an unfetched 
column fails loudly
       Assertions.assertEquals(0, rangeReader.getReadCount());
+      Assertions.assertThrows(DruidException.class, () -> 
index.getColumnHolder(ColumnHolder.TIME_COLUMN_NAME));
+      Assertions.assertEquals(0, rangeReader.getReadCount(), "the failed 
access must not have downloaded anything");
 
-      // accessing a column holder should trigger downloads
+      // after an explicit fetch, deserialization finds the bytes resident
+      mapper.fetchFiles(Set.of("__base/__time", "__base/dim1"));
       
Assertions.assertNotNull(index.getColumnHolder(ColumnHolder.TIME_COLUMN_NAME));
-      Assertions.assertTrue(rangeReader.getReadCount() > 0);
-
       Assertions.assertNotNull(index.getColumnHolder("dim1"));
       Assertions.assertNull(index.getColumnHolder("nonexistent"));
 
@@ -249,7 +250,7 @@ class PartialQueryableIndexTest extends 
InitializedNullHandlingTest
   }
 
   @Test
-  void testGetProjectionMatchesFromMetadataAndLoadsLazily() throws IOException
+  void testGetProjectionMatchesFromMetadataAndReadsAfterPlannedFetch() throws 
IOException
   {
     final CountingRangeReader rangeReader = new 
CountingRangeReader(segmentDir);
     final File cacheDir = newCacheDir("projection");
@@ -276,40 +277,43 @@ class PartialQueryableIndexTest extends 
InitializedNullHandlingTest
       final QueryableIndex projIndex = projection.getRowSelector();
       Assertions.assertNotNull(projIndex);
       Assertions.assertEquals(0, rangeReader.getReadCount(), "this should not 
download files either");
-      // actually accessing a column on the projection triggers the column's 
download
-      
Assertions.assertNotNull(projIndex.getColumnHolder(ColumnHolder.TIME_COLUMN_NAME));
-      Assertions.assertTrue(rangeReader.getReadCount() > 0, "accessing a 
projection column should download");
+
+      // column access never downloads: an unfetched projection column fails 
loudly
+      Assertions.assertThrows(DruidException.class, () -> 
projIndex.getColumnHolder(ColumnHolder.TIME_COLUMN_NAME));
+      Assertions.assertEquals(0, rangeReader.getReadCount(), "the failed 
access must not have downloaded anything");
+
+      // execute the plan for this spec: the projection bundle plus the __base 
parent bundle
+      final PartialQueryableIndex.CursorPrefetchPlan plan = 
index.planCursorPrefetch(matchingSpec);
+      for (PartialQueryableIndex.PrefetchBundle bundle : plan.bundles()) {
+        for (PartialSegmentFileMapperV10.PlannedFetch fetch : 
bundle.planFetches()) {
+          fetch.fetch();
+        }
+      }
+      final int readsAfterFetch = rangeReader.getReadCount();
+      Assertions.assertTrue(readsAfterFetch > 0, "executing the plan 
downloads");
       Assertions.assertEquals(Set.of(IndexIO.V10_FILE_NAME), 
rangeReader.getReadFilenames());
 
-      // downloaded files are scoped to the matched projection's namespace, 
not the base table (if no shared parts)
+      // now reads work, including dim1 which reads through its base parent, 
with no further downloads
+      
Assertions.assertNotNull(projIndex.getColumnHolder(ColumnHolder.TIME_COLUMN_NAME));
+      Assertions.assertNotNull(projIndex.getColumnHolder("dim1"));
+      Assertions.assertEquals(readsAfterFetch, rangeReader.getReadCount(), 
"reads must find everything resident");
+
+      // the plan covered the projection's namespace plus the dim1 parent from 
__base, and nothing else from base
       final Set<String> downloaded = mapper.getDownloadedFiles();
       Assertions.assertTrue(
           downloaded.stream().anyMatch(name -> 
name.startsWith("dim1_hourly_metric1_sum/")),
           "expected at least one file from the matched projection's namespace, 
got " + downloaded
       );
-      Assertions.assertTrue(
-          downloaded.stream().noneMatch(name -> name.startsWith("__base/")),
-          "no base table files should be downloaded when only the projection 
was accessed, got " + downloaded
-      );
-
-      // fetching a projection column which has a base table parent does 
download base table stuff
-      Assertions.assertNotNull(projIndex.getColumnHolder("dim1"));
-      final Set<String> downloadedAfterDim1 = mapper.getDownloadedFiles();
-      Assertions.assertTrue(
-          downloadedAfterDim1.stream().anyMatch(name -> 
name.startsWith("dim1_hourly_metric1_sum/")),
-          "expected at least one file from the matched projection's namespace, 
got " + downloadedAfterDim1
-      );
-      Assertions.assertTrue(
-          downloadedAfterDim1.stream().anyMatch(name -> 
name.startsWith("__base/")),
-          "base table files should be downloaded when a projection column 
shares data with a base table parent, got " + downloadedAfterDim1
-      );
+      Assertions.assertTrue(downloaded.contains("__base/dim1"), "parent must 
be planned, got " + downloaded);
+      Assertions.assertFalse(downloaded.contains("__base/metric1"), "got " + 
downloaded);
     }
   }
 
   @Test
-  void testPerColumnLaziness() throws IOException
+  void testPerColumnFetchIndependence() throws IOException
   {
-    // verify that accessing one column of a projection doesn't download other 
columns
+    // verify that fetching + reading one column leaves other columns 
untouched, and that reading an unfetched
+    // column fails instead of downloading
     final CountingRangeReader rangeReader = new 
CountingRangeReader(segmentDir);
     final File cacheDir = newCacheDir("per_col");
 
@@ -322,26 +326,29 @@ class PartialQueryableIndexTest extends 
InitializedNullHandlingTest
 
       rangeReader.resetCount();
 
-      // access one base table column
+      // fetch + access one base table column
+      mapper.fetchFiles(Set.of("__base/dim1"));
       Assertions.assertNotNull(index.getColumnHolder("dim1"));
       final int countAfterDim1 = rangeReader.getReadCount();
-      Assertions.assertTrue(countAfterDim1 > 0, "accessing dim1 should trigger 
downloads");
+      Assertions.assertTrue(countAfterDim1 > 0, "fetching dim1 should trigger 
downloads");
 
-      // dim1's smoosh entry is downloaded; metric1's is not
+      // dim1's smoosh entry is downloaded; metric1's is not, and accessing it 
fails rather than downloading
       final Set<String> filesAfterDim1 = mapper.getDownloadedFiles();
       Assertions.assertTrue(filesAfterDim1.contains("__base/dim1"), "expected 
__base/dim1 in " + filesAfterDim1);
-      Assertions.assertFalse(filesAfterDim1.contains("__base/metric1"), 
"metric1 should not be downloaded yet");
+      Assertions.assertFalse(filesAfterDim1.contains("__base/metric1"), 
"metric1 should not be downloaded");
+      Assertions.assertThrows(DruidException.class, () -> 
index.getColumnHolder("metric1"));
 
       // access the same column again should not trigger more downloads
       Assertions.assertNotNull(index.getColumnHolder("dim1"));
       Assertions.assertEquals(countAfterDim1, rangeReader.getReadCount(), 
"re-access should be cached");
       Assertions.assertEquals(filesAfterDim1, mapper.getDownloadedFiles(), 
"re-access should not download new files");
 
-      // access a different column should trigger additional downloads for its 
files
+      // after fetching metric1's file, the previously-failing access works
+      mapper.fetchFiles(Set.of("__base/metric1"));
       Assertions.assertNotNull(index.getColumnHolder("metric1"));
       Assertions.assertTrue(
           rangeReader.getReadCount() > countAfterDim1,
-          "accessing metric1 should trigger additional downloads"
+          "fetching metric1 should trigger additional downloads"
       );
 
       // metric1's smoosh entry is now also downloaded
@@ -443,11 +450,12 @@ class PartialQueryableIndexTest extends 
InitializedNullHandlingTest
       Assertions.assertEquals(0, rangeReader.getReadCount(), "planning must 
not download");
       Assertions.assertEquals(1, plan.bundles().size());
       final PartialQueryableIndex.PrefetchBundle bundle = 
plan.bundles().get(0);
-      Assertions.assertEquals(2, bundle.fetches().size(), "one run per mapper: 
" + bundle.fetches());
+      final List<PartialSegmentFileMapperV10.PlannedFetch> fetches = 
bundle.planFetches();
+      Assertions.assertEquals(2, fetches.size(), "one run per mapper: " + 
fetches);
 
       boolean sawMain = false;
       boolean sawExternal = false;
-      for (PartialSegmentFileMapperV10.PlannedFetch fetch : bundle.fetches()) {
+      for (PartialSegmentFileMapperV10.PlannedFetch fetch : fetches) {
         if (fetch.mapper() == mapper) {
           sawMain = true;
           // __time and dimA are adjacent in the main container; dimB is an 
unrequested trailing file, never fetched
@@ -501,7 +509,7 @@ class PartialQueryableIndexTest extends 
InitializedNullHandlingTest
       final PartialQueryableIndex.CursorPrefetchPlan plan = 
index.planCursorPrefetch(
           CursorBuildSpec.builder().setPhysicalColumns(Set.of("dimA")).build()
       );
-      for (PartialSegmentFileMapperV10.PlannedFetch fetch : 
plan.bundles().get(0).fetches()) {
+      for (PartialSegmentFileMapperV10.PlannedFetch fetch : 
plan.bundles().get(0).planFetches()) {
         fetch.fetch();
       }
 
@@ -519,6 +527,41 @@ class PartialQueryableIndexTest extends 
InitializedNullHandlingTest
     }
   }
 
+  @Test
+  void testColumnHolderRebuildsAfterBundleEviction() throws IOException
+  {
+    // The index (and its memoized column suppliers) outlives bundle 
evictions: a memoized holder wraps slices of
+    // the container mmap that eviction unmaps, and once memoized it never 
consults mapFile again, so the
+    // non-resident throw can't catch the staleness. The eviction-aware 
supplier compares bundle generations and
+    // rebuilds instead of returning the dangling holder.
+    final CountingRangeReader rangeReader = new 
CountingRangeReader(segmentDir);
+    final File cacheDir = newCacheDir("evict_rebuild");
+
+    try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, 
cacheDir)) {
+      final PartialQueryableIndex index = new PartialQueryableIndex(
+          mapper.getSegmentFileMetadata(),
+          mapper,
+          COLUMN_CONFIG
+      );
+
+      mapper.fetchFiles(Set.of("__base/dim1"));
+      final ColumnHolder before = index.getColumnHolder("dim1");
+      Assertions.assertNotNull(before);
+      Assertions.assertSame(before, index.getColumnHolder("dim1"), "no 
eviction: the holder is memoized");
+
+      // evict the base bundle's containers, then make the files resident 
again (as a re-acquired query would)
+      for (int containerIndex : 
mapper.getContainerIndicesForBundle(Projections.BASE_TABLE_PROJECTION_NAME)) {
+        mapper.evictContainer(containerIndex);
+      }
+      mapper.fetchFiles(Set.of("__base/dim1"));
+
+      final ColumnHolder after = index.getColumnHolder("dim1");
+      Assertions.assertNotNull(after);
+      Assertions.assertNotSame(before, after, "an evicted bundle's holder must 
be rebuilt, not returned stale");
+      Assertions.assertSame(after, index.getColumnHolder("dim1"), "the rebuilt 
holder memoizes again");
+    }
+  }
+
   @Test
   void testPlanCursorPrefetchIncludesProjectionParentBundle() throws 
IOException
   {
@@ -544,14 +587,15 @@ class PartialQueryableIndexTest extends 
InitializedNullHandlingTest
       Assertions.assertEquals(Projections.BASE_TABLE_PROJECTION_NAME, 
parentBundle.bundleName());
       Assertions.assertEquals(Set.of("dim1"), parentBundle.requiredColumns());
 
+      final List<PartialSegmentFileMapperV10.PlannedFetch> parentFetches = 
parentBundle.planFetches();
       final Set<String> parentFiles = new TreeSet<>();
-      for (PartialSegmentFileMapperV10.PlannedFetch fetch : 
parentBundle.fetches()) {
+      for (PartialSegmentFileMapperV10.PlannedFetch fetch : parentFetches) {
         parentFiles.addAll(fetch.run().files());
       }
       Assertions.assertEquals(Set.of("__base/dim1"), parentFiles);
 
       // executing the parent fetches makes exactly the parent's file 
resident, nothing else from the base bundle
-      for (PartialSegmentFileMapperV10.PlannedFetch fetch : 
parentBundle.fetches()) {
+      for (PartialSegmentFileMapperV10.PlannedFetch fetch : parentFetches) {
         fetch.fetch();
       }
       Assertions.assertEquals(Set.of("__base/dim1"), 
mapper.getDownloadedFiles());
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 561d84dac7d..21e730ca954 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
@@ -22,6 +22,7 @@ package org.apache.druid.segment.file;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.io.Files;
 import com.google.common.primitives.Ints;
+import org.apache.druid.error.DruidException;
 import org.apache.druid.java.util.common.FileUtils;
 import org.apache.druid.java.util.common.StringUtils;
 import org.apache.druid.java.util.common.concurrent.Execs;
@@ -63,10 +64,10 @@ class PartialSegmentFileMapperV10Test
   File tempDir;
 
   @Test
-  void testMapFileDownloadsOnDemand() throws IOException
+  void testMapFileThrowsUntilFetchedThenSlices() throws IOException
   {
     final File segmentFile = buildTestSegment(20, CompressionStrategy.NONE);
-    final File cacheDir = newCacheDir("demand");
+    final File cacheDir = newCacheDir("explicit");
 
     final CountingRangeReader rangeReader = new 
CountingRangeReader(segmentFile.getParentFile());
 
@@ -74,16 +75,21 @@ class PartialSegmentFileMapperV10Test
       // reset count after create fetched metadata
       rangeReader.resetCount();
 
-      // access a single file - should trigger exactly one download
+      // mapFile never downloads: a known-but-unfetched file fails loudly (a 
silent slice would be all zeros)
+      Assertions.assertThrows(DruidException.class, () -> mapper.mapFile("5"));
+      Assertions.assertEquals(0, rangeReader.getReadCount(), "the failed map 
must not have downloaded anything");
+
+      mapper.fetchFiles(Set.of("5"));
+      Assertions.assertEquals(1, rangeReader.getReadCount());
+      Assertions.assertEquals(4, mapper.getDownloadedBytes());
+
       ByteBuffer buf = mapper.mapFile("5");
       Assertions.assertNotNull(buf);
       Assertions.assertEquals(0, buf.position());
       Assertions.assertEquals(4, buf.remaining());
       Assertions.assertEquals(5, buf.getInt());
-      Assertions.assertEquals(1, rangeReader.getReadCount());
-      Assertions.assertEquals(4, mapper.getDownloadedBytes());
 
-      // access the same file again - should NOT trigger another download
+      // mapping again slices the same resident bytes without any further reads
       ByteBuffer buf2 = mapper.mapFile("5");
       Assertions.assertNotNull(buf2);
       Assertions.assertEquals(5, buf2.getInt());
@@ -100,6 +106,7 @@ class PartialSegmentFileMapperV10Test
     final CountingRangeReader rangeReader = new 
CountingRangeReader(segmentFile.getParentFile());
 
     try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, 
cacheDir)) {
+      mapper.ensureAllDownloaded();
       for (int i = 0; i < 20; ++i) {
         ByteBuffer buf = mapper.mapFile(String.valueOf(i));
         Assertions.assertNotNull(buf);
@@ -258,7 +265,7 @@ class PartialSegmentFileMapperV10Test
 
     // large tolerance so only residency, not gaps, can split the span
     try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, 
cacheDir, 1024)) {
-      mapper.mapFile("4");
+      mapper.fetchFiles(Set.of("4"));
       rangeReader.resetCount();
 
       mapper.fetchFiles(Set.of("2", "3", "4", "5", "6", "7"));
@@ -340,12 +347,12 @@ class PartialSegmentFileMapperV10Test
   }
 
   @Test
-  void testConcurrentMapFileVsFetchFiles() throws Exception
+  void testMapFileDuringInFlightFetch() throws Exception
   {
     final File segmentFile = buildTestSegment(20, CompressionStrategy.NONE);
     final File cacheDir = newCacheDir("concurrent_mixed");
 
-    // Gate the first fetch mid-flight so the concurrent mapFile calls 
genuinely contend with an in-progress run.
+    // Gate the second fetch mid-flight so concurrent mapFile calls genuinely 
observe an in-progress run.
     // Discriminate by thread identity, not offset: createMapper's header 
fetch also reads at offset > 0 (the
     // metadata bytes after the fixed preamble) on this thread, and gating 
that would self-deadlock the test.
     final Thread mainThread = Thread.currentThread();
@@ -371,8 +378,10 @@ class PartialSegmentFileMapperV10Test
     };
 
     try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, 
cacheDir, 1024)) {
+      // make a file outside the run resident up front (on this thread, which 
the gate ignores)
+      mapper.fetchFiles(Set.of("10"));
       rangeReader.resetCount();
-      final ExecutorService exec = Execs.multiThreaded(3, "mixed-test-%d");
+      final ExecutorService exec = Execs.multiThreaded(2, "mixed-test-%d");
       try {
         final Future<Void> fetchFuture = exec.submit(() -> {
           mapper.fetchFiles(Set.of("3", "4", "5"));
@@ -380,23 +389,21 @@ class PartialSegmentFileMapperV10Test
         });
         Assertions.assertTrue(fetchStarted.await(10, TimeUnit.SECONDS));
 
-        // a run member: must block on the per-file lock and find the file 
resident after the run finishes
-        final Future<ByteBuffer> memberFuture = exec.submit(() -> 
mapper.mapFile("4"));
-        // a non-member: proceeds in parallel while the run is gated
-        final Future<ByteBuffer> outsideFuture = exec.submit(() -> 
mapper.mapFile("10"));
-
-        final ByteBuffer outside = outsideFuture.get(10, TimeUnit.SECONDS);
+        // reads never wait on downloads: a resident file slices immediately 
while the run is parked, and a run
+        // member that isn't resident yet fails fast instead of blocking for it
+        final ByteBuffer outside = mapper.mapFile("10");
         Assertions.assertNotNull(outside);
         Assertions.assertEquals(10, outside.getInt());
+        Assertions.assertThrows(DruidException.class, () -> 
mapper.mapFile("4"));
 
         releaseFetch.countDown();
         fetchFuture.get(10, TimeUnit.SECONDS);
-        final ByteBuffer member = memberFuture.get(10, TimeUnit.SECONDS);
+        final ByteBuffer member = mapper.mapFile("4");
         Assertions.assertNotNull(member);
         Assertions.assertEquals(4, member.getInt());
 
-        // one read for the run, one for the non-member; the member never 
re-downloaded
-        Assertions.assertEquals(2, rangeReader.getReadCount());
+        // one read for the run; the throwing mapFile and the resident reads 
downloaded nothing
+        Assertions.assertEquals(1, rangeReader.getReadCount());
         Assertions.assertEquals(16, mapper.getDownloadedBytes());
       }
       finally {
@@ -678,8 +685,8 @@ class PartialSegmentFileMapperV10Test
 
     try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, 
cacheDir)) {
       // download just 2 files
-      mapper.mapFile("3");
-      mapper.mapFile("7");
+      mapper.fetchFiles(Set.of("3"));
+      mapper.fetchFiles(Set.of("7"));
 
       // verify local container files exist (all files share one container in 
this test)
       File containerFile = new File(cacheDir, IndexIO.V10_FILE_NAME + 
".container.00000");
@@ -694,53 +701,6 @@ class PartialSegmentFileMapperV10Test
     }
   }
 
-  @Test
-  void testConcurrentMapFile() throws Exception
-  {
-    final File segmentFile = buildTestSegment(20, CompressionStrategy.NONE);
-    final File cacheDir = newCacheDir("concurrent");
-
-    final CountingRangeReader rangeReader = new 
CountingRangeReader(segmentFile.getParentFile());
-
-    try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, 
cacheDir)) {
-      rangeReader.resetCount();
-
-      final int numThreads = 8;
-      final ExecutorService exec = Execs.multiThreaded(numThreads, 
"lazy-test-%d");
-      try {
-        final CountDownLatch startLatch = new CountDownLatch(1);
-        final List<Future<Void>> futures = new ArrayList<>();
-
-        // all threads try to access all 20 files concurrently
-        for (int t = 0; t < numThreads; t++) {
-          futures.add(exec.submit(() -> {
-            startLatch.await();
-            for (int i = 0; i < 20; ++i) {
-              ByteBuffer buf = mapper.mapFile(String.valueOf(i));
-              Assertions.assertNotNull(buf);
-              Assertions.assertEquals(4, buf.remaining());
-              Assertions.assertEquals(i, buf.getInt());
-            }
-            return null;
-          }));
-        }
-
-        startLatch.countDown();
-
-        for (Future<Void> f : futures) {
-          f.get();
-        }
-
-        // each file should have been downloaded exactly once despite 
concurrent access
-        Assertions.assertEquals(20, rangeReader.getReadCount());
-        Assertions.assertEquals(80, mapper.getDownloadedBytes());
-      }
-      finally {
-        exec.shutdownNow();
-      }
-    }
-  }
-
   @Test
   void testProjectionStyleFileNames() throws IOException
   {
@@ -760,6 +720,7 @@ class PartialSegmentFileMapperV10Test
     final DirectoryBackedRangeReader rangeReader = new 
DirectoryBackedRangeReader(baseDir);
 
     try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, 
cacheDir)) {
+      mapper.ensureAllDownloaded();
       for (int i = 0; i < 5; ++i) {
         ByteBuffer buf = 
mapper.mapFile(StringUtils.format("myProjection/col_%d", i));
         Assertions.assertNotNull(buf);
@@ -768,70 +729,6 @@ class PartialSegmentFileMapperV10Test
     }
   }
 
-  @Test
-  void testMatchesEagerMapper() throws IOException
-  {
-    // verify that the lazy mapper produces identical ByteBuffer contents as 
the eager mapper
-    final File segmentFile = buildTestSegment(20, CompressionStrategy.NONE);
-    final File cacheDir = newCacheDir("match_eager");
-
-    final DirectoryBackedRangeReader rangeReader = new 
DirectoryBackedRangeReader(segmentFile.getParentFile());
-    try (SegmentFileMapperV10 eager = SegmentFileMapperV10.create(segmentFile, 
JSON_MAPPER);
-         PartialSegmentFileMapperV10 lazy = createMapper(rangeReader, cacheDir)
-    ) {
-      Assertions.assertEquals(eager.getInternalFilenames(), 
lazy.getInternalFilenames());
-      for (String name : eager.getInternalFilenames()) {
-        ByteBuffer eagerBuf = eager.mapFile(name);
-        ByteBuffer lazyBuf = lazy.mapFile(name);
-        Assertions.assertNotNull(eagerBuf);
-        Assertions.assertNotNull(lazyBuf);
-        Assertions.assertEquals(eagerBuf, lazyBuf);
-      }
-    }
-
-  }
-
-  @Test
-  void testExternalFiles() throws IOException
-  {
-    final String externalName = "external.segment";
-    final File baseDir = new File(tempDir, "base_" + 
ThreadLocalRandom.current().nextInt());
-    FileUtils.mkdirp(baseDir);
-
-    try (SegmentFileBuilderV10 builder = 
SegmentFileBuilderV10.create(JSON_MAPPER, baseDir)) {
-      for (int i = 0; i < 10; ++i) {
-        File tmpFile = new File(tempDir, StringUtils.format("main-%s.bin", i));
-        Files.write(Ints.toByteArray(i), tmpFile);
-        builder.add(StringUtils.format("%d", i), tmpFile);
-      }
-      SegmentFileBuilder external = builder.getExternalBuilder(externalName);
-      for (int i = 10; i < 20; ++i) {
-        File tmpFile = new File(tempDir, StringUtils.format("ext-%s.bin", i));
-        Files.write(Ints.toByteArray(i), tmpFile);
-        external.add(StringUtils.format("%d", i), tmpFile);
-      }
-    }
-
-    final File cacheDir = newCacheDir("ext");
-    final DirectoryBackedRangeReader rangeReader = new 
DirectoryBackedRangeReader(baseDir);
-
-    try (PartialSegmentFileMapperV10 mapper = 
createMapperWithExternal(rangeReader, cacheDir, externalName)) {
-      // verify main file internal files
-      for (int i = 0; i < 10; ++i) {
-        ByteBuffer buf = mapper.mapFile(String.valueOf(i));
-        Assertions.assertNotNull(buf);
-        Assertions.assertEquals(i, buf.getInt());
-      }
-
-      // verify external file internal files via mapExternalFile
-      for (int i = 10; i < 20; ++i) {
-        ByteBuffer buf = mapper.mapExternalFile(externalName, 
String.valueOf(i));
-        Assertions.assertNotNull(buf);
-        Assertions.assertEquals(i, buf.getInt());
-      }
-    }
-  }
-
   @Test
   void testExternalFilesMatchEagerMapper() throws IOException
   {
@@ -860,6 +757,7 @@ class PartialSegmentFileMapperV10Test
     try (SegmentFileMapperV10 eager = SegmentFileMapperV10.create(segmentFile, 
JSON_MAPPER, List.of(externalName));
          PartialSegmentFileMapperV10 lazy = 
createMapperWithExternal(rangeReader, cacheDir, externalName)
     ) {
+      lazy.ensureAllDownloaded();
       // verify main files match
       for (int i = 0; i < 5; ++i) {
         ByteBuffer eagerBuf = eager.mapFile(String.valueOf(i));
@@ -888,11 +786,11 @@ class PartialSegmentFileMapperV10Test
 
     final DirectoryBackedRangeReader rangeReader = new 
DirectoryBackedRangeReader(segmentFile.getParentFile());
 
-    // fetches from range reader and persists header + bitmap
+    // fetches from range reader and persists header + bitmap; separate 
single-file fetches so nothing bridges
     try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, 
cacheDir)) {
-      mapper.mapFile("2");
-      mapper.mapFile("5");
-      mapper.mapFile("8");
+      mapper.fetchFiles(Set.of("2"));
+      mapper.fetchFiles(Set.of("5"));
+      mapper.fetchFiles(Set.of("8"));
       Assertions.assertEquals(12, mapper.getDownloadedBytes());
     }
 
@@ -920,6 +818,7 @@ class PartialSegmentFileMapperV10Test
       Assertions.assertEquals(8, buf8.getInt());
 
       // downloading a new file should still work after restore
+      restored.fetchFiles(Set.of("0"));
       ByteBuffer buf0 = restored.mapFile("0");
       Assertions.assertNotNull(buf0);
       Assertions.assertEquals(0, buf0.getInt());
@@ -951,10 +850,10 @@ class PartialSegmentFileMapperV10Test
     final File cacheDir = newCacheDir("ext_persist");
     final DirectoryBackedRangeReader rangeReader = new 
DirectoryBackedRangeReader(baseDir);
 
-    // create with externals, download some files
+    // create with externals, download some files (external files fetch 
through their own mapper)
     try (PartialSegmentFileMapperV10 mapper = 
createMapperWithExternal(rangeReader, cacheDir, externalName)) {
-      mapper.mapFile("1");
-      mapper.mapExternalFile(externalName, "7");
+      mapper.fetchFiles(Set.of("1"));
+      mapper.getExternalMapper(externalName).fetchFiles(Set.of("7"));
     }
 
     // restore, previously downloaded files should be available
@@ -980,7 +879,7 @@ class PartialSegmentFileMapperV10Test
 
     // populate normally
     try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, 
cacheDir)) {
-      mapper.mapFile("3");
+      mapper.fetchFiles(Set.of("3"));
     }
 
     // corrupt the header file
@@ -995,6 +894,7 @@ class PartialSegmentFileMapperV10Test
     try (PartialSegmentFileMapperV10 recovered = createMapper(rangeReader, 
cacheDir)) {
       Assertions.assertEquals(0, recovered.getDownloadedBytes());
 
+      recovered.fetchFiles(Set.of("3"));
       ByteBuffer buf = recovered.mapFile("3");
       Assertions.assertNotNull(buf);
       Assertions.assertEquals(3, buf.getInt());
@@ -1036,6 +936,7 @@ class PartialSegmentFileMapperV10Test
     try (SegmentFileMapperV10 eager = SegmentFileMapperV10.create(segmentFile, 
JSON_MAPPER);
          PartialSegmentFileMapperV10 lazy = createMapper(freshReader, 
newCacheDir("ensure_all_bulk_match"))) {
       lazy.ensureAllDownloaded();
+      Assertions.assertEquals(eager.getInternalFilenames(), 
lazy.getInternalFilenames());
       for (String name : eager.getInternalFilenames()) {
         Assertions.assertEquals(eager.mapFile(name), lazy.mapFile(name), 
"file[" + name + "]");
       }
@@ -1053,7 +954,7 @@ class PartialSegmentFileMapperV10Test
 
     try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, 
cacheDir)) {
       // make one interior file of the middle container resident first
-      mapper.mapFile("b1/col_1");
+      mapper.fetchFiles(Set.of("b1/col_1"));
       rangeReader.resetCount();
 
       mapper.ensureAllDownloaded();
@@ -1068,6 +969,36 @@ class PartialSegmentFileMapperV10Test
     }
   }
 
+  @Test
+  void testEvictContainerBumpsBundleGeneration() throws IOException
+  {
+    final File segmentFile = buildMultiBundleSegment(3, 4);
+    final File cacheDir = newCacheDir("generation");
+    final CountingRangeReader rangeReader = new 
CountingRangeReader(segmentFile.getParentFile());
+
+    try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, 
cacheDir)) {
+      final long b1Before = mapper.getBundleGeneration("b1");
+      final long b0Before = mapper.getBundleGeneration("b0");
+      final long unknownBefore = mapper.getBundleGeneration("no-such-bundle");
+
+      mapper.ensureBundleDownloaded("b1");
+      Assertions.assertEquals(b1Before, mapper.getBundleGeneration("b1"), 
"downloads must not advance the generation");
+
+      for (int containerIndex : mapper.getContainerIndicesForBundle("b1")) {
+        mapper.evictContainer(containerIndex);
+      }
+
+      Assertions.assertTrue(
+          mapper.getBundleGeneration("b1") > b1Before,
+          "eviction must advance the evicted bundle's generation"
+      );
+      Assertions.assertEquals(b0Before, mapper.getBundleGeneration("b0"), 
"other bundles' generations must not move");
+      // a name with no containers in this mapper (the cache layer can resolve 
names to a catch-all bundle)
+      // conservatively reflects every eviction
+      Assertions.assertTrue(mapper.getBundleGeneration("no-such-bundle") > 
unknownBefore);
+    }
+  }
+
   @Test
   void testEnsureBundleDownloadedFetchesOnlyThatBundle() throws IOException
   {
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 ac63d61c8f8..a6293b5aed2 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
@@ -357,6 +357,7 @@ class PartialSegmentBundleCacheEntryTest
     final PartialSegmentFileMapperV10 mapper = metadata.getFileMapper();
     Assertions.assertNotNull(mapper);
     final String anyFile = 
mapper.getInternalFilenames().stream().findFirst().orElseThrow();
+    mapper.fetchFiles(Set.of(anyFile));
     Assertions.assertNotNull(mapper.mapFile(anyFile));
     Assertions.assertEquals(1, mapper.getDownloadedFiles().size());
     final PartialSegmentBundleCacheEntry.BundleContainerRef evictedRef = 
baseEntry.getContainerRefs().getFirst();
@@ -481,6 +482,7 @@ class PartialSegmentBundleCacheEntryTest
     final PartialSegmentFileMapperV10 mapper = metadata.getFileMapper();
     Assertions.assertNotNull(mapper);
     final String anyFile = 
mapper.getInternalFilenames().stream().findFirst().orElseThrow();
+    mapper.fetchFiles(Set.of(anyFile));
     Assertions.assertNotNull(mapper.mapFile(anyFile));
     final PartialSegmentBundleCacheEntry.BundleContainerRef containerRef = 
base.getContainerRefs().getFirst();
     final String mapperFilename =
diff --git 
a/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentCacheBootstrapTest.java
 
b/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentCacheBootstrapTest.java
index 8a036fb3955..c972f9eee77 100644
--- 
a/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentCacheBootstrapTest.java
+++ 
b/server/src/test/java/org/apache/druid/segment/loading/PartialSegmentCacheBootstrapTest.java
@@ -363,9 +363,9 @@ class PartialSegmentCacheBootstrapTest
   }
 
   @Test
-  void testRestoredEntryCanLazilyFetchUndownloadedFile() throws IOException
+  void testRestoredEntryCanFetchUndownloadedFile() throws IOException
   {
-    // a bootstrap-restored entry must keep the segment's real deep-storage 
range reader so a later query can lazily
+    // a bootstrap-restored entry must keep the segment's real deep-storage 
range reader so a later query can
     // fetch a bundle/column that wasn't on disk at startup. Previously the 
entry held a throwing disk-only reader, so
     // this fetch failed with "bootstrap should only read from local disk".
     primeOnDiskState();
@@ -385,9 +385,10 @@ class PartialSegmentCacheBootstrapTest
         "precondition: " + fileToFetch + " should not be downloaded yet"
     );
 
+    mapper.fetchFiles(Set.of(fileToFetch));
     Assertions.assertNotNull(
         mapper.mapFile(fileToFetch),
-        "restored entry must lazily fetch an un-downloaded file from deep 
storage"
+        "restored entry must be able to fetch an un-downloaded file from deep 
storage"
     );
     Assertions.assertTrue(mapper.getDownloadedFiles().contains(fileToFetch));
   }
@@ -457,6 +458,7 @@ class PartialSegmentCacheBootstrapTest
       mapper.close();
       return;
     }
+    mapper.fetchFiles(Set.of(fileInAgg));
     Assertions.assertNotNull(mapper.mapFile(fileInAgg), "expected file " + 
fileInAgg + " to be downloadable");
     Assertions.assertTrue(mapper.getDownloadedFiles().contains(fileInAgg));
     mapper.close();


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

Reply via email to