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 6e92426ef75 Report idle workers and the slowest worker's clock time in 
MSE stage stats (#19364)
6e92426ef75 is described below

commit 6e92426ef75d9648ae4bc2dce58f542e61f0baec
Author: Gonzalo Ortiz Jaureguizar <[email protected]>
AuthorDate: Fri Aug 28 15:53:37 2026 +0200

    Report idle workers and the slowest worker's clock time in MSE stage stats 
(#19364)
---
 .../core/query/request/ServerQueryRequest.java     |  23 ++++
 .../core/query/request/ServerQueryRequestTest.java |  91 +++++++++++++
 .../pinot/query/runtime/SendStatsPredicate.java    |   9 +-
 .../operator/BaseMailboxReceiveOperator.java       |  27 +++-
 .../pinot/query/runtime/operator/LeafOperator.java |  32 ++++-
 .../runtime/operator/MailboxSendOperator.java      |  62 ++++++++-
 .../query/runtime/operator/MultiStageOperator.java |   6 +
 .../query/runtime/operator/LeafOperatorTest.java   |  45 +++++++
 .../operator/MailboxReceiveOperatorTest.java       |  35 +++++
 .../runtime/operator/MailboxSendOperatorTest.java  | 146 ++++++++++++++++++++-
 .../query/runtime/queries/QueryRunnerTest.java     | 129 ++++++++++++++++++
 11 files changed, 593 insertions(+), 12 deletions(-)

diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/request/ServerQueryRequest.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/request/ServerQueryRequest.java
index d9c0041f10a..7f3666624ac 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/request/ServerQueryRequest.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/request/ServerQueryRequest.java
@@ -22,6 +22,7 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import javax.annotation.Nullable;
+import org.apache.commons.collections4.CollectionUtils;
 import org.apache.pinot.common.metrics.ServerMetrics;
 import org.apache.pinot.common.proto.Server;
 import org.apache.pinot.common.request.BrokerRequest;
@@ -205,10 +206,32 @@ public class ServerQueryRequest {
     return _queryContext.getTableName();
   }
 
+  /// The segments assigned to this worker, or null when the request carries 
them per referenced table in
+  /// [#getTableSegmentsContexts()] instead, as it does for a logical table. 
Callers must check that one first, the
+  /// way `ServerQueryExecutorV1Impl` does, or use [#hasSegmentsToQuery()] 
when all they need is whether this
+  /// request has any segment at all.
+  @Nullable
   public List<String> getSegmentsToQuery() {
     return _segmentsToQuery;
   }
 
+  /// Whether this request has at least one segment to read, whichever of the 
two representations carries them.
+  ///
+  /// A request holds its segments either flat in [#getSegmentsToQuery()], for 
a plain table, or grouped per
+  /// referenced table in [#getTableSegmentsContexts()], for a logical table; 
the other one is null. Resolving that
+  /// here keeps callers that only need the question answered from having to 
know which representation applies.
+  public boolean hasSegmentsToQuery() {
+    if (_tableSegmentsContexts != null) {
+      for (TableSegmentsContext tableSegmentsContext : _tableSegmentsContexts) 
{
+        if (CollectionUtils.isNotEmpty(tableSegmentsContext.getSegments())) {
+          return true;
+        }
+      }
+      return false;
+    }
+    return CollectionUtils.isNotEmpty(_segmentsToQuery);
+  }
+
   public List<String> getOptionalSegments() {
     return _optionalSegments;
   }
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/query/request/ServerQueryRequestTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/query/request/ServerQueryRequestTest.java
new file mode 100644
index 00000000000..19a5c471907
--- /dev/null
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/query/request/ServerQueryRequestTest.java
@@ -0,0 +1,91 @@
+/**
+ * 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.core.query.request;
+
+import java.util.List;
+import javax.annotation.Nullable;
+import org.apache.pinot.common.metrics.ServerMetrics;
+import org.apache.pinot.common.request.InstanceRequest;
+import org.apache.pinot.common.request.TableSegmentsInfo;
+import org.apache.pinot.sql.parsers.CalciteSqlCompiler;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
+
+
+/// Tests how a request answers whether it has segments to read, which depends 
on which of its two mutually
+/// exclusive segment representations is populated.
+public class ServerQueryRequestTest {
+
+  @Test
+  public void shouldHaveSegmentsWhenTheFlatListIsPopulated() {
+    assertTrue(request(List.of("segment1"), null).hasSegmentsToQuery());
+  }
+
+  @Test
+  public void shouldNotHaveSegmentsWhenTheFlatListIsEmpty() {
+    assertFalse(request(List.of(), null).hasSegmentsToQuery());
+  }
+
+  /// A logical table sets the per-table lists instead and leaves the flat one 
unset, which Thrift reports as null.
+  /// Reading it without checking would throw, so this pins both that it stays 
null and that the question is still
+  /// answered from the other representation.
+  @Test
+  public void shouldHaveSegmentsWhenOnlyThePerTableListsArePopulated() {
+    ServerQueryRequest request =
+        request(null, List.of(new TableSegmentsInfo("tbl_OFFLINE", 
List.of("segment1"))));
+
+    assertNull(request.getSegmentsToQuery(), "expected the flat list to be 
null on the logical table path");
+    assertTrue(request.hasSegmentsToQuery());
+  }
+
+  @Test
+  public void shouldNotHaveSegmentsWhenThePerTableListsAreAllEmpty() {
+    ServerQueryRequest request = request(null,
+        List.of(new TableSegmentsInfo("tbl1_OFFLINE", List.of()), new 
TableSegmentsInfo("tbl2_OFFLINE", List.of())));
+
+    assertFalse(request.hasSegmentsToQuery());
+  }
+
+  @Test
+  public void shouldHaveSegmentsWhenOnlyOneOfThePerTableListsIsPopulated() {
+    assertTrue(request(null,
+        List.of(new TableSegmentsInfo("tbl1_OFFLINE", List.of()),
+            new TableSegmentsInfo("tbl2_OFFLINE", 
List.of("segment1")))).hasSegmentsToQuery());
+  }
+
+  /// Exactly one of the two arguments is set on a real request, mirroring
+  /// `ServerPlanRequestUtils.compileInstanceRequest`.
+  private static ServerQueryRequest request(@Nullable List<String> 
searchSegments,
+      @Nullable List<TableSegmentsInfo> tableSegmentsInfoList) {
+    InstanceRequest instanceRequest = new InstanceRequest();
+    instanceRequest.setRequestId(1);
+    instanceRequest.setBrokerId("broker");
+    instanceRequest.setQuery(CalciteSqlCompiler.compileToBrokerRequest("SELECT 
* FROM tbl"));
+    if (searchSegments != null) {
+      instanceRequest.setSearchSegments(searchSegments);
+    }
+    if (tableSegmentsInfoList != null) {
+      instanceRequest.setTableSegmentsInfoList(tableSegmentsInfoList);
+    }
+    return new ServerQueryRequest(instanceRequest, ServerMetrics.get(), 
System.currentTimeMillis());
+  }
+}
diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/SendStatsPredicate.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/SendStatsPredicate.java
index da2a6cb5bc1..a6d2b4b5c6c 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/SendStatsPredicate.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/SendStatsPredicate.java
@@ -49,10 +49,11 @@ import org.slf4j.LoggerFactory;
 /// Therefore the cleanest and safer solution is to not send stats when we 
know a problematic version is in the cluster.
 ///
 /// We support three modes:
-/// - SAFE: This is the default mode. In this mode, we will send stats unless 
we detect a problematic version in the
-///  cluster. This doesn't require human intervention and is the recommended 
mode.
-/// - ALWAYS: In this mode, we will always send stats, regardless of the 
version of the cluster. This mimics the
-///  behavior in 1.3.0 and lower versions.
+/// - ALWAYS: This is the default mode. In this mode, we will always send 
stats, regardless of the version of the
+///  cluster.
+/// - SAFE: In this mode, we will send stats unless we detect a problematic 
version in the cluster, which means any
+///  instance reporting a version other than this one. This doesn't require 
human intervention, and is the mode to
+///  use for a cluster that may still run versions older than 1.4.
 /// - NEVER: In this mode, we will never send stats, regardless of the version 
of the cluster. This is useful for
 /// testing purposes or if for whatever reason you want to disable stats.
 public abstract class SendStatsPredicate implements 
InstanceConfigChangeListener {
diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/BaseMailboxReceiveOperator.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/BaseMailboxReceiveOperator.java
index 0fcc70341e1..4e19b8a3a47 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/BaseMailboxReceiveOperator.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/BaseMailboxReceiveOperator.java
@@ -120,9 +120,16 @@ public abstract class BaseMailboxReceiveOperator extends 
MultiStageOperator {
     return _multiConsumer.calculateStats();
   }
 
+  /// Returns a copy of this operator's stats, extended with 
[StatKey#NON_ACTIVE_WORKERS] for this single worker.
   @Override
   public StatMap<StatKey> copyStatMaps() {
-    return new StatMap<>(_statMap);
+    StatMap<StatKey> statMap = new StatMap<>(_statMap);
+    // This operator hands downstream what it reads from its mailboxes, so 
having emitted no row means having
+    // received none.
+    if (statMap.getLong(StatKey.EMITTED_ROWS) == 0) {
+      statMap.merge(StatKey.NON_ACTIVE_WORKERS, 1);
+    }
+    return statMap;
   }
 
   protected void onEos() {
@@ -203,6 +210,10 @@ public abstract class BaseMailboxReceiveOperator extends 
MultiStageOperator {
     }
   }
 
+  /// The stats reported by this operator.
+  ///
+  /// New keys must be appended at the end of this enum: [StatMap] identifies 
keys by their ordinal on the wire, so
+  /// inserting, reordering or removing a constant breaks the compatibility 
with other versions.
   public enum StatKey implements StatMap.Key {
     EXECUTION_TIME_MS(StatMap.Type.LONG) {
       @Override
@@ -246,7 +257,19 @@ public abstract class BaseMailboxReceiveOperator extends 
MultiStageOperator {
     /// Allocated memory in bytes for this operator or its children in the 
same stage.
     ALLOCATED_MEMORY_BYTES(StatMap.Type.LONG),
     /// Time spent on GC while this operator or its children in the same stage 
were running.
-    GC_TIME_MS(StatMap.Type.LONG);
+    GC_TIME_MS(StatMap.Type.LONG),
+    /// How many workers of this stage emitted no row out of this mailbox 
receive.
+    ///
+    /// Reported as the count of idle workers rather than active ones so that 
it is absent when every worker
+    /// received something, which is the common case. This is what tells apart 
a worker that was handed no data at
+    /// all from one that was handed data and filtered it away downstream: 
compare it against the `parallelism` the
+    /// stats tree renders on this node, and against what the operators above 
and below report.
+    ///
+    /// This operator hands downstream what it reads, so emitting no row means 
having received none. The converse
+    /// can fail in two corner cases, where rows are read but never emitted: 
after the downstream operator has
+    /// early terminated, and when a sorted receive buffers its rows and then 
ends in error. Such a worker is
+    /// reported as idle despite having been given data.
+    NON_ACTIVE_WORKERS(StatMap.Type.INT);
 
     private final StatMap.Type _type;
 
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 6b5ae73499b..d80adc418c8 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
@@ -290,9 +290,28 @@ public class LeafOperator extends MultiStageOperator {
     }
   }
 
+  /// Returns a copy of this operator's stats, extended with 
[StatKey#NON_ACTIVE_WORKERS] for this single worker.
   @Override
   public StatMap<StatKey> copyStatMaps() {
-    return new StatMap<>(_statMap);
+    StatMap<StatKey> statMap = new StatMap<>(_statMap);
+    if (!hasSegmentsAssigned()) {
+      statMap.merge(StatKey.NON_ACTIVE_WORKERS, 1);
+    }
+    return statMap;
+  }
+
+  /// Whether this worker was given at least one segment to read.
+  ///
+  /// Decided from the request rather than from anything the query produces, 
so that a worker whose segments are all
+  /// pruned, or all of whose rows are filtered out, still counts as having 
been given work. A hybrid table produces
+  /// one request per table type, and segments on either of them are enough.
+  private boolean hasSegmentsAssigned() {
+    for (ServerQueryRequest request : _requests) {
+      if (request.hasSegmentsToQuery()) {
+        return true;
+      }
+    }
+    return false;
   }
 
   @Override
@@ -768,11 +787,20 @@ public class LeafOperator extends MultiStageOperator {
     GC_TIME_MS(StatMap.Type.LONG, null),
     /// Time spent in single-stage execution engine for this leaf stage.
     SSE_EXECUTION_TIME_MS(StatMap.Type.LONG, null),
-    EARLY_TERMINATION_REASONS(StatMap.Type.STRING_SET);
+    EARLY_TERMINATION_REASONS(StatMap.Type.STRING_SET),
+    /// How many workers of this stage had no segment assigned to them.
+    ///
+    /// Reported as the count of idle workers rather than active ones so that 
it is absent when every worker was
+    /// given something to read, which is the common case. A worker with no 
segment was given no work at all, which
+    /// is different from a worker that read segments and produced nothing: 
compare this against the `parallelism`
+    /// the stats tree renders on this node, and against what the operators 
above it report.
+    NON_ACTIVE_WORKERS(StatMap.Type.INT, null);
     // 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
     //  propagate to the caller, causing the query to timeout.
+    //  New keys must also be appended at the end: StatMap identifies keys by 
their ordinal on the wire, so
+    //  inserting, reordering or removing a constant breaks the compatibility 
with other versions.
 
     private final StatMap.Type _type;
     @Nullable
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 3e748deaa97..d0d19b3ac81 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
@@ -270,9 +270,27 @@ public class MailboxSendOperator extends 
MultiStageOperator {
     }
   }
 
+  /// Returns a copy of this operator's stats, extended with the stats 
describing this single worker.
+  ///
+  /// These cannot be accumulated in [#registerExecution] like the others, 
because they are not per-block
+  /// quantities: merging [StatKey#MAX_EMITTED_ROWS] once per block would 
report the largest block rather than the
+  /// busiest worker, and merging [StatKey#NON_ACTIVE_WORKERS] once per block 
would count the blocks. They are
+  /// computed once, here, from the totals the operator ends up with.
+  ///
+  /// They describe a single worker, but every stat is merged across all the 
workers of the stage before being
+  /// reported, which is what turns them into a description of how the work 
was spread.
   @Override
   public StatMap<StatKey> copyStatMaps() {
-    return new StatMap<>(_statMap);
+    StatMap<StatKey> statMap = new StatMap<>(_statMap);
+    long emittedRows = statMap.getLong(StatKey.EMITTED_ROWS);
+    if (emittedRows > 0) {
+      statMap.merge(StatKey.MAX_EMITTED_ROWS, emittedRows);
+    } else {
+      statMap.merge(StatKey.NON_ACTIVE_WORKERS, 1);
+    }
+    // Reported by every worker, idle ones included: a worker that sent 
nothing still spent time deciding that.
+    statMap.merge(StatKey.MAX_CLOCK_TIME_MS, 
statMap.getLong(StatKey.EXECUTION_TIME_MS));
+    return statMap;
   }
 
   private void sendMseBlock(MseBlock.Data block) {
@@ -319,6 +337,13 @@ public class MailboxSendOperator extends 
MultiStageOperator {
     }
   }
 
+  /// The stats reported by this operator.
+  ///
+  /// As the root operator of its stage, this operator is also where the 
stage-wide stats live, like [#PARALLELISM]
+  /// and [#NON_ACTIVE_WORKERS].
+  ///
+  /// New keys must be appended at the end of this enum: [StatMap] identifies 
keys by their ordinal on the wire, so
+  /// inserting, reordering or removing a constant breaks the compatibility 
with other versions.
   public enum StatKey implements StatMap.Key {
     EXECUTION_TIME_MS(StatMap.Type.LONG) {
       @Override
@@ -381,7 +406,40 @@ public class MailboxSendOperator extends 
MultiStageOperator {
     /// Allocated memory in bytes for this operator or its children in the 
same stage.
     ALLOCATED_MEMORY_BYTES(StatMap.Type.LONG),
     /// Time spent on GC while this operator or its children in the same stage 
were running.
-    GC_TIME_MS(StatMap.Type.LONG);
+    GC_TIME_MS(StatMap.Type.LONG),
+    /// How many workers of this stage sent no row at all.
+    ///
+    /// Reported as the count of idle workers rather than active ones so that 
it is absent from the stats of a
+    /// stage where every worker produced something, which is the common case.
+    ///
+    /// Each operator reports which of the stage's workers it was idle on, 
applying its own notion of activity:
+    /// this one sent no row, a mailbox receive operator received none, a leaf 
operator had no segment assigned to
+    /// it. Comparing this against `parallelism` on the same node detects 
distribution bias, and comparing it
+    /// against the operators below shows where the stage narrowed: a leaf 
idle on no worker under a send idle on
+    /// nine means the work was spread but the output was not.
+    NON_ACTIVE_WORKERS(StatMap.Type.INT),
+    /// The highest number of rows sent by a single worker of this stage.
+    ///
+    /// [#EMITTED_ROWS] is the sum across all workers, so `maxEmittedRows` 
greatly exceeding the average number of
+    /// rows per worker means the rows were not evenly distributed.
+    MAX_EMITTED_ROWS(StatMap.Type.LONG) {
+      @Override
+      public long merge(long value1, long value2) {
+        return Math.max(value1, value2);
+      }
+    },
+    /// How long the slowest worker of this stage took.
+    ///
+    /// The `clockTimeMs` reported for a stage is its [#EXECUTION_TIME_MS] 
divided by its [#PARALLELISM], which
+    /// assumes the work was spread evenly across the workers. This is the 
same measure taken on the worker that
+    /// took longest, so `maxClockTimeMs` greatly exceeding `clockTimeMs` 
means that assumption does not hold and
+    /// the average understates how long the stage actually took.
+    MAX_CLOCK_TIME_MS(StatMap.Type.LONG) {
+      @Override
+      public long merge(long value1, long value2) {
+        return Math.max(value1, value2);
+      }
+    };
 
     private final StatMap.Type _type;
 
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 479f0fdeed5..0bead828f0f 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
@@ -230,6 +230,12 @@ public abstract class MultiStageOperator implements 
Operator<MseBlock>, AutoClos
         .orElse(MultiStageQueryStats.emptyStats(_context.getStageId()));
   }
 
+  /// Returns the stats to report for this operator, as a copy that the caller 
is free to merge into.
+  ///
+  /// Implementations may derive extra stats here instead of only copying the 
ones they accumulated while running,
+  /// but this method is called several times per opchain and every call must 
return the same values: deriving a
+  /// stat whose merge function is not idempotent (a sum, for instance) must 
be done on the returned copy, never on
+  /// the stat map the operator keeps.
   public abstract StatMap<?> copyStatMaps();
 
   // TODO: Ideally close() call should finish within request deadline.
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 21bea3dbbac..237d7033ff0 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
@@ -124,6 +124,51 @@ public class LeafOperatorTest {
     return queryRequests;
   }
 
+  private ServerQueryRequest mockQueryRequest(boolean hasSegments) {
+    ServerQueryRequest queryRequest = mock(ServerQueryRequest.class);
+    when(queryRequest.getQueryContext()).thenReturn(mock(QueryContext.class));
+    when(queryRequest.hasSegmentsToQuery()).thenReturn(hasSegments);
+    return queryRequest;
+  }
+
+  private int idleWorkers(List<ServerQueryRequest> requests) {
+    OpChainExecutionContext context = OperatorTestUtil.getTracingContext();
+    DataSchema schema = new DataSchema(new String[]{"intCol"},
+        new DataSchema.ColumnDataType[]{DataSchema.ColumnDataType.INT});
+    try (LeafOperator operator = new LeafOperator(context, requests, schema, 
mock(QueryExecutor.class),
+        _executorService)) {
+      // Collected more than once, as the runtime does, so a derivation moved 
onto the shared stat map would show up
+      // here as a doubled count.
+      operator.copyStatMaps();
+      return 
operator.copyStatMaps().getInt(LeafOperator.StatKey.NON_ACTIVE_WORKERS);
+    }
+  }
+
+  /// A leaf operator is idle on the workers that were given no segment to 
read. This is decided from the request
+  /// rather than from anything the query produces, so a worker whose segments 
are all pruned, or all of whose rows
+  /// are filtered out, is not idle.
+  ///
+  /// Which of the request's two segment representations carries them, and 
that one of them is null, is
+  /// [ServerQueryRequest#hasSegmentsToQuery()]'s business and is stubbed out 
here.
+  @Test
+  public void shouldNotReportIdleWorkerWhenSegmentsAreAssigned() {
+    assertEquals(idleWorkers(List.of(mockQueryRequest(true))), 0,
+        "expected a worker with a segment assigned not to count as idle");
+  }
+
+  @Test
+  public void shouldReportIdleWorkerWhenNoSegmentIsAssigned() {
+    assertEquals(idleWorkers(List.of(mockQueryRequest(false))), 1,
+        "expected a worker with no segment assigned to be counted as idle");
+  }
+
+  /// A hybrid table produces one request per table type; having segments in 
either of them is enough.
+  @Test
+  public void shouldNotReportIdleWorkerWhenOnlyTheSecondRequestHasSegments() {
+    assertEquals(idleWorkers(List.of(mockQueryRequest(false), 
mockQueryRequest(true))), 0,
+        "expected segments on any of the requests to keep the worker out of 
the idle count");
+  }
+
   @Test
   public void shouldReturnDataBlockThenMetadataBlock() {
     // Given:
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/MailboxReceiveOperatorTest.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/MailboxReceiveOperatorTest.java
index 6c57ef6ba01..0af5a807b3d 100644
--- 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/MailboxReceiveOperatorTest.java
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/MailboxReceiveOperatorTest.java
@@ -143,6 +143,41 @@ public class MailboxReceiveOperatorTest {
     }
   }
 
+  /// A mailbox receive operator is idle on the workers where it received 
nothing, which is what tells a worker
+  /// that was handed no data apart from one that was handed data and filtered 
it away downstream.
+  @Test
+  public void shouldNotReportIdleWorkerWhenRowsAreReceived() {
+    
when(_mailboxService.getReceivingMailbox(eq(MAILBOX_ID_1))).thenReturn(_mailbox1);
+    
when(_mailbox1.poll()).thenReturn(OperatorTestUtil.blockWithStats(DATA_SCHEMA, 
new Object[]{1, 1}),
+        OperatorTestUtil.eosWithEmptyStats());
+    try (MailboxReceiveOperator operator = getOperator(_stageMetadata1, 
RelDistribution.Type.SINGLETON)) {
+      drain(operator);
+      
assertEquals(operator.copyStatMaps().getInt(BaseMailboxReceiveOperator.StatKey.NON_ACTIVE_WORKERS),
 0,
+          "expected receiving a row to keep this worker out of the idle 
count");
+    }
+  }
+
+  @Test
+  public void shouldReportIdleWorkerWhenNoRowIsReceived() {
+    
when(_mailboxService.getReceivingMailbox(eq(MAILBOX_ID_1))).thenReturn(_mailbox1);
+    when(_mailbox1.poll()).thenReturn(OperatorTestUtil.eosWithEmptyStats());
+    try (MailboxReceiveOperator operator = getOperator(_stageMetadata1, 
RelDistribution.Type.SINGLETON)) {
+      drain(operator);
+      // Collected more than once, as the runtime does, so a derivation moved 
onto the shared stat map would show up
+      // here as a doubled count.
+      operator.copyStatMaps();
+      
assertEquals(operator.copyStatMaps().getInt(BaseMailboxReceiveOperator.StatKey.NON_ACTIVE_WORKERS),
 1,
+          "expected a worker that received no row to be counted as idle");
+    }
+  }
+
+  private static void drain(MailboxReceiveOperator operator) {
+    MseBlock block = operator.nextBlock();
+    while (block.isData()) {
+      block = operator.nextBlock();
+    }
+  }
+
   @Test
   public void shouldReceiveSingletonErrorMailbox() {
     
when(_mailboxService.getReceivingMailbox(eq(MAILBOX_ID_1))).thenReturn(_mailbox1);
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 88d07ad2694..67c1b20eb63 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
@@ -18,8 +18,14 @@
  */
 package org.apache.pinot.query.runtime.operator;
 
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
+import org.apache.pinot.common.datatable.StatMap;
 import org.apache.pinot.common.utils.DataSchema;
 import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
 import org.apache.pinot.query.mailbox.MailboxService;
@@ -42,6 +48,7 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.*;
 import static org.mockito.MockitoAnnotations.openMocks;
+import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertSame;
 import static org.testng.Assert.assertTrue;
 
@@ -211,17 +218,152 @@ public class MailboxSendOperatorTest {
         "total " + total + " exceeds the " + wallTimeMs + "ms the call 
actually took, so it was counted twice");
   }
 
+  @Test
+  public void shouldReportPerWorkerStats()
+      throws Exception {
+    // Given: a worker that sends two single-row blocks
+    when(_input.nextBlock()).thenReturn(getDummyDataBlock(), 
getDummyDataBlock(), SuccessMseBlock.INSTANCE);
+
+    // When:
+    MailboxSendOperator operator = getOperator();
+    drain(operator);
+
+    // Then: the per-worker view is this single worker, so the max is its own 
emitted rows and it is not idle
+    StatMap<MailboxSendOperator.StatKey> statMap = operator.copyStatMaps();
+    assertEquals(statMap.getLong(MailboxSendOperator.StatKey.EMITTED_ROWS), 
2L, "expected 2 emitted rows");
+    
assertEquals(statMap.getLong(MailboxSendOperator.StatKey.MAX_EMITTED_ROWS), 2L, 
"expected max to be 2");
+    
assertEquals(statMap.getInt(MailboxSendOperator.StatKey.NON_ACTIVE_WORKERS), 0, 
"expected no idle worker");
+  }
+
+  /// A send operator is idle exactly when it sent no row, so a worker that 
sent nothing is counted however much
+  /// work the stage was given.
+  @Test
+  public void shouldReportIdleWorkerWhenNoRowIsSent()
+      throws Exception {
+    // Given: a worker that sends no data block at all
+    when(_input.nextBlock()).thenReturn(SuccessMseBlock.INSTANCE);
+
+    // When:
+    MailboxSendOperator operator = getOperator();
+    drain(operator);
+
+    // Then:
+    StatMap<MailboxSendOperator.StatKey> statMap = operator.copyStatMaps();
+    
assertEquals(statMap.getInt(MailboxSendOperator.StatKey.NON_ACTIVE_WORKERS), 1, 
"expected one idle worker");
+    
assertEquals(statMap.getLong(MailboxSendOperator.StatKey.MAX_EMITTED_ROWS), 0L, 
"expected no max");
+  }
+
+  /// [MultiStageOperator#calculateStats()] runs more than once per opchain, 
so the derived stats must not
+  /// accumulate on the operator's own stat map.
+  @Test
+  public void shouldNotDoubleCountIdleWorkersOnRepeatedStatCollection()
+      throws Exception {
+    // Given: a worker that sends nothing, so it is the one counted as idle
+    when(_input.nextBlock()).thenReturn(SuccessMseBlock.INSTANCE);
+
+    // When: stats are collected several times, as the runtime does
+    MailboxSendOperator operator = getOperator();
+    drain(operator);
+    operator.copyStatMaps();
+    operator.copyStatMaps();
+    StatMap<MailboxSendOperator.StatKey> statMap = operator.copyStatMaps();
+
+    // Then: the worker is still counted exactly once
+    
assertEquals(statMap.getInt(MailboxSendOperator.StatKey.NON_ACTIVE_WORKERS), 1, 
"expected counted once");
+  }
+
+  /// Every stat is merged across the workers of the stage before being 
reported, which is where these stats stop
+  /// describing one worker and start describing the distribution.
+  @Test
+  public void shouldMergePerWorkerStatsAcrossWorkers() {
+    // Given: three workers of the same stage, one of which sent nothing
+    StatMap<MailboxSendOperator.StatKey> stage = workerStats(5);
+    stage.merge(workerStats(100));
+    stage.merge(workerStats(0));
+
+    // Then:
+    assertEquals(stage.getLong(MailboxSendOperator.StatKey.EMITTED_ROWS), 
105L, "expected rows to be summed");
+    assertEquals(stage.getInt(MailboxSendOperator.StatKey.NON_ACTIVE_WORKERS), 
1, "expected 1 of 3 workers idle");
+    assertEquals(stage.getLong(MailboxSendOperator.StatKey.MAX_EMITTED_ROWS), 
100L, "expected max across workers");
+  }
+
+  /// The stage's clock time is derived by dividing the summed execution time 
by the parallelism, which assumes the
+  /// work was spread evenly. This is the same measure on the worker that took 
longest, so it must survive the
+  /// merge as a maximum rather than a sum.
+  @Test
+  public void shouldReportTheSlowestWorkersClockTime() {
+    StatMap<MailboxSendOperator.StatKey> slow = new 
StatMap<>(MailboxSendOperator.StatKey.class);
+    slow.merge(MailboxSendOperator.StatKey.MAX_CLOCK_TIME_MS, 500L);
+    StatMap<MailboxSendOperator.StatKey> fast = new 
StatMap<>(MailboxSendOperator.StatKey.class);
+    fast.merge(MailboxSendOperator.StatKey.MAX_CLOCK_TIME_MS, 10L);
+
+    slow.merge(fast);
+
+    assertEquals(slow.getLong(MailboxSendOperator.StatKey.MAX_CLOCK_TIME_MS), 
500L,
+        "expected the slowest worker to win the merge, not the sum of the 
two");
+  }
+
+  @Test
+  public void shouldPreservePerWorkerStatsAcrossSerialization()
+      throws Exception {
+    // Given: two workers whose stats are merged through the serialized form, 
as they are across servers
+    StatMap<MailboxSendOperator.StatKey> stage = workerStats(0);
+    ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+    workerStats(100).serialize(new DataOutputStream(bytes));
+
+    // When:
+    stage.merge(new DataInputStream(new 
ByteArrayInputStream(bytes.toByteArray())));
+
+    // Then:
+    assertEquals(stage.getInt(MailboxSendOperator.StatKey.NON_ACTIVE_WORKERS), 
1, "expected one idle worker");
+    assertEquals(stage.getLong(MailboxSendOperator.StatKey.MAX_EMITTED_ROWS), 
100L, "expected max across workers");
+  }
+
+  /// Builds the stats a single worker sending `emittedRows` rows would report.
+  ///
+  /// Each worker gets its own input mock so that draining one does not 
exhaust the stubbing of the next.
+  private StatMap<MailboxSendOperator.StatKey> workerStats(int emittedRows) {
+    MultiStageOperator input = mock(MultiStageOperator.class);
+    
when(input.calculateStats()).thenReturn(MultiStageQueryStats.emptyStats(SENDER_STAGE_ID));
+    if (emittedRows == 0) {
+      when(input.nextBlock()).thenReturn(SuccessMseBlock.INSTANCE);
+    } else {
+      when(input.nextBlock()).thenReturn(getDummyDataBlock(emittedRows), 
SuccessMseBlock.INSTANCE);
+    }
+    MailboxSendOperator operator = getOperator(input);
+    drain(operator);
+    return operator.copyStatMaps();
+  }
+
+  private static void drain(MailboxSendOperator operator) {
+    MseBlock block = operator.nextBlock();
+    while (block.isData()) {
+      block = operator.nextBlock();
+    }
+  }
+
   private MailboxSendOperator getOperator() {
+    return getOperator(_input);
+  }
+
+  private MailboxSendOperator getOperator(MultiStageOperator input) {
     WorkerMetadata workerMetadata = new WorkerMetadata(0, Map.of(), Map.of());
     StageMetadata stageMetadata = new StageMetadata(SENDER_STAGE_ID, 
List.of(workerMetadata), Map.of());
     OpChainExecutionContext context =
         OpChainExecutionContext.fromQueryContext(_mailboxService, Map.of(), 
stageMetadata, workerMetadata, null, true,
             true, QueryExecutionContext.forMseTest());
-    return new MailboxSendOperator(context, _input, statMap -> _exchange);
+    return new MailboxSendOperator(context, input, statMap -> _exchange);
   }
 
   private static MseBlock.Data getDummyDataBlock() {
+    return getDummyDataBlock(1);
+  }
+
+  /// Returns a single data block holding `numRows` rows, which must be at 
least one.
+  private static MseBlock.Data getDummyDataBlock(int numRows) {
+    Object[][] rows = new Object[numRows][];
+    Arrays.setAll(rows, i -> new Object[]{i});
     return OperatorTestUtil.block(new DataSchema(new String[]{"intCol"}, new 
ColumnDataType[]{ColumnDataType.INT}),
-        new Object[]{1});
+        rows);
   }
 }
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 b0d0eb3c404..951d7ae15ed 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
@@ -27,6 +27,7 @@ import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
+import javax.annotation.Nullable;
 import org.apache.pinot.common.response.broker.ResultTable;
 import org.apache.pinot.query.QueryEnvironmentTestBase;
 import org.apache.pinot.query.QueryServerEnclosure;
@@ -203,6 +204,134 @@ public class QueryRunnerTest extends QueryRunnerTestBase {
     return checked;
   }
 
+  /// Runs a shuffling query over the two-server setup and checks the 
per-worker stats reported by every stage.
+  /// This is the only place these stats are exercised end to end, through 
real multi-worker stages and the
+  /// cross-server merge of their stat maps.
+  @Test
+  public void testPerWorkerStats() {
+    ObjectNode statsTree = statsTreeOf("SELECT col1, COUNT(*) FROM a GROUP BY 
col1");
+
+    // Idle workers are reported rather than active ones, so a query where 
every worker did something must not
+    // report any: their absence is the healthy signal.
+    Assert.assertNull(findFieldOwner(statsTree, "nonActiveWorkers"),
+        "expected no idle worker in a query where every worker contributes, 
got: " + statsTree);
+
+    // Assertions comparing a single worker's stats against the stage totals 
collapse to identities on a
+    // single-worker stage, so require a stage that actually ran on several 
workers, otherwise this test would keep
+    // passing if the cross-worker merge broke.
+    int multiWorkerSends = assertSendStats(statsTree);
+    Assert.assertTrue(multiWorkerSends > 0,
+        "expected a multi-worker send reporting maxEmittedRows and 
maxClockTimeMs, got: " + statsTree);
+  }
+
+  /// Each operator decides for itself which workers it was idle on, so a 
query whose filter matches nothing
+  /// separates them: the leaf operators were handed segments and are not 
idle, while everything above them sent
+  /// and received nothing and is idle on every worker.
+  @Test
+  public void testPerWorkerStatsWhenNothingMatches() {
+    ObjectNode statsTree = statsTreeOf("SELECT col1, COUNT(*) FROM a WHERE 
col1 = 'no-such-value' GROUP BY col1");
+
+    // StatMap drops zero-valued keys, so an absent field means zero 
throughout.
+    ObjectNode leaf = findNodeOfType(statsTree, "LEAF");
+    Assert.assertNotNull(leaf, "expected a LEAF node in " + statsTree);
+    Assert.assertNull(leaf.get("nonActiveWorkers"),
+        "expected workers with segments assigned not to be idle even though 
they emitted nothing: " + leaf);
+
+    ObjectNode leafStageSend = findSendAboveLeaf(statsTree);
+    Assert.assertNotNull(leafStageSend, "expected a leaf stage in " + 
statsTree);
+    Assert.assertEquals(leafStageSend.path("emittedRows").asLong(0), 0L, 
"expected the leaf stage to send nothing");
+    Assert.assertEquals(leafStageSend.path("nonActiveWorkers").asLong(0),
+        leafStageSend.path("parallelism").asLong(0),
+        "expected every worker of a send that sent nothing to be idle: " + 
leafStageSend);
+
+    ObjectNode receive = findNodeOfType(statsTree, "MAILBOX_RECEIVE");
+    Assert.assertNotNull(receive, "expected a MAILBOX_RECEIVE node in " + 
statsTree);
+    Assert.assertEquals(receive.path("nonActiveWorkers").asLong(0), 
receive.path("parallelism").asLong(0),
+        "expected every worker of a receive that got no row to be idle: " + 
receive);
+  }
+
+  private ObjectNode statsTreeOf(@Language("sql") String sql) {
+    QueryDispatcher.QueryResult queryResult = queryRunner(sql, true);
+    Map<Integer, DispatchablePlanFragment> planNodes = 
planQuery(sql).getQueryPlan().getQueryStageMap();
+    return new MultiStageStatsTreeBuilder(planNodes, 
queryResult.getQueryStats()).jsonStatsByStage(1);
+  }
+
+  @Nullable
+  private static ObjectNode findNodeOfType(JsonNode node, String type) {
+    if (type.equals(node.path("type").asText())) {
+      return (ObjectNode) node;
+    }
+    for (JsonNode child : node.path("children")) {
+      ObjectNode found = findNodeOfType(child, type);
+      if (found != null) {
+        return found;
+      }
+    }
+    return null;
+  }
+
+  /// Returns the first node in the tree carrying `field`, or null if none 
does.
+  @Nullable
+  private static ObjectNode findFieldOwner(JsonNode node, String field) {
+    if (node.get(field) != null) {
+      return (ObjectNode) node;
+    }
+    for (JsonNode child : node.path("children")) {
+      ObjectNode found = findFieldOwner(child, field);
+      if (found != null) {
+        return found;
+      }
+    }
+    return null;
+  }
+
+  /// Returns the MAILBOX_SEND node of the leaf stage, that is, the one 
holding the LEAF operator.
+  @Nullable
+  private static ObjectNode findSendAboveLeaf(JsonNode node) {
+    for (JsonNode child : node.path("children")) {
+      if ("MAILBOX_SEND".equals(node.path("type").asText()) && 
"LEAF".equals(child.path("type").asText())) {
+        return (ObjectNode) node;
+      }
+      ObjectNode found = findSendAboveLeaf(child);
+      if (found != null) {
+        return found;
+      }
+    }
+    return null;
+  }
+
+  /// Asserts the per-worker invariants on every send node reporting them, and 
returns how many of those ran on
+  /// more than one worker.
+  private static int assertSendStats(JsonNode node) {
+    int multiWorker = 0;
+    JsonNode maxEmittedRows = node.get("maxEmittedRows");
+    if (maxEmittedRows != null) {
+      long max = maxEmittedRows.asLong();
+      long emitted = node.path("emittedRows").asLong(0);
+      long parallelism = node.path("parallelism").asLong(0);
+      String ctx = " for node " + node;
+
+      // Both are counts over one worker while emittedRows is the sum over all 
of them, so neither can exceed it.
+      // This is what catches a merge function summing where it should take an 
extremum.
+      Assert.assertTrue(max <= emitted, "maxEmittedRows " + max + " exceeds 
emittedRows " + emitted + ctx);
+
+      long maxClockTimeMs = node.path("maxClockTimeMs").asLong(0);
+      long executionTimeMs = node.path("executionTimeMs").asLong(0);
+      Assert.assertTrue(maxClockTimeMs <= executionTimeMs,
+          "maxClockTimeMs " + maxClockTimeMs + " exceeds the summed 
executionTimeMs " + executionTimeMs + ctx);
+      Assert.assertTrue(maxClockTimeMs >= node.path("clockTimeMs").asLong(0),
+          "maxClockTimeMs " + maxClockTimeMs + " is below the average 
clockTimeMs" + ctx);
+
+      if (parallelism > 1) {
+        multiWorker++;
+      }
+    }
+    for (JsonNode child : node.path("children")) {
+      multiWorker += assertSendStats(child);
+    }
+    return multiWorker;
+  }
+
   /// 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