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 b20f29c34bb minor: improve partial load metrics (#19632)
b20f29c34bb is described below

commit b20f29c34bb85b4a16dea426eecaeed8970fdc3e
Author: Clint Wylie <[email protected]>
AuthorDate: Sat Jun 27 10:56:53 2026 -0700

    minor: improve partial load metrics (#19632)
---
 .../embedded/query/QueryVirtualStorageTest.java    | 25 ++++----
 .../file/PartialSegmentDownloadListener.java       | 62 +++++++++++++++++++
 .../segment/file/PartialSegmentFileMapperV10.java  | 25 ++++++--
 .../PartialQueryableIndexCursorFactoryTest.java    |  4 +-
 ...PartialQueryableIndexCursorFactoryTestBase.java |  4 +-
 .../segment/PartialQueryableIndexSegmentTest.java  |  4 +-
 .../druid/segment/PartialQueryableIndexTest.java   | 70 +++++++---------------
 .../file/PartialSegmentFileMapperV10Test.java      | 51 +++++++---------
 .../loading/PartialSegmentMetadataCacheEntry.java  | 22 ++++++-
 .../segment/loading/SegmentLocalCacheManager.java  | 23 ++-----
 .../druid/segment/loading/StorageLocation.java     | 39 ++++++++++++
 .../loading/VirtualStorageLocationStats.java       | 23 ++++++-
 .../druid/server/metrics/StorageMonitor.java       | 42 +++++++++++--
 .../loading/PartialSegmentCacheBootstrapTest.java  | 44 +++++---------
 ...SegmentLocalCacheManagerPartialAcquireTest.java | 54 ++++++++++++++++-
 .../druid/server/metrics/LatchableEmitter.java     | 46 ++++++++++++++
 16 files changed, 380 insertions(+), 158 deletions(-)

diff --git 
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/QueryVirtualStorageTest.java
 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/QueryVirtualStorageTest.java
index 24e812a5f34..b9d78d1fa6c 100644
--- 
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/QueryVirtualStorageTest.java
+++ 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/QueryVirtualStorageTest.java
@@ -24,7 +24,6 @@ import org.apache.druid.data.input.impl.LocalInputSource;
 import org.apache.druid.indexer.TaskState;
 import org.apache.druid.java.util.common.HumanReadableBytes;
 import org.apache.druid.java.util.common.StringUtils;
-import org.apache.druid.java.util.emitter.service.ServiceMetricEvent;
 import org.apache.druid.msq.indexing.report.MSQTaskReport;
 import org.apache.druid.msq.indexing.report.MSQTaskReportPayload;
 import org.apache.druid.query.DefaultQueryMetrics;
@@ -74,6 +73,9 @@ class QueryVirtualStorageTest extends EmbeddedClusterTestBase
   private static final long CACHE_SIZE = HumanReadableBytes.parse("1MiB");
   private static final long MAX_SIZE = HumanReadableBytes.parse("100MiB");
   private static final long ESTIMATE_SIZE = HumanReadableBytes.parse("2KiB");
+  // Quiescence wait for the storage monitor: a few times its PT1s emission 
period, so a single missed tick doesn't
+  // falsely read as "idle" while still bounding how long we wait once 
activity has actually stopped.
+  private static final long MONITOR_QUIESCE_TIMEOUT_MILLIS = 3_000L;
 
   private final EmbeddedBroker broker = new EmbeddedBroker();
   private final EmbeddedIndexer indexer = new EmbeddedIndexer();
@@ -191,19 +193,10 @@ class QueryVirtualStorageTest extends 
EmbeddedClusterTestBase
     LatchableEmitter emitter = historical.latchableEmitter();
     LatchableEmitter coordinatorEmitter = coordinator.latchableEmitter();
 
-    // clear out the pipe to get zerod out storage monitor metrics
-    ServiceMetricEvent monitorEvent = emitter.waitForNextEvent(event -> 
event.hasMetricName(StorageMonitor.VSF_LOAD_BEGIN_COUNT));
-    while (monitorEvent != null && monitorEvent.getValue().longValue() > 0) {
-      monitorEvent = emitter.waitForNextEvent(event -> 
event.hasMetricName(StorageMonitor.VSF_LOAD_BEGIN_COUNT));
-    }
-    // then flush (which clears out the internal events stores in test 
emitter) so we can do clean sums across them
+    // Wait for any in-flight storage activity to settle before taking our 
baseline.
+    emitter.awaitMetricQuiescent(StorageMonitor.VSF_LOAD_BEGIN_COUNT, 
MONITOR_QUIESCE_TIMEOUT_MILLIS);
     emitter.flush();
 
-    emitter.waitForNextEvent(event -> 
event.hasMetricName(StorageMonitor.VSF_LOAD_BEGIN_COUNT));
-    long beforeLoads = 
emitter.getMetricEventLongSum(StorageMonitor.VSF_LOAD_BEGIN_COUNT);
-    // confirm flushed
-    Assertions.assertEquals(0, beforeLoads);
-
     // run the queries in order
     Assertions.assertEquals(expectedResults[0], 
Long.parseLong(cluster.runSql(queries[0], dataSource)));
     assertQueryMetrics(1, expectedLoads[0]);
@@ -234,7 +227,7 @@ class QueryVirtualStorageTest extends 
EmbeddedClusterTestBase
     Assertions.assertTrue(hits >= expectedTotalHits, "expected " + 
expectedTotalHits + " but only got " + hits);
     if (expectedTotalHits > 0) {
       emitter.waitForNextEvent(event -> 
event.hasMetricName(StorageMonitor.VSF_HIT_BYTES));
-      
Assertions.assertTrue(emitter.getMetricEventLongSum(StorageMonitor.VSF_HIT_BYTES)
 >= 0);
+      
Assertions.assertTrue(emitter.getMetricEventLongSum(StorageMonitor.VSF_HIT_BYTES)
 > 0);
     }
     emitter.waitForNextEvent(event -> 
event.hasMetricName(StorageMonitor.VSF_LOAD_BEGIN_COUNT));
     long loads = 
emitter.getMetricEventLongSum(StorageMonitor.VSF_LOAD_BEGIN_COUNT);
@@ -242,8 +235,12 @@ class QueryVirtualStorageTest extends 
EmbeddedClusterTestBase
     
Assertions.assertTrue(emitter.getMetricEventLongSum(StorageMonitor.VSF_LOAD_BEGIN_BYTES)
 > 0);
     
Assertions.assertTrue(emitter.getMetricEventLongSum(StorageMonitor.VSF_LOAD_COUNT)
 > 0);
     
Assertions.assertTrue(emitter.getMetricEventLongSum(StorageMonitor.VSF_LOAD_BYTES)
 > 0);
+    emitter.waitForNextEvent(event -> 
event.hasMetricName(StorageMonitor.VSF_READ_COUNT));
+    
Assertions.assertTrue(emitter.getMetricEventLongSum(StorageMonitor.VSF_READ_COUNT)
 > 0);
+    
Assertions.assertTrue(emitter.getMetricEventLongSum(StorageMonitor.VSF_READ_BYTES)
 > 0);
+    
Assertions.assertTrue(emitter.getMetricEventLongSum(StorageMonitor.VSF_READ_TIME)
 >= 0);
     emitter.waitForNextEvent(event -> 
event.hasMetricName(StorageMonitor.VSF_EVICT_COUNT));
-    
Assertions.assertTrue(emitter.getMetricEventLongSum(StorageMonitor.VSF_EVICT_COUNT)
 >= 0);
+    
Assertions.assertTrue(emitter.getMetricEventLongSum(StorageMonitor.VSF_EVICT_COUNT)
 > 0);
     
Assertions.assertTrue(emitter.getMetricEventLongSum(StorageMonitor.VSF_EVICT_BYTES)
 > 0);
     Assertions.assertEquals(0, 
emitter.getMetricEventLongSum(StorageMonitor.VSF_REJECT_COUNT));
     
Assertions.assertTrue(emitter.getLatestMetricEventValue(StorageMonitor.VSF_USED_BYTES,
 0).longValue() > 0);
diff --git 
a/processing/src/main/java/org/apache/druid/segment/file/PartialSegmentDownloadListener.java
 
b/processing/src/main/java/org/apache/druid/segment/file/PartialSegmentDownloadListener.java
new file mode 100644
index 00000000000..111c78b402c
--- /dev/null
+++ 
b/processing/src/main/java/org/apache/druid/segment/file/PartialSegmentDownloadListener.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.segment.file;
+
+/**
+ * Sink for download events emitted by {@link PartialSegmentFileMapperV10} as 
it materializes internal files on demand
+ * from deep storage, for collecting metrics.
+ */
+public interface PartialSegmentDownloadListener
+{
+  PartialSegmentDownloadListener NOOP = new PartialSegmentDownloadListener()
+  {
+    @Override
+    public void onBytesDownloaded(long bytes)
+    {
+      // no-op
+    }
+
+    @Override
+    public void onRangeRead(long bytes, long nanos)
+    {
+      // no-op
+    }
+  };
+
+  /**
+   * Bytes were newly materialized on local disk from deep storage and are now 
resident and usable: an internal file as
+   * it is accessed (fired exactly once per file, from whichever download path 
first records it), or the V10 header on a
+   * genuine cold mount (not a restore from a previous session's on-disk 
state). Distinct from
+   * {@link #onRangeRead bytes pulled over the wire}, which can be larger when 
a partially-present container is re-read.
+   *
+   * @param bytes the materialized byte count
+   */
+  void onBytesDownloaded(long bytes);
+
+  /**
+   * A single deep-storage range read completed: the actual request 
granularity (one read may cover many files when a
+   * whole container is fetched at once). Measures wire bytes and latency, so 
it reflects the deep-storage request count
+   * and cost rather than how many files became resident.
+   *
+   * @param bytes the number of bytes read from deep storage in this request
+   * @param nanos the wall-clock time the read took, in nanoseconds
+   */
+  void onRangeRead(long bytes, long nanos);
+}
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 c622584bea9..ea4895a7f2e 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
@@ -104,14 +104,16 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
       ObjectMapper jsonMapper,
       File localCacheDir,
       String targetFilename,
-      List<String> externals
+      List<String> externals,
+      PartialSegmentDownloadListener downloadListener
   ) throws IOException
   {
     final PartialSegmentFileMapperV10 entryPoint = createForFile(
         rangeReader,
         jsonMapper,
         localCacheDir,
-        targetFilename
+        targetFilename,
+        downloadListener
     );
 
     final Map<String, PartialSegmentFileMapperV10> externalMappers = new 
HashMap<>();
@@ -119,7 +121,7 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
       for (String filename : externals) {
         externalMappers.put(
             filename,
-            createForFile(rangeReader, jsonMapper, localCacheDir, filename)
+            createForFile(rangeReader, jsonMapper, localCacheDir, filename, 
downloadListener)
         );
       }
     }
@@ -139,7 +141,8 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
       SegmentRangeReader rangeReader,
       ObjectMapper jsonMapper,
       File localCacheDir,
-      String targetFilename
+      String targetFilename,
+      PartialSegmentDownloadListener downloadListener
   ) throws IOException
   {
     FileUtils.mkdirp(localCacheDir);
@@ -172,6 +175,7 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
       fetchAndPersistHeader(rangeReader, targetFilename, headerFile);
       result = parseHeaderFile(headerFile, jsonMapper);
       bitmapBuffer = mmapBitmap(headerFile, result);
+      downloadListener.onBytesDownloaded(headerFile.length());
     }
 
     final PartialSegmentFileMapperV10 mapper = new PartialSegmentFileMapperV10(
@@ -180,7 +184,8 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
         rangeReader,
         targetFilename,
         localCacheDir,
-        bitmapBuffer
+        bitmapBuffer,
+        downloadListener
     );
 
     // bitmap-vs-container repair pre-pass: if the bitmap claims a file is 
downloaded but its container file is
@@ -260,6 +265,7 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
   private final MappedByteBuffer bitmapBuffer;
   private final AtomicLong downloadedBytes = new AtomicLong(0);
   private final AtomicBoolean closed = new AtomicBoolean(false);
+  private final PartialSegmentDownloadListener downloadListener;
 
   private PartialSegmentFileMapperV10(
       SegmentFileMetadata metadata,
@@ -267,7 +273,8 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
       SegmentRangeReader rangeReader,
       String targetFilename,
       File localCacheDir,
-      MappedByteBuffer bitmapBuffer
+      MappedByteBuffer bitmapBuffer,
+      PartialSegmentDownloadListener downloadListener
   )
   {
     this.metadata = metadata;
@@ -276,6 +283,7 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
     this.targetFilename = targetFilename;
     this.localCacheDir = localCacheDir;
     this.bitmapBuffer = bitmapBuffer;
+    this.downloadListener = downloadListener;
 
     // build stable file name ordering for bitmap indexing
     this.sortedFileNames = new ArrayList<>(new 
TreeSet<>(metadata.getFiles().keySet()));
@@ -788,6 +796,7 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
   ) throws IOException
   {
     // stream straight from deep storage to the local container file to avoid 
heap-buffering the whole range
+    final long startNanos = System.nanoTime();
     try (ResourceHolder<byte[]> bufHolder = CompressedPools.getOutputBytes();
          InputStream is = rangeReader.readRange(targetFilename, 
absoluteOffset, size);
          RandomAccessFile raf = new 
RandomAccessFile(containerFiles[containerIndex], "rw")) {
@@ -809,6 +818,9 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
         remaining -= read;
       }
     }
+    // Report the completed deep-storage range read (reached only on success). 
One read may cover many files; this is
+    // the actual request granularity, so it measures wire bytes + latency 
rather than bytes that became resident.
+    downloadListener.onRangeRead(size, System.nanoTime() - startNanos);
   }
 
   /**
@@ -821,6 +833,7 @@ public class PartialSegmentFileMapperV10 implements 
SegmentFileMapper
   {
     if (downloadedFiles.add(name)) {
       downloadedBytes.addAndGet(size);
+      downloadListener.onBytesDownloaded(size);
       markDownloadedInBitmap(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 1e2ef260f3f..aac367cce19 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexCursorFactoryTest.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexCursorFactoryTest.java
@@ -47,6 +47,7 @@ import org.apache.druid.segment.column.ColumnType;
 import org.apache.druid.segment.column.RowSignature;
 import org.apache.druid.segment.data.CompressionStrategy;
 import org.apache.druid.segment.file.CountingRangeReader;
+import org.apache.druid.segment.file.PartialSegmentDownloadListener;
 import org.apache.druid.segment.file.PartialSegmentFileMapperV10;
 import org.apache.druid.segment.incremental.IncrementalIndexSchema;
 import org.apache.druid.segment.projections.Projections;
@@ -323,7 +324,8 @@ class PartialQueryableIndexCursorFactoryTest extends 
PartialQueryableIndexCursor
         TestHelper.makeJsonMapper(),
         cacheDir,
         IndexIO.V10_FILE_NAME,
-        Collections.emptyList()
+        Collections.emptyList(),
+        PartialSegmentDownloadListener.NOOP
     );
     try {
       final PartialQueryableIndex index =
diff --git 
a/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexCursorFactoryTestBase.java
 
b/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexCursorFactoryTestBase.java
index 0b8ccf3b11a..334baaa0442 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexCursorFactoryTestBase.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexCursorFactoryTestBase.java
@@ -26,6 +26,7 @@ import org.apache.druid.common.asyncresource.AsyncResources;
 import org.apache.druid.java.util.common.FileUtils;
 import org.apache.druid.segment.column.ColumnConfig;
 import org.apache.druid.segment.file.CountingRangeReader;
+import org.apache.druid.segment.file.PartialSegmentDownloadListener;
 import org.apache.druid.segment.file.PartialSegmentFileMapperV10;
 import org.apache.druid.testing.InitializedNullHandlingTest;
 import org.junit.jupiter.api.io.TempDir;
@@ -62,7 +63,8 @@ abstract class PartialQueryableIndexCursorFactoryTestBase 
extends InitializedNul
         TestHelper.makeJsonMapper(),
         cacheDir,
         IndexIO.V10_FILE_NAME,
-        Collections.emptyList()
+        Collections.emptyList(),
+        PartialSegmentDownloadListener.NOOP
     );
     return new IndexAndMapper(
         new PartialQueryableIndex(mapper.getSegmentFileMetadata(), mapper, 
COLUMN_CONFIG),
diff --git 
a/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexSegmentTest.java
 
b/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexSegmentTest.java
index 72156b740a1..c23e1745d3d 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexSegmentTest.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexSegmentTest.java
@@ -37,6 +37,7 @@ import org.apache.druid.segment.column.ColumnType;
 import org.apache.druid.segment.column.RowSignature;
 import org.apache.druid.segment.data.CompressionStrategy;
 import org.apache.druid.segment.file.CountingRangeReader;
+import org.apache.druid.segment.file.PartialSegmentDownloadListener;
 import org.apache.druid.segment.file.PartialSegmentFileMapperV10;
 import org.apache.druid.segment.incremental.IncrementalIndexSchema;
 import 
org.apache.druid.segment.writeout.OffHeapMemorySegmentWriteOutMediumFactory;
@@ -131,7 +132,8 @@ class PartialQueryableIndexSegmentTest extends 
InitializedNullHandlingTest
         TestHelper.makeJsonMapper(),
         cacheDir,
         IndexIO.V10_FILE_NAME,
-        Collections.emptyList()
+        Collections.emptyList(),
+        PartialSegmentDownloadListener.NOOP
     );
     return new PartialQueryableIndex(mapper.getSegmentFileMetadata(), mapper, 
COLUMN_CONFIG);
   }
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 f40b0c5523b..20c95f5955c 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexTest.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/PartialQueryableIndexTest.java
@@ -38,9 +38,11 @@ import org.apache.druid.segment.column.RowSignature;
 import org.apache.druid.segment.column.ValueType;
 import org.apache.druid.segment.data.CompressionStrategy;
 import org.apache.druid.segment.file.CountingRangeReader;
+import org.apache.druid.segment.file.PartialSegmentDownloadListener;
 import org.apache.druid.segment.file.PartialSegmentFileMapperV10;
 import org.apache.druid.segment.incremental.IncrementalIndexSchema;
 import org.apache.druid.segment.loading.DirectoryBackedRangeReader;
+import org.apache.druid.segment.loading.SegmentRangeReader;
 import org.apache.druid.segment.projections.QueryableProjection;
 import 
org.apache.druid.segment.writeout.OffHeapMemorySegmentWriteOutMediumFactory;
 import org.apache.druid.testing.InitializedNullHandlingTest;
@@ -139,13 +141,7 @@ class PartialQueryableIndexTest extends 
InitializedNullHandlingTest
     final CountingRangeReader rangeReader = new 
CountingRangeReader(segmentDir);
     final File cacheDir = newCacheDir("schema");
 
-    try (PartialSegmentFileMapperV10 mapper = 
PartialSegmentFileMapperV10.create(
-        rangeReader,
-        TestHelper.makeJsonMapper(),
-        cacheDir,
-        IndexIO.V10_FILE_NAME,
-        Collections.emptyList()
-    )) {
+    try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, 
cacheDir)) {
       rangeReader.resetCount();
 
       final PartialQueryableIndex index = new PartialQueryableIndex(
@@ -175,13 +171,7 @@ class PartialQueryableIndexTest extends 
InitializedNullHandlingTest
     final CountingRangeReader rangeReader = new 
CountingRangeReader(segmentDir);
     final File cacheDir = newCacheDir("caps");
 
-    try (PartialSegmentFileMapperV10 mapper = 
PartialSegmentFileMapperV10.create(
-        rangeReader,
-        TestHelper.makeJsonMapper(),
-        cacheDir,
-        IndexIO.V10_FILE_NAME,
-        Collections.emptyList()
-    )) {
+    try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, 
cacheDir)) {
       rangeReader.resetCount();
 
       final PartialQueryableIndex index = new PartialQueryableIndex(
@@ -219,13 +209,7 @@ class PartialQueryableIndexTest extends 
InitializedNullHandlingTest
     final CountingRangeReader rangeReader = new 
CountingRangeReader(segmentDir);
     final File cacheDir = newCacheDir("colholder");
 
-    try (PartialSegmentFileMapperV10 mapper = 
PartialSegmentFileMapperV10.create(
-        rangeReader,
-        TestHelper.makeJsonMapper(),
-        cacheDir,
-        IndexIO.V10_FILE_NAME,
-        Collections.emptyList()
-    )) {
+    try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, 
cacheDir)) {
       rangeReader.resetCount();
 
       final PartialQueryableIndex index = new PartialQueryableIndex(
@@ -255,13 +239,7 @@ class PartialQueryableIndexTest extends 
InitializedNullHandlingTest
     final CountingRangeReader rangeReader = new 
CountingRangeReader(segmentDir);
     final File cacheDir = newCacheDir("projection");
 
-    try (PartialSegmentFileMapperV10 mapper = 
PartialSegmentFileMapperV10.create(
-        rangeReader,
-        TestHelper.makeJsonMapper(),
-        cacheDir,
-        IndexIO.V10_FILE_NAME,
-        Collections.emptyList()
-    )) {
+    try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, 
cacheDir)) {
       final PartialQueryableIndex index = new PartialQueryableIndex(
           mapper.getSegmentFileMetadata(),
           mapper,
@@ -336,13 +314,7 @@ class PartialQueryableIndexTest extends 
InitializedNullHandlingTest
     final CountingRangeReader rangeReader = new 
CountingRangeReader(segmentDir);
     final File cacheDir = newCacheDir("per_col");
 
-    try (PartialSegmentFileMapperV10 mapper = 
PartialSegmentFileMapperV10.create(
-        rangeReader,
-        TestHelper.makeJsonMapper(),
-        cacheDir,
-        IndexIO.V10_FILE_NAME,
-        Collections.emptyList()
-    )) {
+    try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, 
cacheDir)) {
       final PartialQueryableIndex index = new PartialQueryableIndex(
           mapper.getSegmentFileMetadata(),
           mapper,
@@ -389,13 +361,7 @@ class PartialQueryableIndexTest extends 
InitializedNullHandlingTest
     final CountingRangeReader rangeReader = new 
CountingRangeReader(segmentDir);
     final File cacheDir = newCacheDir("no_proj");
 
-    try (PartialSegmentFileMapperV10 mapper = 
PartialSegmentFileMapperV10.create(
-        rangeReader,
-        TestHelper.makeJsonMapper(),
-        cacheDir,
-        IndexIO.V10_FILE_NAME,
-        Collections.emptyList()
-    )) {
+    try (PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, 
cacheDir)) {
       final PartialQueryableIndex index = new PartialQueryableIndex(
           mapper.getSegmentFileMetadata(),
           mapper,
@@ -421,13 +387,7 @@ class PartialQueryableIndexTest extends 
InitializedNullHandlingTest
 
     try (
         QueryableIndex eagerIndex = indexIO.loadIndex(segmentDir);
-        PartialSegmentFileMapperV10 mapper = 
PartialSegmentFileMapperV10.create(
-            rangeReader,
-            TestHelper.makeJsonMapper(),
-            cacheDir,
-            IndexIO.V10_FILE_NAME,
-            Collections.emptyList()
-        )
+        PartialSegmentFileMapperV10 mapper = createMapper(rangeReader, 
cacheDir)
     ) {
       final PartialQueryableIndex partialIndex = new PartialQueryableIndex(
           mapper.getSegmentFileMetadata(),
@@ -466,4 +426,16 @@ class PartialQueryableIndexTest extends 
InitializedNullHandlingTest
     FileUtils.mkdirp(dir);
     return dir;
   }
+
+  private static PartialSegmentFileMapperV10 createMapper(SegmentRangeReader 
rangeReader, File cacheDir) throws IOException
+  {
+    return PartialSegmentFileMapperV10.create(
+        rangeReader,
+        TestHelper.makeJsonMapper(),
+        cacheDir,
+        IndexIO.V10_FILE_NAME,
+        Collections.emptyList(),
+        PartialSegmentDownloadListener.NOOP
+    );
+  }
 }
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 a2403874757..aa9e420d0d4 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
@@ -306,13 +306,7 @@ class PartialSegmentFileMapperV10Test
     final File cacheDir = newCacheDir("ext");
     final DirectoryBackedRangeReader rangeReader = new 
DirectoryBackedRangeReader(baseDir);
 
-    try (PartialSegmentFileMapperV10 mapper = 
PartialSegmentFileMapperV10.create(
-        rangeReader,
-        JSON_MAPPER,
-        cacheDir,
-        IndexIO.V10_FILE_NAME,
-        List.of(externalName)
-    )) {
+    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));
@@ -355,13 +349,7 @@ class PartialSegmentFileMapperV10Test
     final DirectoryBackedRangeReader rangeReader = new 
DirectoryBackedRangeReader(baseDir);
 
     try (SegmentFileMapperV10 eager = SegmentFileMapperV10.create(segmentFile, 
JSON_MAPPER, List.of(externalName));
-         PartialSegmentFileMapperV10 lazy = PartialSegmentFileMapperV10.create(
-             rangeReader,
-             JSON_MAPPER,
-             cacheDir,
-             IndexIO.V10_FILE_NAME,
-             List.of(externalName)
-         )
+         PartialSegmentFileMapperV10 lazy = 
createMapperWithExternal(rangeReader, cacheDir, externalName)
     ) {
       // verify main files match
       for (int i = 0; i < 5; ++i) {
@@ -455,26 +443,14 @@ class PartialSegmentFileMapperV10Test
     final DirectoryBackedRangeReader rangeReader = new 
DirectoryBackedRangeReader(baseDir);
 
     // create with externals, download some files
-    try (PartialSegmentFileMapperV10 mapper = 
PartialSegmentFileMapperV10.create(
-        rangeReader,
-        JSON_MAPPER,
-        cacheDir,
-        IndexIO.V10_FILE_NAME,
-        List.of(externalName)
-    )) {
+    try (PartialSegmentFileMapperV10 mapper = 
createMapperWithExternal(rangeReader, cacheDir, externalName)) {
       mapper.mapFile("1");
       mapper.mapExternalFile(externalName, "7");
     }
 
     // restore, previously downloaded files should be available
     final DirectoryBackedRangeReader freshReader = new 
DirectoryBackedRangeReader(baseDir);
-    try (PartialSegmentFileMapperV10 restored = 
PartialSegmentFileMapperV10.create(
-        freshReader,
-        JSON_MAPPER,
-        cacheDir,
-        IndexIO.V10_FILE_NAME,
-        List.of(externalName)
-    )) {
+    try (PartialSegmentFileMapperV10 restored = 
createMapperWithExternal(freshReader, cacheDir, externalName)) {
       ByteBuffer buf1 = restored.mapFile("1");
       Assertions.assertNotNull(buf1);
       Assertions.assertEquals(1, buf1.getInt());
@@ -650,7 +626,24 @@ class PartialSegmentFileMapperV10Test
         rangeReader,
         JSON_MAPPER,
         localCacheDir,
-        IndexIO.V10_FILE_NAME
+        IndexIO.V10_FILE_NAME,
+        PartialSegmentDownloadListener.NOOP
+    );
+  }
+
+  private static PartialSegmentFileMapperV10 createMapperWithExternal(
+      SegmentRangeReader rangeReader,
+      File localCacheDir,
+      String externalName
+  ) throws IOException
+  {
+    return PartialSegmentFileMapperV10.create(
+        rangeReader,
+        JSON_MAPPER,
+        localCacheDir,
+        IndexIO.V10_FILE_NAME,
+        List.of(externalName),
+        PartialSegmentDownloadListener.NOOP
     );
   }
 
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 e5f8aa55fab..d597b656da5 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
@@ -34,6 +34,7 @@ import org.apache.druid.segment.PartialQueryableIndexSegment;
 import org.apache.druid.segment.ReferenceCountingCloseableObject;
 import org.apache.druid.segment.Segment;
 import org.apache.druid.segment.column.ColumnConfig;
+import org.apache.druid.segment.file.PartialSegmentDownloadListener;
 import org.apache.druid.segment.file.PartialSegmentFileMapperV10;
 import org.apache.druid.segment.file.SegmentFileBuilder;
 import org.apache.druid.segment.file.SegmentFileMetadata;
@@ -289,8 +290,7 @@ public class PartialSegmentMetadataCacheEntry implements 
SegmentCacheEntry, Resi
    * the race where the entry's reservation gets evicted (e.g. cache picks a 
weak entry whose lone hold was released
    * by a concurrent canceler, or {@link StorageLocation#release} fires on the 
static entry from a coordinator drop)
    * while mount() is still in progress. Without this check, mount would 
commit local state for an entry the cache
-   * manager no longer knows about, leaking files on disk and memory mappings. 
Mirrors the same defensive check in
-   * {@code SegmentCacheEntry.mount}. Returns normally if rollback fires; 
callers detect via {@link #isMounted}.
+   * manager no longer knows about, leaking files on disk and memory mappings.
    */
   private void verifyStillReservedOrRollback(StorageLocation mountLocation)
   {
@@ -336,7 +336,8 @@ public class PartialSegmentMetadataCacheEntry implements 
SegmentCacheEntry, Resi
           jsonMapper,
           localCacheDir,
           targetFilename,
-          externalFilenames
+          externalFilenames,
+          new WeakLoadTracker(mountLocation)
       );
 
       final long sizeToAdjust;
@@ -979,4 +980,19 @@ public class PartialSegmentMetadataCacheEntry implements 
SegmentCacheEntry, Resi
       LOG.warn("Failed to delete header file[%s] during unmount of partial 
segment[%s]", file, segmentId);
     }
   }
+
+  private record WeakLoadTracker(StorageLocation mountLocation) implements 
PartialSegmentDownloadListener
+  {
+    @Override
+    public void onBytesDownloaded(long bytes)
+    {
+      mountLocation.trackWeakLoad(bytes);
+    }
+
+    @Override
+    public void onRangeRead(long bytes, long nanos)
+    {
+      mountLocation.trackWeakRangeRead(bytes, nanos);
+    }
+  }
 }
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 b19a9e51a8f..f5d2452d9c3 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
@@ -678,34 +678,19 @@ public class SegmentLocalCacheManager implements 
SegmentCacheManager
                   final PartialSegmentFileMapperV10 mapper = 
reserved.metadata.getFileMapper();
                   final long loadSizeBytes;
                   if (fullDownload) {
-                    // Delta of internal-file bytes downloaded by this task. 
The small header range-read isn't
-                    // counted in getDownloadedBytes; it's the negligible 
mount cost.
+                    // 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, SIEVE-evictable, and
-                    // deleted on drop. Writing through the file mapper alone 
(ensureAllDownloaded) would leave those
-                    // containers unmanaged by any cache entry. Each acquire 
returns a hold added to loadCleanup, so
-                    // the fully-materialized segment stays resident for the 
eager (sync-cursor) caller until the
-                    // action closes; on later eviction acquireCachedSegment 
returns empty and the caller re-downloads.
-                    // acquire() mounts each bundle's parents first, so 
iteration order doesn't matter.
+                    // 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));
                     }
                     mapper.ensureAllDownloaded();
                     loadSizeBytes = mapper.getDownloadedBytes() - 
downloadedBefore;
                   } else {
-                    // Lazy mount: report the header bytes when this task 
caused the mount; 0 when the entry was
-                    // already mounted (a concurrent acquirer or earlier query 
did the load).
+                    // Lazy mount: the header bytes when this task caused the 
mount; 0 when the entry was already
+                    // mounted (a concurrent acquirer or earlier query did the 
load).
                     loadSizeBytes = wasMounted ? 0L : 
mapper.getOnDiskHeaderSize();
                   }
-                  // Record the actual-load bytes on the location 
(VSF_LOAD_*): the header bytes for a lazy mount, or the
-                  // full downloaded delta for a full download. The partial 
metadata entry is always weak (reservePartial
-                  // uses addWeakReservationHold). Skip when nothing was 
pulled (a cache hit / already-mounted re-acquire
-                  // reports 0) so we don't log a spurious 0-byte load. 
On-demand per-column downloads on the lazy path
-                  // happen later at query time and are captured by the 
query-layer load metrics (the
-                  // AcquireSegmentResult load size), not by this per-location 
stat.
-                  if (loadSizeBytes > 0) {
-                    reserved.location.trackWeakLoad(loadSizeBytes);
-                  }
                   final long loadNanos = System.nanoTime() - taskStartNanos;
                   return new AcquireSegmentResult(
                       reserved.metadata::acquireReference,
diff --git 
a/server/src/main/java/org/apache/druid/segment/loading/StorageLocation.java 
b/server/src/main/java/org/apache/druid/segment/loading/StorageLocation.java
index fbc756f8b0b..460a2fa9d2c 100644
--- a/server/src/main/java/org/apache/druid/segment/loading/StorageLocation.java
+++ b/server/src/main/java/org/apache/druid/segment/loading/StorageLocation.java
@@ -794,6 +794,16 @@ public class StorageLocation
     weakStats.getAndUpdate(s -> s.load(size));
   }
 
+  /**
+   * Record a single on-demand deep-storage range read: {@code bytes} pulled 
over the wire in {@code nanos}. One read
+   * may materialize several internal files (whole-container fetch), so this 
is the request-level signal that
+   * complements the per-file {@link #trackWeakLoad}.
+   */
+  public void trackWeakRangeRead(long bytes, long nanos)
+  {
+    weakStats.getAndUpdate(s -> s.rangeRead(bytes, nanos));
+  }
+
   private void trackWeakHold(WeakCacheEntry entry)
   {
     currHoldCount.getAndIncrement();
@@ -1234,6 +1244,9 @@ public class StorageLocation
     private final AtomicLong evictionCount = new AtomicLong(0);
     private final AtomicLong evictionBytes = new AtomicLong(0);
     private final AtomicLong unmountCount = new AtomicLong(0);
+    private final AtomicLong readCount = new AtomicLong(0);
+    private final AtomicLong readBytes = new AtomicLong(0);
+    private final AtomicLong readTimeNanos = new AtomicLong(0);
 
     public WeakStats(AtomicLong sizeUsed, AtomicLong holdCount, AtomicLong 
holdBytes)
     {
@@ -1293,6 +1306,14 @@ public class StorageLocation
       return this;
     }
 
+    public WeakStats rangeRead(long bytes, long nanos)
+    {
+      readCount.getAndIncrement();
+      readBytes.getAndAdd(bytes);
+      readTimeNanos.getAndAdd(nanos);
+      return this;
+    }
+
     @Override
     public long getUsedBytes()
     {
@@ -1365,6 +1386,24 @@ public class StorageLocation
       return rejectionCount.get();
     }
 
+    @Override
+    public long getReadCount()
+    {
+      return readCount.get();
+    }
+
+    @Override
+    public long getReadBytes()
+    {
+      return readBytes.get();
+    }
+
+    @Override
+    public long getReadTimeNanos()
+    {
+      return readTimeNanos.get();
+    }
+
     @VisibleForTesting
     public long getUnmountCount()
     {
diff --git 
a/server/src/main/java/org/apache/druid/segment/loading/VirtualStorageLocationStats.java
 
b/server/src/main/java/org/apache/druid/segment/loading/VirtualStorageLocationStats.java
index 5f63b08b659..035d30cafff 100644
--- 
a/server/src/main/java/org/apache/druid/segment/loading/VirtualStorageLocationStats.java
+++ 
b/server/src/main/java/org/apache/druid/segment/loading/VirtualStorageLocationStats.java
@@ -87,6 +87,24 @@ public interface VirtualStorageLocationStats
    */
   long getRejectCount();
 
+  /**
+   * Number of deep-storage range reads issued during the measurement period 
(on-demand partial downloads). One read
+   * may cover several internal files, so this is the actual request count, 
distinct from {@link #getLoadCount()}.
+   */
+  long getReadCount();
+
+  /**
+   * Total bytes pulled from deep storage by range reads during the 
measurement period. May exceed
+   * {@link #getLoadBytes()} when a partially-present container is re-fetched 
in full.
+   */
+  long getReadBytes();
+
+  /**
+   * Total wall-clock time spent in deep-storage range reads during the 
measurement period, in nanoseconds. Divide by
+   * {@link #getReadCount()} for the average per-read latency.
+   */
+  long getReadTimeNanos();
+
   /**
    * Whether any stats are nonzero.
    */
@@ -103,6 +121,9 @@ public interface VirtualStorageLocationStats
            || getLoadBytes() != 0
            || getEvictionCount() != 0
            || getEvictionBytes() != 0
-           || getRejectCount() != 0;
+           || getRejectCount() != 0
+           || getReadCount() != 0
+           || getReadBytes() != 0
+           || getReadTimeNanos() != 0;
   }
 }
diff --git 
a/server/src/main/java/org/apache/druid/server/metrics/StorageMonitor.java 
b/server/src/main/java/org/apache/druid/server/metrics/StorageMonitor.java
index a0a551d8bb1..23a8bd4bbf4 100644
--- a/server/src/main/java/org/apache/druid/server/metrics/StorageMonitor.java
+++ b/server/src/main/java/org/apache/druid/server/metrics/StorageMonitor.java
@@ -31,6 +31,7 @@ import 
org.apache.druid.segment.loading.VirtualStorageLocationStats;
 
 import javax.annotation.Nullable;
 import java.util.List;
+import java.util.concurrent.TimeUnit;
 
 /**
  * Monitor to emit stats from {@link StorageLocation}.
@@ -114,22 +115,33 @@ public class StorageMonitor extends AbstractMonitor
   /**
    * Number of weakly-held objects whose load was started during the 
measurement period. Incremented when space is
    * reserved, before the object has been downloaded.
+   * <p>
+   * For partial (on-demand) V10 segments, reservation is pessimistic: a 
bundle reserves its full container size up
+   * front even though a query may download only a subset of that bundle's 
columns. So begin (reserved) and complete
+   * (actually downloaded) deliberately diverge on the partial path; begin 
reflects reserved space, the
+   * {@link #VSF_LOAD_BYTES} complete metrics reflect bytes actually pulled 
from deep storage.
    */
   public static final String VSF_LOAD_BEGIN_COUNT = 
"storage/virtual/load/begin/count";
 
   /**
-   * Total bytes of weakly-held objects whose load was started during the 
measurement period.
+   * Total bytes of weakly-held objects whose load was started during the 
measurement period. See
+   * {@link #VSF_LOAD_BEGIN_COUNT} for why this (reserved) can exceed {@link 
#VSF_LOAD_BYTES} (actually downloaded) for
+   * partial segments.
    */
   public static final String VSF_LOAD_BEGIN_BYTES = 
"storage/virtual/load/begin/bytes";
 
   /**
-   * Number of weakly-held objects whose load completed during the measurement 
period. Incremented after the object has
-   * been downloaded and is usable.
+   * Number of load completions during the measurement period. For 
fully-downloaded weak entries this is incremented
+   * once per object after it has been downloaded and is usable. For partial 
(on-demand) V10 segments it additionally
+   * counts each individual internal-file download (a query lazily pulls only 
the columns it needs), so on the partial
+   * path this is file-granular and may exceed {@link #VSF_LOAD_BEGIN_COUNT} 
(which is per reserved entry).
    */
   public static final String VSF_LOAD_COUNT = "storage/virtual/load/count";
 
   /**
-   * Total bytes of weakly-held objects whose load completed during the 
measurement period.
+   * Total bytes whose load completed during the measurement period: bytes 
actually downloaded from deep storage. For
+   * partial segments this includes header range-reads plus every 
lazily-downloaded column, and is the accurate measure
+   * of bandwidth used (vs {@link #VSF_LOAD_BEGIN_BYTES}, which is reserved 
space).
    */
   public static final String VSF_LOAD_BYTES = "storage/virtual/load/bytes";
 
@@ -149,6 +161,25 @@ public class StorageMonitor extends AbstractMonitor
    */
   public static final String VSF_REJECT_COUNT = "storage/virtual/reject/count";
 
+  /**
+   * Number of deep-storage range reads issued during the measurement period 
for on-demand partial downloads. One read
+   * can cover several internal files (a whole-container fetch), so this is 
the actual deep-storage request count;
+   * distinct from {@link #VSF_LOAD_COUNT} (file/object load completions).
+   */
+  public static final String VSF_READ_COUNT = "storage/virtual/read/count";
+
+  /**
+   * Total bytes pulled from deep storage by range reads during the 
measurement period. Can exceed
+   * {@link #VSF_LOAD_BYTES} when a partially-present container is re-fetched 
in full.
+   */
+  public static final String VSF_READ_BYTES = "storage/virtual/read/bytes";
+
+  /**
+   * Total wall-clock time spent in deep-storage range reads during the 
measurement period, in milliseconds. Combined
+   * with {@link #VSF_READ_COUNT} this gives average per-read latency.
+   */
+  public static final String VSF_READ_TIME = "storage/virtual/read/time";
+
   private final List<StorageLocation> locations;
   private final Supplier<ServiceMetricEvent.Builder> builderSupplier;
 
@@ -193,6 +224,9 @@ public class StorageMonitor extends AbstractMonitor
         emitter.emit(builder.setMetric(VSF_EVICT_COUNT, 
weakStats.getEvictionCount()));
         emitter.emit(builder.setMetric(VSF_EVICT_BYTES, 
weakStats.getEvictionBytes()));
         emitter.emit(builder.setMetric(VSF_REJECT_COUNT, 
weakStats.getRejectCount()));
+        emitter.emit(builder.setMetric(VSF_READ_COUNT, 
weakStats.getReadCount()));
+        emitter.emit(builder.setMetric(VSF_READ_BYTES, 
weakStats.getReadBytes()));
+        emitter.emit(builder.setMetric(VSF_READ_TIME, 
TimeUnit.NANOSECONDS.toMillis(weakStats.getReadTimeNanos())));
       }
     }
     return true;
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 fb57c807314..c71e209d004 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
@@ -40,6 +40,7 @@ import org.apache.druid.segment.TestHelper;
 import org.apache.druid.segment.column.ColumnType;
 import org.apache.druid.segment.column.RowSignature;
 import org.apache.druid.segment.data.CompressionStrategy;
+import org.apache.druid.segment.file.PartialSegmentDownloadListener;
 import org.apache.druid.segment.file.PartialSegmentFileMapperV10;
 import org.apache.druid.segment.incremental.IncrementalIndexSchema;
 import org.apache.druid.segment.projections.Projections;
@@ -194,13 +195,7 @@ class PartialSegmentCacheBootstrapTest
     primeOnDiskState();
 
     // remove the aggregate bundle's container file(s), base's containers stay
-    final PartialSegmentFileMapperV10 introspect = 
PartialSegmentFileMapperV10.create(
-        new DirectoryBackedRangeReader(deepStorageDir),
-        JSON_MAPPER,
-        cacheDir,
-        IndexIO.V10_FILE_NAME,
-        List.of()
-    );
+    final PartialSegmentFileMapperV10 introspect = 
createMapper(deepStorageDir, cacheDir);
     final List<Integer> aggContainers = new ArrayList<>();
     final String prefix = AGG_BUNDLE + "/";
     for (var entry : 
introspect.getSegmentFileMetadata().getFiles().entrySet()) {
@@ -236,13 +231,7 @@ class PartialSegmentCacheBootstrapTest
 
     // Remove base's container files. After this, base is unrestorable on 
disk, which makes the aggregate (which
     // depends on base) an orphan that must be deleted rather than restored in 
a degenerate state.
-    final PartialSegmentFileMapperV10 introspect = 
PartialSegmentFileMapperV10.create(
-        new DirectoryBackedRangeReader(deepStorageDir),
-        JSON_MAPPER,
-        cacheDir,
-        IndexIO.V10_FILE_NAME,
-        List.of()
-    );
+    final PartialSegmentFileMapperV10 introspect = 
createMapper(deepStorageDir, cacheDir);
     final Set<Integer> baseContainers = new HashSet<>();
     final Set<Integer> aggContainers = new HashSet<>();
     for (var entry : 
introspect.getSegmentFileMetadata().getFiles().entrySet()) {
@@ -439,13 +428,7 @@ class PartialSegmentCacheBootstrapTest
   {
     primeOnDiskState();
     // download a file in the aggregate bundle to set a bit, then close 
(persists the bitmap)
-    final PartialSegmentFileMapperV10 mapper = 
PartialSegmentFileMapperV10.create(
-        new DirectoryBackedRangeReader(deepStorageDir),
-        JSON_MAPPER,
-        cacheDir,
-        IndexIO.V10_FILE_NAME,
-        List.of()
-    );
+    final PartialSegmentFileMapperV10 mapper = createMapper(deepStorageDir, 
cacheDir);
     final String prefix = AGG_BUNDLE + "/";
     String fileInAgg = null;
     int aggContainerIdx = -1;
@@ -482,13 +465,7 @@ class PartialSegmentCacheBootstrapTest
     Assertions.assertTrue(aggContainer.delete());
 
     // re-open the mapper: the bitmap-vs-container repair should clear the bit 
for the missing file
-    try (PartialSegmentFileMapperV10 restored = 
PartialSegmentFileMapperV10.create(
-        new DirectoryBackedRangeReader(deepStorageDir),
-        JSON_MAPPER,
-        cacheDir,
-        IndexIO.V10_FILE_NAME,
-        List.of()
-    )) {
+    try (PartialSegmentFileMapperV10 restored = createMapper(deepStorageDir, 
cacheDir)) {
       Assertions.assertFalse(
           restored.getDownloadedFiles().contains(fileInAgg),
           "bitmap repair should have cleared the bit for " + fileInAgg
@@ -565,4 +542,15 @@ class PartialSegmentCacheBootstrapTest
     return metadata;
   }
 
+  private static PartialSegmentFileMapperV10 createMapper(File deepStorageDir, 
File cacheDir) throws IOException
+  {
+    return PartialSegmentFileMapperV10.create(
+        new DirectoryBackedRangeReader(deepStorageDir),
+        JSON_MAPPER,
+        cacheDir,
+        IndexIO.V10_FILE_NAME,
+        List.of(),
+        PartialSegmentDownloadListener.NOOP
+    );
+  }
 }
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 8bffa4cf040..01862eb2b9a 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
@@ -58,6 +58,7 @@ import org.apache.druid.segment.column.ColumnConfig;
 import org.apache.druid.segment.column.ColumnType;
 import org.apache.druid.segment.column.RowSignature;
 import org.apache.druid.segment.data.CompressionStrategy;
+import org.apache.druid.segment.file.PartialSegmentDownloadListener;
 import org.apache.druid.segment.file.PartialSegmentFileMapperV10;
 import org.apache.druid.segment.incremental.IncrementalIndexSchema;
 import org.apache.druid.segment.projections.Projections;
@@ -563,10 +564,58 @@ class SegmentLocalCacheManagerPartialAcquireTest
     // Confirm the metadata entry exists on the location and reports fully 
downloaded.
     final CacheEntry entry = loc.getCacheEntry(new 
SegmentCacheEntryIdentifier(SEGMENT_ID));
     Assertions.assertInstanceOf(PartialSegmentMetadataCacheEntry.class, entry);
+    final PartialSegmentMetadataCacheEntry metaEntry = 
(PartialSegmentMetadataCacheEntry) entry;
     Assertions.assertTrue(
-        ((PartialSegmentMetadataCacheEntry) entry).isFullyDownloaded(),
+        metaEntry.isFullyDownloaded(),
         "force-download path must leave the segment fully downloaded after 
acquire returns"
     );
+    Assertions.assertEquals(
+        metaEntry.getFileMapper().getOnDiskHeaderSize() + 
metaEntry.getFileMapper().getDownloadedBytes(),
+        loc.getWeakStats().getLoadBytes(),
+        "full-download bytes (header + every column file) must each be 
recorded exactly once"
+    );
+  }
+
+  @Test
+  void testLazyColumnDownloadsAreRecordedInWeakLoadStats()
+      throws ExecutionException, InterruptedException, IOException
+  {
+    final StorageLocation loc = manager.getLocations().get(0);
+    try (AcquireSegmentAction action = manager.acquireSegment(partialSegment, 
AcquireMode.PARTIAL)) {
+      final AcquireSegmentResult result = action.getSegmentFuture().get();
+      try (Segment segment = 
result.getReferenceProvider().acquireReference().orElseThrow()) {
+        // After a lazy mount only the metadata header has been pulled; no 
column files yet.
+        final long headerLoadBytes = loc.getWeakStats().getLoadBytes();
+        Assertions.assertTrue(headerLoadBytes > 0, "lazy mount records the 
header load");
+
+        // A base-table scan downloads the base bundle's columns on demand at 
cursor-build time.
+        try (var asyncHolder = 
segment.as(CursorFactory.class).makeCursorHolderAsync(CursorBuildSpec.FULL_SCAN))
 {
+          final CountDownLatch ready = new CountDownLatch(1);
+          asyncHolder.addReadyCallback(ready::countDown);
+          Assertions.assertTrue(ready.await(15, TimeUnit.SECONDS));
+          try (CursorHolder cursorHolder = asyncHolder.release()) {
+            Assertions.assertNotNull(cursorHolder.asCursor());
+          }
+        }
+
+        // Those on-demand column downloads are now reflected in the 
per-location completed-load metrics (previously
+        // they were recorded nowhere): loadBytes grew past the header, and 
loadCount grew per downloaded file.
+        final StorageLocation.WeakStats after = loc.getWeakStats();
+        Assertions.assertTrue(
+            after.getLoadBytes() > headerLoadBytes,
+            "on-demand column downloads must add to VSF_LOAD_BYTES; header=" + 
headerLoadBytes
+            + " after=" + after.getLoadBytes()
+        );
+        Assertions.assertTrue(after.getLoadCount() > 1, "each downloaded 
column file increments the load count");
+
+        // The deep-storage range reads that pulled those columns are recorded 
in the request-level VSF_READ_* stats:
+        // one or more range reads, with wire bytes and nonzero latency. (The 
header range-read at mount time is not
+        // counted here — it predates the listener and is accounted as the 
load header above.)
+        Assertions.assertTrue(after.getReadCount() > 0, "on-demand range reads 
must increment VSF_READ_COUNT");
+        Assertions.assertTrue(after.getReadBytes() > 0, "range reads must 
record wire bytes in VSF_READ_BYTES");
+        Assertions.assertTrue(after.getReadTimeNanos() > 0, "range reads must 
record latency in VSF_READ_TIME");
+      }
+    }
   }
 
   @Test
@@ -876,7 +925,8 @@ class SegmentLocalCacheManagerPartialAcquireTest
                  jsonMapper,
                  partialDir,
                  IndexIO.V10_FILE_NAME,
-                 List.of()
+                 List.of(),
+                 PartialSegmentDownloadListener.NOOP
              )) {
       final int numContainers = 
seed.getSegmentFileMetadata().getContainers().size();
       for (int i = 0; i < numContainers; i++) {
diff --git 
a/server/src/test/java/org/apache/druid/server/metrics/LatchableEmitter.java 
b/server/src/test/java/org/apache/druid/server/metrics/LatchableEmitter.java
index fc4b40af6d6..499e15fee5c 100644
--- a/server/src/test/java/org/apache/druid/server/metrics/LatchableEmitter.java
+++ b/server/src/test/java/org/apache/druid/server/metrics/LatchableEmitter.java
@@ -194,6 +194,52 @@ public class LatchableEmitter extends StubServiceEmitter
     return matcher.matchingEvent.get();
   }
 
+  /**
+   * Like {@link #waitForNextEvent(UnaryOperator)} but treats a timeout as a 
valid outcome rather than throwing:
+   * returns {@code true} if a matching event was emitted within {@code 
timeoutMillis} (negative for no timeout),
+   * {@code false} if it timed out. Useful when the absence of an event is 
itself meaningful (e.g. waiting for a
+   * periodic monitor to fall quiet).
+   */
+  public boolean tryWaitForNextEvent(UnaryOperator<EventMatcher> condition, 
long timeoutMillis)
+  {
+    final EventMatcher matcher = condition.apply(new EventMatcher());
+    final WaitCondition waitCondition = new WaitCondition(
+        event -> event instanceof ServiceMetricEvent && 
matcher.test((ServiceMetricEvent) event)
+    );
+    registerWaitConditionNextEvent(waitCondition);
+
+    try {
+      final long awaitTime = timeoutMillis >= 0 ? timeoutMillis : 
Long.MAX_VALUE;
+      return waitCondition.countDownLatch.await(awaitTime, 
TimeUnit.MILLISECONDS);
+    }
+    catch (InterruptedException e) {
+      throw new RuntimeException(e);
+    }
+    finally {
+      waitConditions.remove(waitCondition);
+    }
+  }
+
+  /**
+   * Wait until the given metric has quiesced: its cumulative value (per 
{@link #getMetricEventLongSum}) stops growing
+   * across consecutive emissions, or no matching event is emitted within 
{@code timeoutMillis} (which, for a metric
+   * only emitted while there is activity, itself means idle). Unlike {@link 
#waitForNextEvent}, this never throws on
+   * timeout, a quiet period is the expected terminating signal. A "wait for 
background activity to settle" latch
+   * without a fixed sleep, e.g. to drain stragglers before taking a metric 
baseline. Size {@code timeoutMillis} to a
+   * few times the emitting monitor's period so a single missed tick doesn't 
read as idle.
+   */
+  public void awaitMetricQuiescent(String metricName, long timeoutMillis)
+  {
+    long previous = getMetricEventLongSum(metricName);
+    while (tryWaitForNextEvent(event -> event.hasMetricName(metricName), 
timeoutMillis)) {
+      final long current = getMetricEventLongSum(metricName);
+      if (current == previous) {
+        return;
+      }
+      previous = current;
+    }
+  }
+
   /**
    * Waits indefinitely until the overall aggregate of matching events 
satisfies the given criteria.
    * Use {@link Timeout} on the overall test case to get a timeout.


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

Reply via email to