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

Jackie-Jiang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new bf956b3c204 Collect remote access stats per execution and add direct 
remote access stats (#19859)
bf956b3c204 is described below

commit bf956b3c204680f98982fb7e70733d19594411a5
Author: Xiaotian (Jackie) Jiang <[email protected]>
AuthorDate: Wed Oct 14 17:59:00 2026 -0700

    Collect remote access stats per execution and add direct remote access 
stats (#19859)
---
 .../apache/pinot/common/datatable/DataTable.java   |  8 ++-
 .../pinot/common/response/BrokerResponse.java      |  8 +++
 .../response/broker/BrokerResponseNative.java      | 29 +++++++-
 .../response/broker/BrokerResponseNativeV2.java    | 24 +++++--
 .../response/broker/CursorResponseNative.java      |  8 ++-
 .../response/mapper/TimeSeriesResponseMapper.java  |  4 ++
 .../response/broker/BrokerResponseNativeTest.java  | 80 ++++++++++++++-------
 .../broker/BrokerResponseNativeV2Test.java         | 30 ++++++++
 .../core/operator/InstanceResponseOperator.java    | 16 ++---
 .../query/executor/ServerQueryExecutorV1Impl.java  | 39 ++++++++++
 .../query/pruner/BloomFilterSegmentPruner.java     |  4 +-
 .../query/reduce/ExecutionStatsAggregator.java     | 10 +++
 .../operator/InstanceResponseOperatorTest.java     | 55 +++++++-------
 .../core/query/executor/QueryExecutorTest.java     | 83 ++++++++++++++++++++++
 .../query/pruner/BloomFilterSegmentPrunerTest.java | 13 +++-
 .../query/reduce/ExecutionStatsAggregatorTest.java |  8 +++
 .../pinot/query/runtime/operator/LeafOperator.java | 12 +++-
 .../TimeSeriesExchangeReceiveOperator.java         |  4 +-
 .../query/runtime/operator/LeafOperatorTest.java   |  8 +++
 .../org/apache/pinot/segment/spi/FetchContext.java | 28 ++------
 .../apache/pinot/spi/query/QueryThreadContext.java | 62 ++++++++++++++--
 .../apache/pinot/spi/query/RemoteAccessStats.java  | 75 +++++++++++++++++++
 22 files changed, 504 insertions(+), 104 deletions(-)

diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/datatable/DataTable.java 
b/pinot-common/src/main/java/org/apache/pinot/common/datatable/DataTable.java
index 1187448c81b..989b148405f 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/datatable/DataTable.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/datatable/DataTable.java
@@ -164,11 +164,15 @@ public interface DataTable {
     // Number of remote accesses (local cache misses) made to read segment 
data from a remote storage tier, and the
     // number of bytes they read.
     NUM_REMOTE_ACCESSES(45, "numRemoteAccesses", MetadataValueType.LONG),
-    REMOTE_ACCESS_BYTES(46, "remoteAccessBytes", MetadataValueType.LONG);
+    REMOTE_ACCESS_BYTES(46, "remoteAccessBytes", MetadataValueType.LONG),
+    // The subset of the remote accesses made directly, i.e. outside the 
buffers fetched for the query, and the number
+    // of bytes they read.
+    NUM_DIRECT_REMOTE_ACCESSES(47, "numDirectRemoteAccesses", 
MetadataValueType.LONG),
+    DIRECT_REMOTE_ACCESS_BYTES(48, "directRemoteAccessBytes", 
MetadataValueType.LONG);
 
     // We keep this constant to track the max id added so far for backward 
compatibility.
     // Increase it when adding new keys, but NEVER DECREASE IT!!!
-    private static final int MAX_ID = REMOTE_ACCESS_BYTES.getId();
+    private static final int MAX_ID = DIRECT_REMOTE_ACCESS_BYTES.getId();
 
     private static final MetadataKey[] ID_TO_ENUM_KEY_MAP = new 
MetadataKey[MAX_ID + 1];
     private static final Map<String, MetadataKey> NAME_TO_ENUM_KEY_MAP = new 
HashMap<>();
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/response/BrokerResponse.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/response/BrokerResponse.java
index cd9bc76f160..9fb3a9a79f1 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/response/BrokerResponse.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/response/BrokerResponse.java
@@ -229,6 +229,14 @@ public interface BrokerResponse {
   /// Returns the number of bytes read by the remote accesses (see 
[#getNumRemoteAccesses()]).
   long getRemoteAccessBytes();
 
+  /// Returns the number of remote accesses (see [#getNumRemoteAccesses()]) 
made directly, i.e. outside the buffers
+  /// fetched for the query. Each of them is a round trip to the remote 
storage tier for a single read, so a non-zero
+  /// value points at reads the fetch plan of the query did not cover.
+  long getNumDirectRemoteAccesses();
+
+  /// Returns the number of bytes read by the direct remote accesses (see 
[#getNumDirectRemoteAccesses()]).
+  long getDirectRemoteAccessBytes();
+
   /// Returns the time used to reduce the server responses into the final 
response in milliseconds.
   long getBrokerReduceTimeMs();
 
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNative.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNative.java
index 81749d6002f..7fd2bd5fdf8 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNative.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNative.java
@@ -49,8 +49,9 @@ import org.apache.pinot.spi.utils.JsonUtils;
     "numConsumingSegmentsQueried", "numConsumingSegmentsProcessed", 
"numConsumingSegmentsMatched",
     "minConsumingFreshnessTimeMs", "numSegmentsPrunedByBroker", 
"numSegmentsPrunedByServer",
     "numSegmentsPrunedInvalid", "numSegmentsPrunedByLimit", 
"numSegmentsPrunedByValue", "numRemoteAccesses",
-    "remoteAccessBytes", "brokerReduceTimeMs", "offlineThreadCpuTimeNs", 
"realtimeThreadCpuTimeNs",
-    "offlineSystemActivitiesCpuTimeNs", "realtimeSystemActivitiesCpuTimeNs", 
"offlineResponseSerializationCpuTimeNs",
+    "remoteAccessBytes", "numDirectRemoteAccesses", "directRemoteAccessBytes", 
"brokerReduceTimeMs",
+    "offlineThreadCpuTimeNs", "realtimeThreadCpuTimeNs", 
"offlineSystemActivitiesCpuTimeNs",
+    "realtimeSystemActivitiesCpuTimeNs", 
"offlineResponseSerializationCpuTimeNs",
     "realtimeResponseSerializationCpuTimeNs", "offlineTotalCpuTimeNs", 
"realtimeTotalCpuTimeNs",
     "explainPlanNumEmptyFilterSegments", 
"explainPlanNumMatchAllFilterSegments", "traceInfo", "tablesQueried",
     "offlineThreadMemAllocatedBytes", "realtimeThreadMemAllocatedBytes", 
"offlineResponseSerMemAllocatedBytes",
@@ -101,6 +102,8 @@ public class BrokerResponseNative implements BrokerResponse 
{
   private long _numSegmentsPrunedByValue = 0L;
   private long _numRemoteAccesses = 0L;
   private long _remoteAccessBytes = 0L;
+  private long _numDirectRemoteAccesses = 0L;
+  private long _directRemoteAccessBytes = 0L;
   private long _brokerReduceTimeMs = 0L;
   private long _offlineThreadCpuTimeNs = 0L;
   private long _realtimeThreadCpuTimeNs = 0L;
@@ -480,6 +483,7 @@ public class BrokerResponseNative implements BrokerResponse 
{
     _numSegmentsPrunedByValue = numSegmentsPrunedByValue;
   }
 
+  @JsonInclude(JsonInclude.Include.NON_DEFAULT)
   @Override
   public long getNumRemoteAccesses() {
     return _numRemoteAccesses;
@@ -489,6 +493,7 @@ public class BrokerResponseNative implements BrokerResponse 
{
     _numRemoteAccesses = numRemoteAccesses;
   }
 
+  @JsonInclude(JsonInclude.Include.NON_DEFAULT)
   @Override
   public long getRemoteAccessBytes() {
     return _remoteAccessBytes;
@@ -498,6 +503,26 @@ public class BrokerResponseNative implements 
BrokerResponse {
     _remoteAccessBytes = remoteAccessBytes;
   }
 
+  @JsonInclude(JsonInclude.Include.NON_DEFAULT)
+  @Override
+  public long getNumDirectRemoteAccesses() {
+    return _numDirectRemoteAccesses;
+  }
+
+  public void setNumDirectRemoteAccesses(long numDirectRemoteAccesses) {
+    _numDirectRemoteAccesses = numDirectRemoteAccesses;
+  }
+
+  @JsonInclude(JsonInclude.Include.NON_DEFAULT)
+  @Override
+  public long getDirectRemoteAccessBytes() {
+    return _directRemoteAccessBytes;
+  }
+
+  public void setDirectRemoteAccessBytes(long directRemoteAccessBytes) {
+    _directRemoteAccessBytes = directRemoteAccessBytes;
+  }
+
   @Override
   public long getBrokerReduceTimeMs() {
     return _brokerReduceTimeMs;
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2.java
index c9657f39ea6..d4b2152d4be 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2.java
@@ -47,9 +47,9 @@ import org.apache.pinot.common.response.ProcessingException;
     "numSegmentsQueried", "numSegmentsProcessed", "numSegmentsMatched", 
"numConsumingSegmentsQueried",
     "numConsumingSegmentsProcessed", "numConsumingSegmentsMatched", 
"minConsumingFreshnessTimeMs",
     "numSegmentsPrunedByBroker", "numSegmentsPrunedByServer", 
"numSegmentsPrunedInvalid", "numSegmentsPrunedByLimit",
-    "numSegmentsPrunedByValue", "numRemoteAccesses", "remoteAccessBytes", 
"brokerReduceTimeMs",
-    "offlineThreadCpuTimeNs", "realtimeThreadCpuTimeNs", 
"offlineSystemActivitiesCpuTimeNs",
-    "realtimeSystemActivitiesCpuTimeNs", 
"offlineResponseSerializationCpuTimeNs",
+    "numSegmentsPrunedByValue", "numRemoteAccesses", "remoteAccessBytes", 
"numDirectRemoteAccesses",
+    "directRemoteAccessBytes", "brokerReduceTimeMs", "offlineThreadCpuTimeNs", 
"realtimeThreadCpuTimeNs",
+    "offlineSystemActivitiesCpuTimeNs", "realtimeSystemActivitiesCpuTimeNs", 
"offlineResponseSerializationCpuTimeNs",
     "realtimeResponseSerializationCpuTimeNs", "offlineTotalCpuTimeNs", 
"realtimeTotalCpuTimeNs",
     "explainPlanNumEmptyFilterSegments", 
"explainPlanNumMatchAllFilterSegments", "traceInfo", "tablesQueried",
     "offlineThreadMemAllocatedBytes", "realtimeThreadMemAllocatedBytes", 
"offlineResponseSerMemAllocatedBytes",
@@ -409,16 +409,30 @@ public class BrokerResponseNativeV2 implements 
BrokerResponse {
     return _brokerStats.getLong(StatKey.NUM_SEGMENTS_PRUNED_BY_VALUE);
   }
 
+  @JsonInclude(JsonInclude.Include.NON_DEFAULT)
   @Override
   public long getNumRemoteAccesses() {
     return _brokerStats.getLong(StatKey.NUM_REMOTE_ACCESSES);
   }
 
+  @JsonInclude(JsonInclude.Include.NON_DEFAULT)
   @Override
   public long getRemoteAccessBytes() {
     return _brokerStats.getLong(StatKey.REMOTE_ACCESS_BYTES);
   }
 
+  @JsonInclude(JsonInclude.Include.NON_DEFAULT)
+  @Override
+  public long getNumDirectRemoteAccesses() {
+    return _brokerStats.getLong(StatKey.NUM_DIRECT_REMOTE_ACCESSES);
+  }
+
+  @JsonInclude(JsonInclude.Include.NON_DEFAULT)
+  @Override
+  public long getDirectRemoteAccessBytes() {
+    return _brokerStats.getLong(StatKey.DIRECT_REMOTE_ACCESS_BYTES);
+  }
+
   @Override
   public long getBrokerReduceTimeMs() {
     return _brokerReduceTimeMs;
@@ -582,7 +596,9 @@ public class BrokerResponseNativeV2 implements 
BrokerResponse {
     EARLY_TERMINATION_REASONS(StatMap.Type.STRING_SET),
     LITE_MODE_LEAF_STAGE_LIMIT_REACHED(StatMap.Type.BOOLEAN),
     NUM_REMOTE_ACCESSES(StatMap.Type.LONG),
-    REMOTE_ACCESS_BYTES(StatMap.Type.LONG);
+    REMOTE_ACCESS_BYTES(StatMap.Type.LONG),
+    NUM_DIRECT_REMOTE_ACCESSES(StatMap.Type.LONG),
+    DIRECT_REMOTE_ACCESS_BYTES(StatMap.Type.LONG);
 
     private final StatMap.Type _type;
 
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/response/broker/CursorResponseNative.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/response/broker/CursorResponseNative.java
index d88d30f92e0..a2f04533ab4 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/response/broker/CursorResponseNative.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/response/broker/CursorResponseNative.java
@@ -30,9 +30,9 @@ import org.apache.pinot.common.response.CursorResponse;
     "numSegmentsQueried", "numSegmentsProcessed", "numSegmentsMatched", 
"numConsumingSegmentsQueried",
     "numConsumingSegmentsProcessed", "numConsumingSegmentsMatched", 
"minConsumingFreshnessTimeMs",
     "numSegmentsPrunedByBroker", "numSegmentsPrunedByServer", 
"numSegmentsPrunedInvalid", "numSegmentsPrunedByLimit",
-    "numSegmentsPrunedByValue", "numRemoteAccesses", "remoteAccessBytes", 
"brokerReduceTimeMs",
-    "offlineThreadCpuTimeNs", "realtimeThreadCpuTimeNs", 
"offlineSystemActivitiesCpuTimeNs",
-    "realtimeSystemActivitiesCpuTimeNs", 
"offlineResponseSerializationCpuTimeNs",
+    "numSegmentsPrunedByValue", "numRemoteAccesses", "remoteAccessBytes", 
"numDirectRemoteAccesses",
+    "directRemoteAccessBytes", "brokerReduceTimeMs", "offlineThreadCpuTimeNs", 
"realtimeThreadCpuTimeNs",
+    "offlineSystemActivitiesCpuTimeNs", "realtimeSystemActivitiesCpuTimeNs", 
"offlineResponseSerializationCpuTimeNs",
     "realtimeResponseSerializationCpuTimeNs", "offlineTotalCpuTimeNs", 
"realtimeTotalCpuTimeNs",
     "explainPlanNumEmptyFilterSegments", 
"explainPlanNumMatchAllFilterSegments", "traceInfo", "tableQueries",
     "groupsTrimmed", "approximateFunctionApplied",
@@ -85,6 +85,8 @@ public class CursorResponseNative extends 
BrokerResponseNative implements Cursor
     setNumSegmentsPrunedByValue(response.getNumSegmentsPrunedByValue());
     setNumRemoteAccesses(response.getNumRemoteAccesses());
     setRemoteAccessBytes(response.getRemoteAccessBytes());
+    setNumDirectRemoteAccesses(response.getNumDirectRemoteAccesses());
+    setDirectRemoteAccessBytes(response.getDirectRemoteAccessBytes());
     setBrokerReduceTimeMs(response.getBrokerReduceTimeMs());
     setOfflineThreadCpuTimeNs(response.getOfflineThreadCpuTimeNs());
     setRealtimeThreadCpuTimeNs(response.getRealtimeThreadCpuTimeNs());
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/response/mapper/TimeSeriesResponseMapper.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/response/mapper/TimeSeriesResponseMapper.java
index 1d71932777b..7e3a81d18ad 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/response/mapper/TimeSeriesResponseMapper.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/response/mapper/TimeSeriesResponseMapper.java
@@ -170,6 +170,10 @@ public class TimeSeriesResponseMapper {
         getLongMetadataValue(metadata, 
DataTable.MetadataKey.NUM_REMOTE_ACCESSES));
     map.merge(BrokerResponseNativeV2.StatKey.REMOTE_ACCESS_BYTES,
         getLongMetadataValue(metadata, 
DataTable.MetadataKey.REMOTE_ACCESS_BYTES));
+    map.merge(BrokerResponseNativeV2.StatKey.NUM_DIRECT_REMOTE_ACCESSES,
+        getLongMetadataValue(metadata, 
DataTable.MetadataKey.NUM_DIRECT_REMOTE_ACCESSES));
+    map.merge(BrokerResponseNativeV2.StatKey.DIRECT_REMOTE_ACCESS_BYTES,
+        getLongMetadataValue(metadata, 
DataTable.MetadataKey.DIRECT_REMOTE_ACCESS_BYTES));
     brokerResponse.addBrokerStats(map);
     brokerResponse.setTimeUsedMs(getLongMetadataValue(metadata, 
DataTable.MetadataKey.TIME_USED_MS));
   }
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/response/broker/BrokerResponseNativeTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/response/broker/BrokerResponseNativeTest.java
index 7ede9c2932b..c0b37673202 100644
--- 
a/pinot-common/src/test/java/org/apache/pinot/common/response/broker/BrokerResponseNativeTest.java
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/response/broker/BrokerResponseNativeTest.java
@@ -19,12 +19,16 @@
 package org.apache.pinot.common.response.broker;
 
 import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
 import java.io.IOException;
 import org.apache.pinot.spi.exception.QueryErrorCode;
-import org.testng.Assert;
+import org.apache.pinot.spi.utils.JsonUtils;
 import org.testng.annotations.Test;
 
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
+
 
 public class BrokerResponseNativeTest {
   @Test
@@ -33,9 +37,39 @@ public class BrokerResponseNativeTest {
     BrokerResponseNative expected = BrokerResponseNative.EMPTY_RESULT;
     String brokerString = expected.toJsonString();
     BrokerResponseNative actual = 
BrokerResponseNative.fromJsonString(brokerString);
-    Assert.assertEquals(actual.getNumDocsScanned(), 
expected.getNumDocsScanned());
-    Assert.assertEquals(actual.getTimeUsedMs(), expected.getTimeUsedMs());
-    Assert.assertEquals(actual.getResultTable(), expected.getResultTable());
+    assertEquals(actual.getNumDocsScanned(), expected.getNumDocsScanned());
+    assertEquals(actual.getTimeUsedMs(), expected.getTimeUsedMs());
+    assertEquals(actual.getResultTable(), expected.getResultTable());
+  }
+
+  /// The remote access stats are serialized only when a remote access 
happened, and the direct ones only when a direct
+  /// remote access happened.
+  @Test
+  public void testRemoteAccessStatsSerializedOnlyWhenNonZero()
+      throws IOException {
+    BrokerResponseNative response = new BrokerResponseNative();
+    JsonNode json = JsonUtils.stringToJsonNode(response.toJsonString());
+    assertFalse(json.has("numRemoteAccesses"));
+    assertFalse(json.has("remoteAccessBytes"));
+    assertFalse(json.has("numDirectRemoteAccesses"));
+    assertFalse(json.has("directRemoteAccessBytes"));
+
+    response.setNumRemoteAccesses(7L);
+    response.setRemoteAccessBytes(700L);
+    json = JsonUtils.stringToJsonNode(response.toJsonString());
+    assertEquals(json.get("numRemoteAccesses").asLong(), 7L);
+    assertEquals(json.get("remoteAccessBytes").asLong(), 700L);
+    assertFalse(json.has("numDirectRemoteAccesses"));
+    assertFalse(json.has("directRemoteAccessBytes"));
+
+    response.setNumDirectRemoteAccesses(2L);
+    response.setDirectRemoteAccessBytes(20L);
+    json = JsonUtils.stringToJsonNode(response.toJsonString());
+    assertEquals(json.get("numDirectRemoteAccesses").asLong(), 2L);
+    assertEquals(json.get("directRemoteAccessBytes").asLong(), 20L);
+    BrokerResponseNative deserialized = 
BrokerResponseNative.fromJsonString(response.toJsonString());
+    assertEquals(deserialized.getNumDirectRemoteAccesses(), 2L);
+    assertEquals(deserialized.getDirectRemoteAccessBytes(), 20L);
   }
 
   @Test
@@ -44,9 +78,8 @@ public class BrokerResponseNativeTest {
     BrokerResponseNative expected = BrokerResponseNative.NO_TABLE_RESULT;
     String brokerString = expected.toJsonString();
     BrokerResponseNative actual = 
BrokerResponseNative.fromJsonString(brokerString);
-    Assert.assertEquals(actual.getExceptions().get(0).getErrorCode(),
-        QueryErrorCode.BROKER_RESOURCE_MISSING.getId());
-    Assert.assertEquals(actual.getExceptions().get(0).getMessage(),
+    assertEquals(actual.getExceptions().get(0).getErrorCode(), 
QueryErrorCode.BROKER_RESOURCE_MISSING.getId());
+    assertEquals(actual.getExceptions().get(0).getMessage(),
         QueryErrorCode.BROKER_RESOURCE_MISSING.getDefaultMessage());
   }
 
@@ -58,12 +91,12 @@ public class BrokerResponseNativeTest {
     expected.addException(new QueryProcessingException(400, errorMsgStr));
     String brokerString = expected.toJsonString();
     BrokerResponseNative newBrokerResponse = 
BrokerResponseNative.fromJsonString(brokerString);
-    
Assert.assertEquals(newBrokerResponse.getExceptions().get(0).getErrorCode(),
+    assertEquals(newBrokerResponse.getExceptions().get(0).getErrorCode(),
         QueryErrorCode.BROKER_RESOURCE_MISSING.getId());
-    Assert.assertEquals(newBrokerResponse.getExceptions().get(0).getMessage(),
+    assertEquals(newBrokerResponse.getExceptions().get(0).getMessage(),
         QueryErrorCode.BROKER_RESOURCE_MISSING.getDefaultMessage());
-    
Assert.assertEquals(newBrokerResponse.getExceptions().get(1).getErrorCode(), 
400);
-    Assert.assertEquals(newBrokerResponse.getExceptions().get(1).getMessage(), 
errorMsgStr);
+    assertEquals(newBrokerResponse.getExceptions().get(1).getErrorCode(), 400);
+    assertEquals(newBrokerResponse.getExceptions().get(1).getMessage(), 
errorMsgStr);
   }
 
   @Test
@@ -73,10 +106,10 @@ public class BrokerResponseNativeTest {
     expected.setMaterializedViewQueried("orders_by_day_OFFLINE");
 
     String brokerString = expected.toJsonString();
-    Assert.assertTrue(brokerString.contains("\"materializedViewQueried\""));
+    assertTrue(brokerString.contains("\"materializedViewQueried\""));
 
     BrokerResponseNative actual = 
BrokerResponseNative.fromJsonString(brokerString);
-    Assert.assertEquals(actual.getMaterializedViewQueried(), 
expected.getMaterializedViewQueried());
+    assertEquals(actual.getMaterializedViewQueried(), 
expected.getMaterializedViewQueried());
   }
 
   @Test
@@ -84,7 +117,7 @@ public class BrokerResponseNativeTest {
       throws IOException {
     BrokerResponseNative response = new BrokerResponseNative();
     String json = response.toJsonString();
-    Assert.assertFalse(json.contains("materializedViewQueried"),
+    assertFalse(json.contains("materializedViewQueried"),
         "Null materializedViewQueried should be suppressed by 
@JsonInclude(NON_NULL)");
   }
 
@@ -92,26 +125,25 @@ public class BrokerResponseNativeTest {
   public void testServerStatsRoundTrip()
       throws IOException {
     BrokerResponseNative expected = new BrokerResponseNative();
-    String stats =
-        
"Server=SubmitDelayMs,ResponseDelayMs,ResponseSize,DeserializationTimeMs,RequestSentDelayMs;"
-            + "pinot-server-0_O=0,1,7571,0,0;pinot-server-1_O=0,1,7574,0,0";
+    String stats = 
"Server=SubmitDelayMs,ResponseDelayMs,ResponseSize,DeserializationTimeMs,RequestSentDelayMs;"
+        + "pinot-server-0_O=0,1,7571,0,0;pinot-server-1_O=0,1,7574,0,0";
     expected.setServerStats(stats);
 
     BrokerResponseNative actual = 
BrokerResponseNative.fromJsonString(expected.toJsonString());
-    Assert.assertEquals(actual.getServerStats(), stats);
+    assertEquals(actual.getServerStats(), stats);
   }
 
   @Test
   public void testServerStatsAbsentWhenNull()
       throws IOException {
     BrokerResponseNative response = new BrokerResponseNative();
-    JsonNode tree = new ObjectMapper().readTree(response.toJsonString());
-    Assert.assertFalse(tree.has("serverStats"));
+    JsonNode tree = JsonUtils.stringToJsonNode(response.toJsonString());
+    assertFalse(tree.has("serverStats"));
   }
 
   @Test
   public void testServerStatsDefaultsToNull() {
-    Assert.assertNull(new BrokerResponseNative().getServerStats());
+    assertNull(new BrokerResponseNative().getServerStats());
   }
 
   /// Regression test for backward-compatible deserialization: 
[BrokerResponseNative] does not
@@ -127,7 +159,7 @@ public class BrokerResponseNativeTest {
         + "\"details\":{\"count\":2}},\"numDocsScanned\":5}";
     BrokerResponseNative actual = BrokerResponseNative.fromJsonString(json);
     // The unknown field is ignored on the legacy impl; other fields still 
deserialize correctly.
-    Assert.assertEquals(actual.getNumDocsScanned(), 5);
-    Assert.assertTrue(actual.getResponseMetadata().isEmpty());
+    assertEquals(actual.getNumDocsScanned(), 5);
+    assertTrue(actual.getResponseMetadata().isEmpty());
   }
 }
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2Test.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2Test.java
index 22cf653d6e5..139fa96aa76 100644
--- 
a/pinot-common/src/test/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2Test.java
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2Test.java
@@ -52,6 +52,36 @@ public class BrokerResponseNativeV2Test {
     assertTrue(brokerResponse.isPartialResult());
   }
 
+  /// The remote access stats are serialized only when a remote access 
happened, and the direct ones only when a direct
+  /// remote access happened.
+  @Test
+  public void testRemoteAccessStatsSerializedOnlyWhenNonZero() {
+    BrokerResponseNativeV2 response = new BrokerResponseNativeV2();
+    JsonNode json = JsonUtils.objectToJsonNode(response);
+    assertFalse(json.has("numRemoteAccesses"));
+    assertFalse(json.has("remoteAccessBytes"));
+    assertFalse(json.has("numDirectRemoteAccesses"));
+    assertFalse(json.has("directRemoteAccessBytes"));
+
+    StatMap<BrokerResponseNativeV2.StatKey> stats = new 
StatMap<>(BrokerResponseNativeV2.StatKey.class);
+    stats.merge(BrokerResponseNativeV2.StatKey.NUM_REMOTE_ACCESSES, 7L);
+    stats.merge(BrokerResponseNativeV2.StatKey.REMOTE_ACCESS_BYTES, 700L);
+    response.addBrokerStats(stats);
+    json = JsonUtils.objectToJsonNode(response);
+    assertEquals(json.get("numRemoteAccesses").asLong(), 7L);
+    assertEquals(json.get("remoteAccessBytes").asLong(), 700L);
+    assertFalse(json.has("numDirectRemoteAccesses"));
+    assertFalse(json.has("directRemoteAccessBytes"));
+
+    stats = new StatMap<>(BrokerResponseNativeV2.StatKey.class);
+    stats.merge(BrokerResponseNativeV2.StatKey.NUM_DIRECT_REMOTE_ACCESSES, 2L);
+    stats.merge(BrokerResponseNativeV2.StatKey.DIRECT_REMOTE_ACCESS_BYTES, 
20L);
+    response.addBrokerStats(stats);
+    json = JsonUtils.objectToJsonNode(response);
+    assertEquals(json.get("numDirectRemoteAccesses").asLong(), 2L);
+    assertEquals(json.get("directRemoteAccessBytes").asLong(), 20L);
+  }
+
   @Test
   public void testResponseMetadataSerialization()
       throws Exception {
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/InstanceResponseOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/InstanceResponseOperator.java
index 5d63ac36cdd..28da024c3e4 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/InstanceResponseOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/InstanceResponseOperator.java
@@ -29,6 +29,7 @@ import 
org.apache.pinot.core.query.request.context.QueryContext;
 import org.apache.pinot.segment.spi.FetchContext;
 import org.apache.pinot.segment.spi.SegmentContext;
 import org.apache.pinot.spi.accounting.ThreadResourceSnapshot;
+import org.apache.pinot.spi.query.QueryThreadContext;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -99,16 +100,6 @@ public class InstanceResponseOperator extends 
BaseOperator<InstanceResponseBlock
         String.valueOf(_threadMemAllocatedBytes));
     
instanceResponseBlock.addMetadata(MetadataKey.SYSTEM_ACTIVITIES_CPU_TIME_NS.getName(),
         String.valueOf(_systemActivitiesCpuTimeNs));
-    long numRemoteAccesses = 0;
-    long remoteAccessBytes = 0;
-    for (FetchContext fetchContext : _fetchContexts) {
-      numRemoteAccesses += fetchContext.getNumRemoteAccesses();
-      remoteAccessBytes += fetchContext.getRemoteAccessBytes();
-    }
-    if (numRemoteAccesses > 0) {
-      
instanceResponseBlock.addMetadata(MetadataKey.NUM_REMOTE_ACCESSES.getName(), 
Long.toString(numRemoteAccesses));
-      
instanceResponseBlock.addMetadata(MetadataKey.REMOTE_ACCESS_BYTES.getName(), 
Long.toString(remoteAccessBytes));
-    }
     Integer implicitLimit = 
QueryOptionsUtils.getLiteModeImplicitLeafStageLimit(
         _queryContext.getQueryOptions());
     // false-positive when table has exactly implicitLimit rows
@@ -162,9 +153,12 @@ public class InstanceResponseOperator extends 
BaseOperator<InstanceResponseBlock
     }
   }
 
+  /// Releases the fetched buffers of all the segments, and reports their 
remote accesses to the query execution.
   public void releaseAll() {
     for (int i = 0; i < _fetchContextSize; i++) {
-      _segmentContexts.get(i).getIndexSegment().release(_fetchContexts.get(i));
+      FetchContext fetchContext = _fetchContexts.get(i);
+      _segmentContexts.get(i).getIndexSegment().release(fetchContext);
+      
QueryThreadContext.mergeRemoteAccessStats(fetchContext.getRemoteAccessStats());
     }
   }
 
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/executor/ServerQueryExecutorV1Impl.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/executor/ServerQueryExecutorV1Impl.java
index a948f1575b1..e0625491dd9 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/executor/ServerQueryExecutorV1Impl.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/executor/ServerQueryExecutorV1Impl.java
@@ -85,6 +85,7 @@ import org.apache.pinot.spi.plugin.PluginManager;
 import org.apache.pinot.spi.query.QueryExecutionContext;
 import org.apache.pinot.spi.query.QueryScanCostContext;
 import org.apache.pinot.spi.query.QueryThreadContext;
+import org.apache.pinot.spi.query.RemoteAccessStats;
 import org.apache.pinot.spi.trace.Tracer;
 import org.apache.pinot.spi.trace.Tracing;
 import org.apache.pinot.spi.utils.CommonConstants.Accounting.ScanKillingMode;
@@ -161,6 +162,44 @@ public class ServerQueryExecutorV1Impl implements 
QueryExecutor {
   @Override
   public InstanceResponseBlock execute(ServerQueryRequest queryRequest, 
ExecutorService executorService,
       @Nullable ResultsBlockStreamer streamer, @Nullable PlanMaker 
planMakerOverride) {
+    QueryThreadContext threadContext = QueryThreadContext.getIfAvailable();
+    // NOTE: In production code, threadContext should never be null. It might 
be null in tests when QueryThreadContext
+    //       is not set up.
+    if (threadContext == null) {
+      return executeWithTrace(queryRequest, executorService, streamer, 
planMakerOverride);
+    }
+    // Collect the remote accesses of this execution from this thread and the 
ones it spawns, and report them in the
+    // response metadata. The stats are replaced per execution because the 
ones of the thread context are shared by
+    // every execution spawned from it, e.g. the two of a hybrid table.
+    RemoteAccessStats remoteAccessStats = new RemoteAccessStats();
+    threadContext.setRemoteAccessStats(remoteAccessStats);
+    InstanceResponseBlock instanceResponse =
+        executeWithTrace(queryRequest, executorService, streamer, 
planMakerOverride);
+    addRemoteAccessStats(instanceResponse, remoteAccessStats);
+    return instanceResponse;
+  }
+
+  /// Adds the remote access stats to the response metadata, each pair of keys 
only when its count is not zero.
+  private static void addRemoteAccessStats(InstanceResponseBlock 
instanceResponse,
+      RemoteAccessStats remoteAccessStats) {
+    long numRemoteAccesses = remoteAccessStats.getNumRemoteAccesses();
+    if (numRemoteAccesses == 0) {
+      return;
+    }
+    instanceResponse.addMetadata(MetadataKey.NUM_REMOTE_ACCESSES.getName(), 
Long.toString(numRemoteAccesses));
+    instanceResponse.addMetadata(MetadataKey.REMOTE_ACCESS_BYTES.getName(),
+        Long.toString(remoteAccessStats.getRemoteAccessBytes()));
+    long numDirectRemoteAccesses = 
remoteAccessStats.getNumDirectRemoteAccesses();
+    if (numDirectRemoteAccesses > 0) {
+      
instanceResponse.addMetadata(MetadataKey.NUM_DIRECT_REMOTE_ACCESSES.getName(),
+          Long.toString(numDirectRemoteAccesses));
+      
instanceResponse.addMetadata(MetadataKey.DIRECT_REMOTE_ACCESS_BYTES.getName(),
+          Long.toString(remoteAccessStats.getDirectRemoteAccessBytes()));
+    }
+  }
+
+  private InstanceResponseBlock executeWithTrace(ServerQueryRequest 
queryRequest, ExecutorService executorService,
+      @Nullable ResultsBlockStreamer streamer, @Nullable PlanMaker 
planMakerOverride) {
     if (!queryRequest.isEnableTrace()) {
       return executeInternal(queryRequest, executorService, streamer, 
planMakerOverride);
     }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/pruner/BloomFilterSegmentPruner.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/pruner/BloomFilterSegmentPruner.java
index 623ad153baf..d3b6d00b363 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/pruner/BloomFilterSegmentPruner.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/pruner/BloomFilterSegmentPruner.java
@@ -37,6 +37,7 @@ import org.apache.pinot.segment.spi.datasource.DataSource;
 import org.apache.pinot.segment.spi.datasource.DataSourceMetadata;
 import org.apache.pinot.segment.spi.index.reader.BloomFilterReader;
 import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.query.QueryThreadContext;
 
 
 /// Prunes segments using bloom filters for EQ and IN predicates, with 
optional prefetch.
@@ -99,11 +100,12 @@ public class BloomFilterSegmentPruner extends 
ValueBasedSegmentPruner {
       }
       return pruneFunc.apply(fetchContexts);
     } finally {
-      // Release the prefetched bloom filters
+      // Release the prefetched bloom filters, and report their remote 
accesses to the query execution
       for (int i = 0; i < numSegments; i++) {
         FetchContext fetchContext = fetchContexts[i];
         if (fetchContext != null) {
           segments.get(i).release(fetchContext);
+          
QueryThreadContext.mergeRemoteAccessStats(fetchContext.getRemoteAccessStats());
         }
       }
     }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/ExecutionStatsAggregator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/ExecutionStatsAggregator.java
index 15cc51ef7b9..af7b892d21e 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/ExecutionStatsAggregator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/ExecutionStatsAggregator.java
@@ -73,6 +73,8 @@ public class ExecutionStatsAggregator {
   private long _numSegmentsPrunedByValue = 0L;
   private long _numRemoteAccesses = 0L;
   private long _remoteAccessBytes = 0L;
+  private long _numDirectRemoteAccesses = 0L;
+  private long _directRemoteAccessBytes = 0L;
   private long _explainPlanNumEmptyFilterSegments = 0L;
   private long _explainPlanNumMatchAllFilterSegments = 0L;
   private boolean _groupsTrimmed = false;
@@ -221,6 +223,10 @@ public class ExecutionStatsAggregator {
         l -> _numSegmentsPrunedByValue += l);
     withNotNullLongMetadata(metadata, 
DataTable.MetadataKey.NUM_REMOTE_ACCESSES, l -> _numRemoteAccesses += l);
     withNotNullLongMetadata(metadata, 
DataTable.MetadataKey.REMOTE_ACCESS_BYTES, l -> _remoteAccessBytes += l);
+    withNotNullLongMetadata(metadata, 
DataTable.MetadataKey.NUM_DIRECT_REMOTE_ACCESSES,
+        l -> _numDirectRemoteAccesses += l);
+    withNotNullLongMetadata(metadata, 
DataTable.MetadataKey.DIRECT_REMOTE_ACCESS_BYTES,
+        l -> _directRemoteAccessBytes += l);
 
     String explainPlanNumEmptyFilterSegments =
         
metadata.get(DataTable.MetadataKey.EXPLAIN_PLAN_NUM_EMPTY_FILTER_SEGMENTS.getName());
@@ -309,6 +315,8 @@ public class ExecutionStatsAggregator {
     
brokerResponseNative.setNumSegmentsPrunedByValue(_numSegmentsPrunedByValue);
     brokerResponseNative.setNumRemoteAccesses(_numRemoteAccesses);
     brokerResponseNative.setRemoteAccessBytes(_remoteAccessBytes);
+    brokerResponseNative.setNumDirectRemoteAccesses(_numDirectRemoteAccesses);
+    brokerResponseNative.setDirectRemoteAccessBytes(_directRemoteAccessBytes);
     
brokerResponseNative.setExplainPlanNumEmptyFilterSegments(_explainPlanNumEmptyFilterSegments);
     
brokerResponseNative.setExplainPlanNumMatchAllFilterSegments(_explainPlanNumMatchAllFilterSegments);
     if (_numConsumingSegmentsQueried > 0) {
@@ -408,6 +416,8 @@ public class ExecutionStatsAggregator {
     putLong(metadata, DataTable.MetadataKey.NUM_SEGMENTS_PRUNED_BY_VALUE, 
_numSegmentsPrunedByValue);
     putLong(metadata, DataTable.MetadataKey.NUM_REMOTE_ACCESSES, 
_numRemoteAccesses);
     putLong(metadata, DataTable.MetadataKey.REMOTE_ACCESS_BYTES, 
_remoteAccessBytes);
+    putLong(metadata, DataTable.MetadataKey.NUM_DIRECT_REMOTE_ACCESSES, 
_numDirectRemoteAccesses);
+    putLong(metadata, DataTable.MetadataKey.DIRECT_REMOTE_ACCESS_BYTES, 
_directRemoteAccessBytes);
     putLong(metadata, 
DataTable.MetadataKey.EXPLAIN_PLAN_NUM_EMPTY_FILTER_SEGMENTS,
         _explainPlanNumEmptyFilterSegments);
     putLong(metadata, 
DataTable.MetadataKey.EXPLAIN_PLAN_NUM_MATCH_ALL_FILTER_SEGMENTS,
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/InstanceResponseOperatorTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/InstanceResponseOperatorTest.java
index ac700099b3b..e5c484df863 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/InstanceResponseOperatorTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/InstanceResponseOperatorTest.java
@@ -29,9 +29,14 @@ import 
org.apache.pinot.core.operator.blocks.results.SelectionResultsBlock;
 import org.apache.pinot.core.operator.combine.BaseCombineOperator;
 import org.apache.pinot.core.query.request.context.QueryContext;
 import org.apache.pinot.segment.spi.FetchContext;
+import org.apache.pinot.segment.spi.IndexSegment;
+import org.apache.pinot.segment.spi.SegmentContext;
+import org.apache.pinot.spi.query.QueryThreadContext;
+import org.apache.pinot.spi.query.RemoteAccessStats;
 import 
org.apache.pinot.spi.utils.CommonConstants.Broker.Request.QueryOptionKey;
 import org.testng.annotations.Test;
 
+import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 import static org.testng.Assert.assertEquals;
@@ -128,36 +133,30 @@ public class InstanceResponseOperatorTest {
         
.containsKey(MetadataKey.LITE_MODE_LEAF_STAGE_LIMIT_REACHED.getName()));
   }
 
+  /// Releasing the fetched buffers reports the remote accesses recorded on 
the fetch contexts to the query execution.
   @Test
-  public void testRemoteAccessesSummedAcrossFetchContexts() {
-    SelectionResultsBlock resultsBlock = mock(SelectionResultsBlock.class);
-    when(resultsBlock.getResultsMetadata()).thenReturn(new HashMap<>());
-    QueryContext queryContext = mock(QueryContext.class);
-    when(queryContext.getQueryOptions()).thenReturn(new HashMap<>());
+  public void testReleaseReportsRemoteAccessesOfFetchContexts() {
     FetchContext fetchContext1 = new FetchContext(UUID.randomUUID(), 
"segment1", Set.of("col"));
-    fetchContext1.addRemoteAccesses(3, 300);
     FetchContext fetchContext2 = new FetchContext(UUID.randomUUID(), 
"segment2", Set.of("col"));
-    fetchContext2.addRemoteAccesses(4, 400);
-    InstanceResponseOperator operator = new 
InstanceResponseOperator(mock(BaseCombineOperator.class), List.of(),
-        List.of(fetchContext1, fetchContext2), queryContext);
-    InstanceResponseBlock responseBlock = 
operator.buildInstanceResponseBlock(resultsBlock);
-    Map<String, String> metadata = responseBlock.getResponseMetadata();
-    assertEquals(metadata.get(MetadataKey.NUM_REMOTE_ACCESSES.getName()), "7");
-    assertEquals(metadata.get(MetadataKey.REMOTE_ACCESS_BYTES.getName()), 
"700");
-  }
-
-  @Test
-  public void testNoRemoteAccessMetadataWithoutRemoteAccesses() {
-    SelectionResultsBlock resultsBlock = mock(SelectionResultsBlock.class);
-    when(resultsBlock.getResultsMetadata()).thenReturn(new HashMap<>());
-    QueryContext queryContext = mock(QueryContext.class);
-    when(queryContext.getQueryOptions()).thenReturn(new HashMap<>());
-    FetchContext fetchContext = new FetchContext(UUID.randomUUID(), "segment", 
Set.of("col"));
-    InstanceResponseOperator operator = new 
InstanceResponseOperator(mock(BaseCombineOperator.class), List.of(),
-        List.of(fetchContext), queryContext);
-    InstanceResponseBlock responseBlock = 
operator.buildInstanceResponseBlock(resultsBlock);
-    Map<String, String> metadata = responseBlock.getResponseMetadata();
-    
assertFalse(metadata.containsKey(MetadataKey.NUM_REMOTE_ACCESSES.getName()));
-    
assertFalse(metadata.containsKey(MetadataKey.REMOTE_ACCESS_BYTES.getName()));
+    IndexSegment segment1 = mock(IndexSegment.class);
+    IndexSegment segment2 = mock(IndexSegment.class);
+    doAnswer(_ -> {
+      fetchContext1.getRemoteAccessStats().addRemoteAccesses(3, 300);
+      return null;
+    }).when(segment1).release(fetchContext1);
+    doAnswer(_ -> {
+      fetchContext2.getRemoteAccessStats().addRemoteAccesses(4, 400);
+      return null;
+    }).when(segment2).release(fetchContext2);
+    InstanceResponseOperator operator = new 
InstanceResponseOperator(mock(BaseCombineOperator.class),
+        List.of(new SegmentContext(segment1), new SegmentContext(segment2)), 
List.of(fetchContext1, fetchContext2),
+        mock(QueryContext.class));
+
+    try (QueryThreadContext threadContext = 
QueryThreadContext.openForSseTest()) {
+      operator.releaseAll();
+      RemoteAccessStats remoteAccessStats = 
threadContext.getRemoteAccessStats();
+      assertEquals(remoteAccessStats.getNumRemoteAccesses(), 7);
+      assertEquals(remoteAccessStats.getRemoteAccessBytes(), 700);
+    }
   }
 }
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/query/executor/QueryExecutorTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/query/executor/QueryExecutorTest.java
index ecd4a223d88..61965252a8a 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/query/executor/QueryExecutorTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/query/executor/QueryExecutorTest.java
@@ -32,11 +32,13 @@ import java.util.Optional;
 import java.util.UUID;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 import org.apache.commons.configuration2.PropertiesConfiguration;
 import org.apache.commons.io.FileUtils;
 import org.apache.helix.HelixManager;
+import org.apache.pinot.common.datatable.DataTable.MetadataKey;
 import org.apache.pinot.common.metrics.ServerMetrics;
 import org.apache.pinot.common.request.InstanceRequest;
 import org.apache.pinot.common.request.context.ExpressionContext;
@@ -48,6 +50,8 @@ import 
org.apache.pinot.core.operator.blocks.InstanceResponseBlock;
 import org.apache.pinot.core.operator.blocks.results.AggregationResultsBlock;
 import org.apache.pinot.core.operator.blocks.results.GroupByResultsBlock;
 import org.apache.pinot.core.operator.timeseries.TimeSeriesOperatorUtils;
+import org.apache.pinot.core.plan.Plan;
+import org.apache.pinot.core.plan.maker.PlanMaker;
 import 
org.apache.pinot.core.query.aggregation.function.TimeSeriesAggregationFunction;
 import org.apache.pinot.core.query.request.ServerQueryRequest;
 import org.apache.pinot.core.query.request.context.QueryContext;
@@ -85,6 +89,7 @@ import org.testng.annotations.AfterClass;
 import org.testng.annotations.BeforeClass;
 import org.testng.annotations.Test;
 
+import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 import static org.testng.Assert.assertEquals;
@@ -226,6 +231,84 @@ public class QueryExecutorTest {
     assertEquals(((AggregationResultsBlock) 
instanceResponse.getResultsBlock()).getResults().get(0), 0.0);
   }
 
+  @Test
+  public void testNoRemoteAccessStatsWithoutRemoteAccesses() {
+    String query = "SELECT COUNT(*) FROM " + OFFLINE_TABLE_NAME;
+    InstanceRequest instanceRequest = new InstanceRequest(0L, 
CalciteSqlCompiler.compileToBrokerRequest(query));
+    instanceRequest.setSearchSegments(_segmentNames);
+    Map<String, String> responseMetadata = 
execute(instanceRequest).getResponseMetadata();
+    
assertFalse(responseMetadata.containsKey(MetadataKey.NUM_REMOTE_ACCESSES.getName()));
+    
assertFalse(responseMetadata.containsKey(MetadataKey.REMOTE_ACCESS_BYTES.getName()));
+    
assertFalse(responseMetadata.containsKey(MetadataKey.NUM_DIRECT_REMOTE_ACCESSES.getName()));
+    
assertFalse(responseMetadata.containsKey(MetadataKey.DIRECT_REMOTE_ACCESS_BYTES.getName()));
+  }
+
+  /// The remote accesses reported while the plan executes, from the query 
thread and the ones it spawns, are reported
+  /// in the response metadata, with the direct ones counted in the totals and 
reported on their own.
+  @Test
+  public void testRemoteAccessStatsReportedForTheExecution()
+      throws Exception {
+    String query = "SELECT COUNT(*) FROM " + OFFLINE_TABLE_NAME;
+    InstanceRequest instanceRequest = new InstanceRequest(0L, 
CalciteSqlCompiler.compileToBrokerRequest(query));
+    instanceRequest.setSearchSegments(_segmentNames);
+    ServerQueryRequest queryRequest =
+        new ServerQueryRequest(instanceRequest, ServerMetrics.get(), 
System.currentTimeMillis());
+    Plan plan = mock(Plan.class);
+    when(plan.execute()).thenAnswer(_ -> {
+      QueryThreadContext.addRemoteAccesses(1, 10);
+      QUERY_RUNNERS.submit(() -> QueryThreadContext.addDirectRemoteAccesses(2, 
20)).get();
+      return new InstanceResponseBlock();
+    });
+    PlanMaker planMaker = mock(PlanMaker.class);
+    when(planMaker.makeInstancePlan(any(), any(), any())).thenReturn(plan);
+    try (QueryThreadContext ignore = QueryThreadContext.openForSseTest()) {
+      Map<String, String> responseMetadata =
+          _queryExecutor.execute(queryRequest, QUERY_RUNNERS, null, 
planMaker).getResponseMetadata();
+      
assertEquals(responseMetadata.get(MetadataKey.NUM_REMOTE_ACCESSES.getName()), 
"3");
+      
assertEquals(responseMetadata.get(MetadataKey.REMOTE_ACCESS_BYTES.getName()), 
"30");
+      
assertEquals(responseMetadata.get(MetadataKey.NUM_DIRECT_REMOTE_ACCESSES.getName()),
 "2");
+      
assertEquals(responseMetadata.get(MetadataKey.DIRECT_REMOTE_ACCESS_BYTES.getName()),
 "20");
+    }
+  }
+
+  /// Executions spawned from one thread context, like the two of a hybrid 
table, each report their own remote
+  /// accesses.
+  @Test
+  public void 
testExecutionsSpawnedFromOneThreadContextReportTheirOwnRemoteAccesses()
+      throws Exception {
+    String query = "SELECT COUNT(*) FROM " + OFFLINE_TABLE_NAME;
+    InstanceRequest instanceRequest = new InstanceRequest(0L, 
CalciteSqlCompiler.compileToBrokerRequest(query));
+    instanceRequest.setSearchSegments(_segmentNames);
+    Plan plan = mock(Plan.class);
+    when(plan.execute()).thenAnswer(_ -> {
+      QueryThreadContext.addRemoteAccesses(1, 10);
+      return new InstanceResponseBlock();
+    });
+    PlanMaker planMaker = mock(PlanMaker.class);
+    when(planMaker.makeInstancePlan(any(), any(), any())).thenReturn(plan);
+    ExecutorService executor = 
QueryThreadContext.contextAwareExecutorService(Executors.newFixedThreadPool(2));
+    try (QueryThreadContext ignore = QueryThreadContext.openForSseTest()) {
+      List<Future<Map<String, String>>> futures = new ArrayList<>();
+      for (int i = 0; i < 2; i++) {
+        futures.add(executor.submit(() -> {
+          ServerQueryRequest queryRequest =
+              new ServerQueryRequest(instanceRequest, ServerMetrics.get(), 
System.currentTimeMillis());
+          return _queryExecutor.execute(queryRequest, QUERY_RUNNERS, null, 
planMaker).getResponseMetadata();
+        }));
+      }
+      for (Future<Map<String, String>> future : futures) {
+        Map<String, String> responseMetadata = future.get();
+        
assertEquals(responseMetadata.get(MetadataKey.NUM_REMOTE_ACCESSES.getName()), 
"1");
+        
assertEquals(responseMetadata.get(MetadataKey.REMOTE_ACCESS_BYTES.getName()), 
"10");
+        // Reported only when a direct remote access happened
+        
assertFalse(responseMetadata.containsKey(MetadataKey.NUM_DIRECT_REMOTE_ACCESSES.getName()));
+        
assertFalse(responseMetadata.containsKey(MetadataKey.DIRECT_REMOTE_ACCESS_BYTES.getName()));
+      }
+    } finally {
+      executor.shutdownNow();
+    }
+  }
+
   @Test
   public void testTimeSeriesSumQuery() {
     TimeBuckets timeBuckets = 
TimeBuckets.ofSeconds(TIME_SERIES_TEST_START_TIME, Duration.ofHours(2), 2);
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/query/pruner/BloomFilterSegmentPrunerTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/query/pruner/BloomFilterSegmentPrunerTest.java
index 2865581173c..d7b31670c16 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/query/pruner/BloomFilterSegmentPrunerTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/query/pruner/BloomFilterSegmentPrunerTest.java
@@ -61,6 +61,8 @@ import org.apache.pinot.spi.data.Schema;
 import org.apache.pinot.spi.data.readers.GenericRow;
 import org.apache.pinot.spi.env.PinotConfiguration;
 import org.apache.pinot.spi.exception.QueryCancelledException;
+import org.apache.pinot.spi.query.QueryThreadContext;
+import org.apache.pinot.spi.query.RemoteAccessStats;
 import org.apache.pinot.spi.utils.ReadMode;
 import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
 import org.mockito.ArgumentCaptor;
@@ -285,7 +287,10 @@ public class BloomFilterSegmentPrunerTest {
         return null;
       }).when(segment).acquire(any(FetchContext.class));
       doAnswer(invocation -> {
-        acquired.set(false);
+        // Record a remote access at the release of the acquired bloom filter, 
like a segment directory does
+        if (acquired.getAndSet(false)) {
+          
invocation.<FetchContext>getArgument(0).getRemoteAccessStats().addRemoteAccesses(1,
 10);
+        }
         return null;
       }).when(segment).release(any(FetchContext.class));
       DataSource dataSource = segment.getDataSourceNullable("column");
@@ -299,7 +304,7 @@ public class BloomFilterSegmentPrunerTest {
     query.setEnablePrefetch(enablePrefetch);
     query.setMaxExecutionThreads(4);
     ExecutorService executor = Executors.newFixedThreadPool(4);
-    try {
+    try (QueryThreadContext threadContext = 
QueryThreadContext.openForSseTest()) {
       List<IndexSegment> selected = useExecutor ? PRUNER.prune(segments, 
query, executor)
           : PRUNER.prune(segments, query);
       assertEquals(selected.size(), expected.size());
@@ -313,6 +318,10 @@ public class BloomFilterSegmentPrunerTest {
           verify(segment, never()).release(any(FetchContext.class));
         }
       }
+      // The remote accesses of the released bloom filters are reported to the 
query execution
+      RemoteAccessStats remoteAccessStats = 
threadContext.getRemoteAccessStats();
+      assertEquals(remoteAccessStats.getNumRemoteAccesses(), enablePrefetch ? 
numSegments : 0);
+      assertEquals(remoteAccessStats.getRemoteAccessBytes(), enablePrefetch ? 
10L * numSegments : 0);
     } finally {
       executor.shutdownNow();
       assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/query/reduce/ExecutionStatsAggregatorTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/query/reduce/ExecutionStatsAggregatorTest.java
index e351288ac43..bb26e922099 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/query/reduce/ExecutionStatsAggregatorTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/query/reduce/ExecutionStatsAggregatorTest.java
@@ -172,9 +172,13 @@ public class ExecutionStatsAggregatorTest {
     DataTable table1 = emptyServerTable();
     table1.getMetadata().put(MetadataKey.NUM_REMOTE_ACCESSES.getName(), "3");
     table1.getMetadata().put(MetadataKey.REMOTE_ACCESS_BYTES.getName(), "300");
+    table1.getMetadata().put(MetadataKey.NUM_DIRECT_REMOTE_ACCESSES.getName(), 
"1");
+    table1.getMetadata().put(MetadataKey.DIRECT_REMOTE_ACCESS_BYTES.getName(), 
"10");
     DataTable table2 = emptyServerTable();
     table2.getMetadata().put(MetadataKey.NUM_REMOTE_ACCESSES.getName(), "4");
     table2.getMetadata().put(MetadataKey.REMOTE_ACCESS_BYTES.getName(), "400");
+    table2.getMetadata().put(MetadataKey.NUM_DIRECT_REMOTE_ACCESSES.getName(), 
"2");
+    table2.getMetadata().put(MetadataKey.DIRECT_REMOTE_ACCESS_BYTES.getName(), 
"20");
     ExecutionStatsAggregator aggregator = new ExecutionStatsAggregator(false);
     aggregator.aggregate(SERVER, table1);
     aggregator.aggregate(SERVER, table2);
@@ -183,10 +187,14 @@ public class ExecutionStatsAggregatorTest {
     aggregator.setStats("testTable", response, mock(BrokerMetrics.class));
     assertEquals(response.getNumRemoteAccesses(), 7L);
     assertEquals(response.getRemoteAccessBytes(), 700L);
+    assertEquals(response.getNumDirectRemoteAccesses(), 3L);
+    assertEquals(response.getDirectRemoteAccessBytes(), 30L);
 
     DataTable merged = emptyServerTable();
     aggregator.setStatsOnMergedDataTable(merged);
     
assertEquals(merged.getMetadata().get(MetadataKey.NUM_REMOTE_ACCESSES.getName()),
 "7");
     
assertEquals(merged.getMetadata().get(MetadataKey.REMOTE_ACCESS_BYTES.getName()),
 "700");
+    
assertEquals(merged.getMetadata().get(MetadataKey.NUM_DIRECT_REMOTE_ACCESSES.getName()),
 "3");
+    
assertEquals(merged.getMetadata().get(MetadataKey.DIRECT_REMOTE_ACCESS_BYTES.getName()),
 "30");
   }
 }
diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/LeafOperator.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/LeafOperator.java
index 30290091d27..18761c37c28 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/LeafOperator.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/LeafOperator.java
@@ -442,6 +442,12 @@ public class LeafOperator extends MultiStageOperator 
implements ExplainableOpera
         case REMOTE_ACCESS_BYTES:
           _statMap.merge(StatKey.REMOTE_ACCESS_BYTES, 
Long.parseLong(entry.getValue()));
           break;
+        case NUM_DIRECT_REMOTE_ACCESSES:
+          _statMap.merge(StatKey.NUM_DIRECT_REMOTE_ACCESSES, 
Long.parseLong(entry.getValue()));
+          break;
+        case DIRECT_REMOTE_ACCESS_BYTES:
+          _statMap.merge(StatKey.DIRECT_REMOTE_ACCESS_BYTES, 
Long.parseLong(entry.getValue()));
+          break;
         case EXPLAIN_PLAN_NUM_EMPTY_FILTER_SEGMENTS:
           LOGGER.debug("Skipping empty filter segments: {}", entry.getValue());
           break;
@@ -808,7 +814,11 @@ public class LeafOperator extends MultiStageOperator 
implements ExplainableOpera
     /// Number of remote accesses (local cache misses) made to read segment 
data from a remote storage tier.
     NUM_REMOTE_ACCESSES(StatMap.Type.LONG),
     /// Number of bytes read by the remote accesses.
-    REMOTE_ACCESS_BYTES(StatMap.Type.LONG);
+    REMOTE_ACCESS_BYTES(StatMap.Type.LONG),
+    /// Number of remote accesses made directly, i.e. outside the buffers 
fetched for the query.
+    NUM_DIRECT_REMOTE_ACCESSES(StatMap.Type.LONG),
+    /// Number of bytes read by the direct remote accesses.
+    DIRECT_REMOTE_ACCESS_BYTES(StatMap.Type.LONG);
     // IMPORTANT: When adding new StatKeys, make sure to either create the 
same key in BrokerResponseNativeV2.StatKey or
     //  call the constructor that accepts a String as last argument and set it 
to null.
     //  Otherwise the constructor will fail with an IllegalArgumentException 
which will not be caught and will
diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/timeseries/TimeSeriesExchangeReceiveOperator.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/timeseries/TimeSeriesExchangeReceiveOperator.java
index bfede821faf..9c76f071058 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/timeseries/TimeSeriesExchangeReceiveOperator.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/timeseries/TimeSeriesExchangeReceiveOperator.java
@@ -70,7 +70,9 @@ public class TimeSeriesExchangeReceiveOperator extends 
BaseTimeSeriesOperator {
       DataTable.MetadataKey.NUM_SEGMENTS_PRUNED_BY_LIMIT,
       DataTable.MetadataKey.NUM_SEGMENTS_PRUNED_BY_VALUE,
       DataTable.MetadataKey.NUM_REMOTE_ACCESSES,
-      DataTable.MetadataKey.REMOTE_ACCESS_BYTES
+      DataTable.MetadataKey.REMOTE_ACCESS_BYTES,
+      DataTable.MetadataKey.NUM_DIRECT_REMOTE_ACCESSES,
+      DataTable.MetadataKey.DIRECT_REMOTE_ACCESS_BYTES
   );
 
   private static final List<DataTable.MetadataKey> MIN_STATS_KEYS = List.of(
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/LeafOperatorTest.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/LeafOperatorTest.java
index 69d5baff59a..a403e5154bd 100644
--- 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/LeafOperatorTest.java
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/LeafOperatorTest.java
@@ -554,6 +554,8 @@ public class LeafOperatorTest {
     InstanceResponseBlock metadataBlock = new InstanceResponseBlock(new 
MetadataResultsBlock());
     
metadataBlock.getResponseMetadata().put(DataTable.MetadataKey.NUM_REMOTE_ACCESSES.getName(),
 "7");
     
metadataBlock.getResponseMetadata().put(DataTable.MetadataKey.REMOTE_ACCESS_BYTES.getName(),
 "700");
+    
metadataBlock.getResponseMetadata().put(DataTable.MetadataKey.NUM_DIRECT_REMOTE_ACCESSES.getName(),
 "2");
+    
metadataBlock.getResponseMetadata().put(DataTable.MetadataKey.DIRECT_REMOTE_ACCESS_BYTES.getName(),
 "20");
     QueryExecutor queryExecutor = mockQueryExecutor(List.of(), metadataBlock);
     LeafOperator operator =
         new LeafOperator(OperatorTestUtil.getTracingContext(), 
mockQueryRequests(1), schema, queryExecutor,
@@ -567,14 +569,20 @@ public class LeafOperatorTest {
     StatMap<LeafOperator.StatKey> leafStats = operator.copyStatMaps();
     assertEquals(leafStats.getLong(LeafOperator.StatKey.NUM_REMOTE_ACCESSES), 
7L);
     assertEquals(leafStats.getLong(LeafOperator.StatKey.REMOTE_ACCESS_BYTES), 
700L);
+    
assertEquals(leafStats.getLong(LeafOperator.StatKey.NUM_DIRECT_REMOTE_ACCESSES),
 2L);
+    
assertEquals(leafStats.getLong(LeafOperator.StatKey.DIRECT_REMOTE_ACCESS_BYTES),
 20L);
 
     BrokerResponseNativeV2 brokerResponse = new BrokerResponseNativeV2();
     MultiStageOperator.Type.LEAF.mergeInto(brokerResponse, leafStats);
     assertEquals(brokerResponse.getNumRemoteAccesses(), 7L);
     assertEquals(brokerResponse.getRemoteAccessBytes(), 700L);
+    assertEquals(brokerResponse.getNumDirectRemoteAccesses(), 2L);
+    assertEquals(brokerResponse.getDirectRemoteAccessBytes(), 20L);
     JsonNode responseJson = JsonUtils.objectToJsonNode(brokerResponse);
     assertEquals(responseJson.path("numRemoteAccesses").asLong(), 7L);
     assertEquals(responseJson.path("remoteAccessBytes").asLong(), 700L);
+    assertEquals(responseJson.path("numDirectRemoteAccesses").asLong(), 2L);
+    assertEquals(responseJson.path("directRemoteAccessBytes").asLong(), 20L);
 
     operator.close();
   }
diff --git 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/FetchContext.java
 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/FetchContext.java
index 9949aa44636..9f0cf7688d7 100644
--- 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/FetchContext.java
+++ 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/FetchContext.java
@@ -23,21 +23,16 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.UUID;
-import java.util.concurrent.atomic.AtomicLong;
 import org.apache.pinot.segment.spi.index.IndexType;
+import org.apache.pinot.spi.query.RemoteAccessStats;
 
 
 /// The context for fetching buffers of a segment during query.
-///
-/// It also accumulates the remote accesses (local cache misses) performed to 
serve the fetch and the bytes they read.
-/// A segment directory backed by a remote storage tier records them here, 
typically when the fetched buffers are
-/// released, so that they can be reported in the query stats.
 public class FetchContext {
   private final UUID _fetchId;
   private final String _segmentName;
   private final Map<String, List<IndexType<?, ?, ?>>> _columnToIndexList;
-  private final AtomicLong _numRemoteAccesses = new AtomicLong();
-  private final AtomicLong _remoteAccessBytes = new AtomicLong();
+  private final RemoteAccessStats _remoteAccessStats = new RemoteAccessStats();
 
   /// Create a new FetchRequest for this segment, to fetch all buffers of the 
given columns
   /// @param fetchId unique fetch id
@@ -83,19 +78,10 @@ public class FetchContext {
     return _columnToIndexList.isEmpty();
   }
 
-  /// Records remote accesses (local cache misses) performed to serve this 
fetch and the bytes they read.
-  public void addRemoteAccesses(long numRemoteAccesses, long 
remoteAccessBytes) {
-    _numRemoteAccesses.addAndGet(numRemoteAccesses);
-    _remoteAccessBytes.addAndGet(remoteAccessBytes);
-  }
-
-  /// Returns the number of remote accesses (local cache misses) recorded for 
this fetch.
-  public long getNumRemoteAccesses() {
-    return _numRemoteAccesses.get();
-  }
-
-  /// Returns the number of bytes read by the remote accesses recorded for 
this fetch.
-  public long getRemoteAccessBytes() {
-    return _remoteAccessBytes.get();
+  /// Returns the remote accesses (local cache misses) performed to serve this 
fetch and the bytes they read. A segment
+  /// directory backed by a remote storage tier records them, typically when 
the fetched buffers are released, and the
+  /// owner of the fetch reports them to the query execution afterwards, so 
that they end up in the query stats.
+  public RemoteAccessStats getRemoteAccessStats() {
+    return _remoteAccessStats;
   }
 }
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/query/QueryThreadContext.java 
b/pinot-spi/src/main/java/org/apache/pinot/spi/query/QueryThreadContext.java
index 52fca64440f..ce07866de43 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/query/QueryThreadContext.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/query/QueryThreadContext.java
@@ -61,12 +61,15 @@ public class QueryThreadContext implements AutoCloseable {
   @Nullable
   private final MseWorkerInfo _mseWorkerInfo;
   private final ThreadAccountant _accountant;
+  // The stats of the query execution served by this thread, replaced by the 
query executor per execution
+  private RemoteAccessStats _remoteAccessStats;
 
   private QueryThreadContext(QueryExecutionContext executionContext, @Nullable 
MseWorkerInfo mseWorkerInfo,
-      ThreadAccountant accountant) {
+      ThreadAccountant accountant, RemoteAccessStats remoteAccessStats) {
     _executionContext = executionContext;
     _mseWorkerInfo = mseWorkerInfo;
     _accountant = accountant;
+    _remoteAccessStats = remoteAccessStats;
     
LoggerConstants.REQUEST_ID_KEY.registerInMdc(Long.toString(executionContext.getRequestId()));
     
LoggerConstants.CORRELATION_ID_KEY.registerInMdc(executionContext.getCid());
     String queryHash = executionContext.getQueryHash();
@@ -103,6 +106,18 @@ public class QueryThreadContext implements AutoCloseable {
     return _accountant;
   }
 
+  /// Returns the [RemoteAccessStats] of the query execution served by the 
current thread.
+  public RemoteAccessStats getRemoteAccessStats() {
+    return _remoteAccessStats;
+  }
+
+  /// Sets the [RemoteAccessStats] of the query execution served by the 
current thread. Tasks submitted afterwards to a
+  /// [#contextAwareExecutorService] share the same instance, so a query 
executor sets fresh stats when it starts an
+  /// execution and every thread serving it reports into them through 
[#addRemoteAccesses].
+  public void setRemoteAccessStats(RemoteAccessStats remoteAccessStats) {
+    _remoteAccessStats = remoteAccessStats;
+  }
+
   private void sampleUsageInternal() {
     _accountant.sampleUsage();
   }
@@ -198,7 +213,13 @@ public class QueryThreadContext implements AutoCloseable {
   /// Opens a new [QueryThreadContext] for the current thread and add it to 
the thread-local storage.
   public static QueryThreadContext open(QueryExecutionContext 
executionContext, @Nullable MseWorkerInfo mseWorkerInfo,
       ThreadAccountant accountant) {
-    QueryThreadContext threadContext = new 
QueryThreadContext(executionContext, mseWorkerInfo, accountant);
+    return open(executionContext, mseWorkerInfo, accountant, new 
RemoteAccessStats());
+  }
+
+  private static QueryThreadContext open(QueryExecutionContext 
executionContext, @Nullable MseWorkerInfo mseWorkerInfo,
+      ThreadAccountant accountant, RemoteAccessStats remoteAccessStats) {
+    QueryThreadContext threadContext =
+        new QueryThreadContext(executionContext, mseWorkerInfo, accountant, 
remoteAccessStats);
     THREAD_LOCAL.set(threadContext);
     accountant.setupTask(threadContext);
     return threadContext;
@@ -258,6 +279,35 @@ public class QueryThreadContext implements AutoCloseable {
     }
   }
 
+  /// Records remote accesses (local cache misses) and the bytes they read on 
the [RemoteAccessStats] of the query
+  /// execution served by the current thread. No-op when the current thread 
has no [QueryThreadContext], e.g. when the
+  /// remote data is read to load a segment.
+  public static void addRemoteAccesses(long numRemoteAccesses, long 
remoteAccessBytes) {
+    QueryThreadContext threadContext = THREAD_LOCAL.get();
+    if (threadContext != null) {
+      threadContext._remoteAccessStats.addRemoteAccesses(numRemoteAccesses, 
remoteAccessBytes);
+    }
+  }
+
+  /// Records direct remote accesses, i.e. reads that went to the remote 
storage tier directly instead of through the
+  /// buffers fetched for the query, and the bytes they read on the 
[RemoteAccessStats] of the query execution served
+  /// by the current thread. No-op when the current thread has no 
[QueryThreadContext].
+  public static void addDirectRemoteAccesses(long numDirectRemoteAccesses, 
long directRemoteAccessBytes) {
+    QueryThreadContext threadContext = THREAD_LOCAL.get();
+    if (threadContext != null) {
+      
threadContext._remoteAccessStats.addDirectRemoteAccesses(numDirectRemoteAccesses,
 directRemoteAccessBytes);
+    }
+  }
+
+  /// Merges the given stats, e.g. the ones of a released fetch, into the 
[RemoteAccessStats] of the query execution
+  /// served by the current thread. No-op when the current thread has no 
[QueryThreadContext].
+  public static void mergeRemoteAccessStats(RemoteAccessStats 
remoteAccessStats) {
+    QueryThreadContext threadContext = THREAD_LOCAL.get();
+    if (threadContext != null) {
+      threadContext._remoteAccessStats.merge(remoteAccessStats);
+    }
+  }
+
   /// Returns a new [ExecutorService] whose tasks will be executed with the 
[QueryThreadContext] initialized with the
   /// state of the thread submitting the tasks. Tasks are registered for 
cancellation when the query is terminated.
   public static ExecutorService contextAwareExecutorService(ExecutorService 
executorService) {
@@ -281,9 +331,11 @@ public class QueryThreadContext implements AutoCloseable {
         //       the thread context might not be set up.
         QueryThreadContext parentThreadContext = registerTaskForCancellation ? 
get() : getIfAvailable();
         if (parentThreadContext != null) {
+          // Captured at submission, so that the task reports into the 
execution that submitted it
+          RemoteAccessStats remoteAccessStats = 
parentThreadContext._remoteAccessStats;
           return () -> {
             try (QueryThreadContext ignore = 
open(parentThreadContext._executionContext,
-                parentThreadContext._mseWorkerInfo, 
parentThreadContext._accountant)) {
+                parentThreadContext._mseWorkerInfo, 
parentThreadContext._accountant, remoteAccessStats)) {
               return task.call();
             }
           };
@@ -298,9 +350,11 @@ public class QueryThreadContext implements AutoCloseable {
         //       the thread context might not be set up.
         QueryThreadContext parentThreadContext = registerTaskForCancellation ? 
get() : getIfAvailable();
         if (parentThreadContext != null) {
+          // Captured at submission, so that the task reports into the 
execution that submitted it
+          RemoteAccessStats remoteAccessStats = 
parentThreadContext._remoteAccessStats;
           return () -> {
             try (QueryThreadContext ignore = 
open(parentThreadContext._executionContext,
-                parentThreadContext._mseWorkerInfo, 
parentThreadContext._accountant)) {
+                parentThreadContext._mseWorkerInfo, 
parentThreadContext._accountant, remoteAccessStats)) {
               task.run();
             }
           };
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/query/RemoteAccessStats.java 
b/pinot-spi/src/main/java/org/apache/pinot/spi/query/RemoteAccessStats.java
new file mode 100644
index 00000000000..34acdc8559c
--- /dev/null
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/query/RemoteAccessStats.java
@@ -0,0 +1,75 @@
+/**
+ * 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.pinot.spi.query;
+
+import java.util.concurrent.atomic.LongAdder;
+
+
+/// Thread-safe accumulator for the remote accesses (local cache misses) 
performed to read segment data from a remote
+/// storage tier, and the bytes they read. The direct remote accesses, i.e. 
the reads that went to the remote storage
+/// tier directly instead of through the buffers fetched for the query, are 
counted both in the totals and on their
+/// own.
+///
+/// The counts are read independently, so a read concurrent with a record can 
observe one without the others.
+public final class RemoteAccessStats {
+  private final LongAdder _numRemoteAccesses = new LongAdder();
+  private final LongAdder _remoteAccessBytes = new LongAdder();
+  private final LongAdder _numDirectRemoteAccesses = new LongAdder();
+  private final LongAdder _directRemoteAccessBytes = new LongAdder();
+
+  /// Records remote accesses and the bytes they read.
+  public void addRemoteAccesses(long numRemoteAccesses, long 
remoteAccessBytes) {
+    _numRemoteAccesses.add(numRemoteAccesses);
+    _remoteAccessBytes.add(remoteAccessBytes);
+  }
+
+  /// Records direct remote accesses and the bytes they read, in the totals 
and on their own.
+  public void addDirectRemoteAccesses(long numDirectRemoteAccesses, long 
directRemoteAccessBytes) {
+    addRemoteAccesses(numDirectRemoteAccesses, directRemoteAccessBytes);
+    _numDirectRemoteAccesses.add(numDirectRemoteAccesses);
+    _directRemoteAccessBytes.add(directRemoteAccessBytes);
+  }
+
+  /// Merges all the counts of the given stats into these.
+  public void merge(RemoteAccessStats remoteAccessStats) {
+    addRemoteAccesses(remoteAccessStats.getNumRemoteAccesses(), 
remoteAccessStats.getRemoteAccessBytes());
+    
_numDirectRemoteAccesses.add(remoteAccessStats.getNumDirectRemoteAccesses());
+    
_directRemoteAccessBytes.add(remoteAccessStats.getDirectRemoteAccessBytes());
+  }
+
+  /// Returns the number of remote accesses recorded, the direct ones included.
+  public long getNumRemoteAccesses() {
+    return _numRemoteAccesses.sum();
+  }
+
+  /// Returns the number of bytes read by the remote accesses recorded, the 
direct ones included.
+  public long getRemoteAccessBytes() {
+    return _remoteAccessBytes.sum();
+  }
+
+  /// Returns the number of direct remote accesses recorded.
+  public long getNumDirectRemoteAccesses() {
+    return _numDirectRemoteAccesses.sum();
+  }
+
+  /// Returns the number of bytes read by the direct remote accesses recorded.
+  public long getDirectRemoteAccessBytes() {
+    return _directRemoteAccessBytes.sum();
+  }
+}


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

Reply via email to