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

gortiz 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 c5c95497516 Account a mailbox send's current block before it reports 
its stats (#19365)
c5c95497516 is described below

commit c5c95497516600aa1b21d9a3c6af973cf25910e4
Author: Gonzalo Ortiz Jaureguizar <[email protected]>
AuthorDate: Wed Aug 26 14:00:30 2026 +0200

    Account a mailbox send's current block before it reports its stats (#19365)
---
 .../runtime/operator/MailboxSendOperator.java      |  4 ++
 .../query/runtime/operator/MultiStageOperator.java | 70 ++++++++++++++++++++--
 .../runtime/operator/MailboxSendOperatorTest.java  | 40 +++++++++++++
 .../query/runtime/queries/QueryRunnerTest.java     | 40 +++++++++++++
 4 files changed, 150 insertions(+), 4 deletions(-)

diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/MailboxSendOperator.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/MailboxSendOperator.java
index a4aca1dc3ff..3e748deaa97 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/MailboxSendOperator.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/MailboxSendOperator.java
@@ -247,6 +247,10 @@ public class MailboxSendOperator extends 
MultiStageOperator {
     MultiStageQueryStats stats = null;
     List<DataBuffer> serializedStats;
     if (_context.isSendStats()) {
+      // The stats are serialized into the block this method is about to send, 
so what this operator has spent in
+      // the getNextBlock() call it is running has to be accounted before they 
are collected. Otherwise this
+      // operator reports less than the input whose call it contains, and the 
stage renders a negative self time.
+      registerExecutionSoFar();
       stats = calculateStats();
       try {
         serializedStats = stats.serialize();
diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/MultiStageOperator.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/MultiStageOperator.java
index 65aa6da8b2b..479f0fdeed5 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/MultiStageOperator.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/MultiStageOperator.java
@@ -61,6 +61,13 @@ public abstract class MultiStageOperator implements 
Operator<MseBlock>, AutoClos
     _operatorId = Joiner.on("_").join(getClass().getSimpleName(), 
_context.getStageId(), _context.getServer());
   }
 
+  /// The [#getNextBlock()] call currently running, or null when none is. See 
[#registerExecutionSoFar()].
+  ///
+  /// A single thread runs an opchain at a time and an operator never 
re-enters its own [#nextBlock()], so this is
+  /// only ever read and written from inside the call it describes.
+  @Nullable
+  private BlockExecution _blockExecution;
+
   /// Returns the logger for the operator.
   ///
   /// This method is used to generic multi-stage operator messages using the 
name of the specific operator.
@@ -72,6 +79,57 @@ public abstract class MultiStageOperator implements 
Operator<MseBlock>, AutoClos
 
   public abstract void registerExecution(long time, int numRows, long 
memoryUsedBytes, long gcTimeMs);
 
+  /// Accounts everything this operator has spent so far in the 
[#getNextBlock()] call currently running.
+  ///
+  /// [#nextBlock()] normally registers a block's usage only once 
[#getNextBlock()] has returned, which is too late
+  /// for an operator that has to report its own stats from inside that call: 
[MailboxSendOperator] serializes them
+  /// into the end-of-stream block it is about to send. Without this, that 
operator reports less time, memory and GC
+  /// than the inputs whose calls it contains, and the stats tree renders a 
negative self time for the stage.
+  ///
+  /// Whatever is left when the call returns is registered as usual, so the 
totals an operator ends up with are the
+  /// same either way. No rows are attributed here; they are counted from the 
block the call returns.
+  ///
+  /// Does nothing when called outside a [#nextBlock()] call.
+  protected void registerExecutionSoFar() {
+    BlockExecution blockExecution = _blockExecution;
+    if (blockExecution != null) {
+      blockExecution.registerUnaccounted(0);
+    }
+  }
+
+  /// What a single [#getNextBlock()] call has spent, and how much of that has 
already been handed to
+  /// [#registerExecution].
+  ///
+  /// The meters are created by [#nextBlock()] and passed in rather than 
created here, so that each of them keeps
+  /// measuring from exactly the point it always did.
+  private final class BlockExecution {
+    private final Stopwatch _stopwatch = Stopwatch.createStarted();
+    private final ThreadResourceSnapshot _resourceSnapshot;
+    private final long _preGcTimeMs;
+    private long _accountedTimeMs;
+    private long _accountedMemoryBytes;
+    private long _accountedGcTimeMs;
+
+    private BlockExecution(ThreadResourceSnapshot resourceSnapshot, long 
preGcTimeMs) {
+      _resourceSnapshot = resourceSnapshot;
+      _preGcTimeMs = preGcTimeMs;
+    }
+
+    /// Hands whatever this call has spent and not yet registered to 
[MultiStageOperator#registerExecution],
+    /// attributing `numRows` rows to it. Each invocation registers only what 
accrued since the previous one, which
+    /// is what lets the call report from the inside and still end up with 
exact totals.
+    private void registerUnaccounted(int numRows) {
+      long timeMs = _stopwatch.elapsed(TimeUnit.MILLISECONDS);
+      long memoryBytes = _resourceSnapshot.getAllocatedBytes();
+      long gcTimeMs = getGcTimeMillis() - _preGcTimeMs;
+      registerExecution(timeMs - _accountedTimeMs, numRows, memoryBytes - 
_accountedMemoryBytes,
+          gcTimeMs - _accountedGcTimeMs);
+      _accountedTimeMs = timeMs;
+      _accountedMemoryBytes = memoryBytes;
+      _accountedGcTimeMs = gcTimeMs;
+    }
+  }
+
   /// By default, it uses the active deadline, which is the one that should be 
used for most operators, but if the
   /// operator does not actively process data (ie both mailbox operators), it 
should override this method to use the
   /// passive deadline instead.
@@ -104,18 +162,22 @@ public abstract class MultiStageOperator implements 
Operator<MseBlock>, AutoClos
     long preBlockGcTime = getGcTimeMillis();
     try (InvocationScope ignored = 
Tracing.getTracer().createScope(getClass())) {
       MseBlock nextBlock;
-      Stopwatch executeStopwatch = Stopwatch.createStarted();
+      BlockExecution blockExecution = new BlockExecution(resourceSnapshot, 
preBlockGcTime);
+      _blockExecution = blockExecution;
       try {
         checkTermination();
         nextBlock = getNextBlock();
       } catch (Exception e) {
         logger().warn("Operator {}: Exception while processing next block", 
_operatorId, e);
         nextBlock = ErrorMseBlock.fromException(e);
+      } finally {
+        // Cleared even when getNextBlock() throws, so a later 
registerExecutionSoFar() cannot read a finished call.
+        _blockExecution = null;
       }
       int numRows = nextBlock instanceof MseBlock.Data ? ((MseBlock.Data) 
nextBlock).getNumRows() : 0;
-      long memoryUsedBytes = resourceSnapshot.getAllocatedBytes();
-      long gcTimeMs = getGcTimeMillis() - preBlockGcTime;
-      registerExecution(executeStopwatch.elapsed(TimeUnit.MILLISECONDS), 
numRows, memoryUsedBytes, gcTimeMs);
+      // Only what registerExecutionSoFar() left unaccounted, so the totals 
are the same whether or not the operator
+      // reported from inside the call.
+      blockExecution.registerUnaccounted(numRows);
 
       if (logger().isDebugEnabled()) {
         logger().debug("Operator {}. Block {} ready to send", _operatorId, 
nextBlock);
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/MailboxSendOperatorTest.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/MailboxSendOperatorTest.java
index e0936b6763f..88d07ad2694 100644
--- 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/MailboxSendOperatorTest.java
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/MailboxSendOperatorTest.java
@@ -171,6 +171,46 @@ public class MailboxSendOperatorTest {
     verify(_input).earlyTerminate();
   }
 
+  /// The stats a send operator reports travel inside the end-of-stream block 
it sends, so they are collected from
+  /// inside its own getNextBlock() call. Everything that call has spent, 
including the input call it contains, must
+  /// already be accounted by then: otherwise this operator reports less time 
than its own input, and the stats tree
+  /// renders a negative self time for the stage.
+  @Test
+  public void shouldAccountCurrentBlockBeforeReportingStats()
+      throws Exception {
+    // Given: an input that takes a measurable time and then reports EOS 
without ever producing a data block, so the
+    // end-of-stream block is the only block this operator ever handles.
+    when(_input.nextBlock()).thenAnswer(invocation -> {
+      Thread.sleep(50);
+      return SuccessMseBlock.INSTANCE;
+    });
+    long[] reportedAtSendTime = {-1};
+    MailboxSendOperator[] operatorRef = new MailboxSendOperator[1];
+    doAnswer(invocation -> {
+      reportedAtSendTime[0] =
+          
operatorRef[0].copyStatMaps().getLong(MailboxSendOperator.StatKey.EXECUTION_TIME_MS);
+      return null;
+    }).when(_exchange).send(any(MseBlock.Eos.class), anyList());
+
+    // When:
+    MailboxSendOperator operator = getOperator();
+    operatorRef[0] = operator;
+    long startNs = System.nanoTime();
+    operator.nextBlock();
+    long wallTimeMs = (System.nanoTime() - startNs) / 1_000_000;
+
+    // Then: the time the input spent is already part of what this operator 
reports when it hands its stats over.
+    assertTrue(reportedAtSendTime[0] > 0,
+        "expected the current block to be accounted before the stats are 
collected, got " + reportedAtSendTime[0]);
+    long total = 
operator.copyStatMaps().getLong(MailboxSendOperator.StatKey.EXECUTION_TIME_MS);
+    assertTrue(total >= reportedAtSendTime[0],
+        "total " + total + " must not be below what was already reported " + 
reportedAtSendTime[0]);
+    // What was accounted early must not be counted a second time when the 
call returns. An operator can never have
+    // spent more than the call it was made from took, so double counting 
shows up as exceeding the wall time.
+    assertTrue(total <= wallTimeMs,
+        "total " + total + " exceeds the " + wallTimeMs + "ms the call 
actually took, so it was counted twice");
+  }
+
   private MailboxSendOperator getOperator() {
     WorkerMetadata workerMetadata = new WorkerMetadata(0, Map.of(), Map.of());
     StageMetadata stageMetadata = new StageMetadata(SENDER_STAGE_ID, 
List.of(workerMetadata), Map.of());
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/QueryRunnerTest.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/QueryRunnerTest.java
index 00121f59d66..b0d0eb3c404 100644
--- 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/QueryRunnerTest.java
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/QueryRunnerTest.java
@@ -18,6 +18,8 @@
  */
 package org.apache.pinot.query.runtime.queries;
 
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -29,7 +31,9 @@ import org.apache.pinot.common.response.broker.ResultTable;
 import org.apache.pinot.query.QueryEnvironmentTestBase;
 import org.apache.pinot.query.QueryServerEnclosure;
 import org.apache.pinot.query.mailbox.MailboxService;
+import org.apache.pinot.query.planner.physical.DispatchablePlanFragment;
 import org.apache.pinot.query.routing.QueryServerInstance;
+import org.apache.pinot.query.runtime.MultiStageStatsTreeBuilder;
 import org.apache.pinot.query.service.dispatch.QueryDispatcher;
 import org.apache.pinot.query.testutils.MockInstanceDataManagerFactory;
 import org.apache.pinot.query.testutils.QueryTestUtils;
@@ -163,6 +167,42 @@ public class QueryRunnerTest extends QueryRunnerTestBase {
     _mailboxService.shutdown();
   }
 
+  /// The self stats of a node are the node's own value minus its children's. 
A mailbox send reports its stats from
+  /// inside the getNextBlock() call whose time it is still spending, so 
unless that call is accounted first it
+  /// reports less than the input whose call it contains and the subtraction 
goes negative. A query whose filter
+  /// matches nothing makes the end-of-stream block the only block a stage 
handles, which is when the whole of the
+  /// send's time would be missing.
+  @Test
+  public void testSelfStatsAreNotNegative() {
+    @Language("sql")
+    String sql = "SELECT col1, COUNT(*) FROM a WHERE col1 = 'no-such-value' 
GROUP BY col1";
+    QueryDispatcher.QueryResult queryResult = queryRunner(sql, true);
+    Map<Integer, DispatchablePlanFragment> planNodes = 
planQuery(sql).getQueryPlan().getQueryStageMap();
+    ObjectNode statsTree =
+        new MultiStageStatsTreeBuilder(planNodes, 
queryResult.getQueryStats()).jsonStatsByStage(1);
+
+    int checked = assertSelfStatsAreNotNegative(statsTree);
+    Assert.assertTrue(checked > 0, "expected some self stats to check, got: " 
+ statsTree);
+  }
+
+  /// Asserts that no self stat in the tree is negative, and returns how many 
were checked.
+  private static int assertSelfStatsAreNotNegative(JsonNode node) {
+    int checked = 0;
+    for (String statName : List.of("selfExecutionTimeMs", "selfClockTimeMs", 
"selfAllocatedMB", "selfGcTimeMs")) {
+      JsonNode stat = node.get(statName);
+      if (stat != null) {
+        Assert.assertTrue(stat.asLong() >= 0,
+            statName + " is " + stat.asLong() + ", which means this node 
reported less than its children, for node "
+                + node);
+        checked++;
+      }
+    }
+    for (JsonNode child : node.path("children")) {
+      checked += assertSelfStatsAreNotNegative(child);
+    }
+    return checked;
+  }
+
   /// Test compares with expected row count only.
   @Test(dataProvider = "testDataWithSqlToFinalRowCount")
   public void testSqlWithFinalRowCountChecker(String sql, int expectedRows) {


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

Reply via email to