yashmayya commented on code in PR #18458:
URL: https://github.com/apache/pinot/pull/18458#discussion_r3352990417


##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/DispatchClient.java:
##########
@@ -130,6 +133,38 @@ public void submit(Worker.QueryRequest request, 
QueryServerInstance virtualServe
     _dispatchStub.withDeadline(deadline).submit(request, new 
LastValueDispatchObserver<>(virtualServer, callback));
   }
 
+  /**
+   * Opens a {@code SubmitWithStream} bidi RPC for one server, sends the 
initial {@code submit}, and registers the
+   * resulting {@link StreamingDispatchObserver} with {@code session} for 
cancel fan-out and {@code OpChainComplete}
+   * accumulation.
+   *
+   * <p>The submit-ack callback is invoked exactly once: with the {@link 
Worker.QueryResponse} on the first
+   * {@code submit_ack} from the server, or with a non-null {@link Throwable} 
if the stream errors before the ack
+   * arrives.
+   *
+   * @param request               the plan submission
+   * @param virtualServer         server identity (used in callbacks for 
routing decisions on failure)
+   * @param deadline              gRPC deadline for the call
+   * @param session               broker-side streaming session — the returned 
observer registers itself here
+   * @param expectedOpChainsForThisServer  number of opchains this server is 
expected to report; used to drain the
+   *                              session latch correctly when the stream 
errors before all opchains have responded
+   * @param ackCallback           receives the submit-ack response or a 
failure throwable
+   * @return the observer, also exposed as
+   *         {@link 
org.apache.pinot.query.service.dispatch.streaming.StreamingServerHandle} on the 
session for
+   *         cancel fan-out
+   */
+  public StreamingDispatchObserver submitWithStream(Worker.QueryRequest 
request, QueryServerInstance virtualServer,
+      Deadline deadline, StreamingQuerySession session, int 
expectedOpChainsForThisServer,
+      BiConsumer<Worker.QueryResponse, Throwable> ackCallback) {
+    StreamingDispatchObserver observer = new 
StreamingDispatchObserver(virtualServer, session,
+        expectedOpChainsForThisServer, ackCallback);
+    StreamObserver<Worker.BrokerToServer> outbound = 
_dispatchStub.withDeadline(deadline).submitWithStream(observer);

Review Comment:
   There's no `UNIMPLEMENTED` fallback here. The PR description's compat table 
says it's "Implemented in DispatchClient", but this just opens the stream and 
sends — the accurate doc is the one on `submitAndReduceWithStream` ("No 
automatic fallback … will cause query failures").
   
   So if stream mode is enabled while any server is still on an older version 
(rolling upgrade, or a single rollback), every MSE query fails. It's behind the 
default-off flag so that's tolerable, but the description should match the 
code, and a loud warning at the call site (or an actual fallback) would help 
stop someone flipping this mid-upgrade.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/QueryDispatcher.java:
##########
@@ -243,19 +459,52 @@ private QueryResult tryRecover(long requestId, 
Set<QueryServerInstance> servers,
     } else if (ex instanceof QueryException) {
       errorCode = ((QueryException) ex).getErrorCode();
     } else {
-      // in case of unknown exceptions, the exception will be rethrown, so we 
don't need stats
       cancel(requestId, servers);
       throw ex;
     }
-    // in case of known exceptions (timeout or query exception), we need can 
build here the erroneous QueryResult
-    // that include the stats.
-    LOGGER.warn("Query failed with a known exception. Trying to cancel the 
other opchains");
-    MultiStageQueryStats stats = cancelWithStats(requestId, servers);
-    if (stats == null) {
+    LOGGER.warn("Query failed with a known exception. Cancelling remaining 
opchains.");
+    cancel(requestId, servers);
+    QueryProcessingException processingException = new 
QueryProcessingException(errorCode, ex.getMessage());
+    return new QueryResult(processingException, 
MultiStageQueryStats.emptyStats(0), 0L);

Review Comment:
   Worth noting this changes legacy behavior too: on master this path returned 
partial stats via `cancelWithStats(...)`; now it returns empty stats. It only 
bites when dispatch throws a known exception (a normal broker timeout still 
returns an error block *with* stats), and dropping the per-failure cancel 
fan-out is a reasonable trade — but it is a default-path change, so a note in 
the description/release notes would be good.
   
   Minor: the `QueryRunner.cancel` Javadoc saying the consumer "was reverted at 
the same time" reads as if it was already gone — master's `tryRecover` did 
consume it; this PR removes it.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/service/server/QueryServer.java:
##########
@@ -450,6 +454,296 @@ private void 
submitTimeSeriesInternal(Worker.TimeSeriesQueryRequest request,
     }
   }
 
+  /// Stream-mode submission handler. The broker keeps the stream open for the 
query lifetime; the server replies
+  /// with a {@code submit_ack} as the first message and (in subsequent 
commits) per-opchain
+  /// {@link Worker.OpChainComplete} messages followed by a final {@link 
Worker.ServerDone}.
+  ///
+  /// This skeleton wires up the gRPC mechanics + plan submission via the 
existing submission path. It does NOT yet
+  /// emit OpChainComplete / ServerDone — those need a per-opchain completion 
hook on
+  /// {@link org.apache.pinot.query.runtime.executor.OpChainSchedulerService}, 
which is layered on next.
+  /// Cancel still routes through the existing unary {@link 
#cancel(Worker.CancelRequest, StreamObserver)} RPC; broker
+  /// stream-close also triggers a cancel here.
+  @Override
+  public StreamObserver<Worker.BrokerToServer> submitWithStream(
+      StreamObserver<Worker.ServerToBroker> responseObserver) {
+    return new SubmitWithStreamObserver(responseObserver);
+  }
+
+  /// Per-query state for an open {@code SubmitWithStream} call. Owns the 
response stream and serialises every
+  /// {@code onNext} call on it via a {@code synchronized} block — gRPC 
requires {@code StreamObserver.onNext} to be
+  /// called serially.
+  ///
+  /// Tracks the expected number of opchains for the request (sum of 
WorkerMetadata across all stages). An
+  /// {@link OpChainCompletionListener} registered with {@link 
QueryRunner#registerOpChainCompletionListener}
+  /// fires once per opchain finishing, encodes its stats via {@link 
MultiStageStatsTreeEncoder}, and emits an
+  /// {@link Worker.OpChainComplete} on the response stream. When the 
per-request completed-count reaches the
+  /// expected total, {@link Worker.ServerDone} is emitted and the stream is 
closed.
+  ///
+  /// All blocking work (plan deserialization, opchain construction) runs on
+  /// {@link QueryServer#_submissionExecutorService}.
+  private final class SubmitWithStreamObserver implements 
StreamObserver<Worker.BrokerToServer> {
+    private final StreamObserver<Worker.ServerToBroker> _responseObserver;
+    /// Serialises onNext calls on the response stream and guards mutable 
session state.
+    private final Object _streamLock = new Object();
+    /// True once we've received the first {@code submit} and dispatched it.
+    private final AtomicBoolean _submitted = new AtomicBoolean(false);
+    /// True once we've completed the response stream (success or error). 
Idempotent guard.
+    private final AtomicBoolean _completed = new AtomicBoolean(false);
+    /// Number of opchains we expect to report for this request — set after we 
deserialize the plan.
+    private final AtomicInteger _expectedOpChains = new AtomicInteger(-1);
+    /// Number of opchains that have reported so far via the completion 
listener.
+    private final AtomicInteger _completedOpChains = new AtomicInteger(0);
+    /// Set once we successfully parse the request id from the submit 
metadata. Used by cancel-via-stream.
+    private volatile long _requestId = -1;
+    /// Completed once {@link #sendSubmitAck} has been called (success or 
error path). Guards the ack/done race:
+    /// if a trivial opchain finishes before the {@code whenComplete} callback 
fires, {@code onOpChainComplete}
+    /// waits for this future via {@code thenRun} instead of calling {@link 
#sendDoneAndComplete} immediately,
+    /// ensuring the broker always receives the {@code submit_ack} before 
{@code ServerDone}.
+    private final CompletableFuture<Void> _ackSentFuture = new 
CompletableFuture<>();
+
+    SubmitWithStreamObserver(StreamObserver<Worker.ServerToBroker> 
responseObserver) {
+      _responseObserver = responseObserver;
+    }
+
+    @Override
+    public void onNext(Worker.BrokerToServer message) {
+      switch (message.getPayloadCase()) {
+        case SUBMIT:
+          handleSubmit(message.getSubmit());
+          break;
+        case CANCEL:
+          handleCancel(message.getCancel());
+          break;
+        case PAYLOAD_NOT_SET:
+        default:
+          sendErrorAndComplete("Unexpected BrokerToServer payload: " + 
message.getPayloadCase());
+          break;
+      }
+    }
+
+    @Override
+    public void onError(Throwable t) {
+      // Broker-side stream error / disconnect. Treat like a cancel and clean 
up; do not reply on the response stream
+      // (the underlying transport is gone). Use compareAndSet to be 
consistent with sendDoneAndComplete and avoid
+      // double-cancelling if onOpChainComplete already completed the stream 
first.
+      LOGGER.warn("SubmitWithStream stream error for request {}: {}", 
_requestId, t.getMessage());
+      if (_completed.compareAndSet(false, true)) {
+        cleanupListener();
+        cancelIfSubmitted();
+      }
+    }
+
+    @Override
+    public void onCompleted() {
+      // Broker has half-closed (no more inbound messages). The server stream 
stays open until all opchains have
+      // reported via the completion listener — it's the listener's job to 
emit ServerDone and complete the stream.
+      // If the broker half-closes before the server is done, that's OK; we 
keep emitting on the response stream
+      // until our own completion criterion is met.
+    }
+
+    private void handleSubmit(Worker.QueryRequest request) {
+      if (!_submitted.compareAndSet(false, true)) {
+        sendErrorAndComplete("Multiple submit messages on the same stream are 
not allowed");
+        return;
+      }
+      ServerMetrics.get().addMeteredGlobalValue(ServerMeter.MSE_QUERIES, 1L);
+      Map<String, String> deserializedMetadata;
+      try {
+        deserializedMetadata = 
QueryPlanSerDeUtils.fromProtoProperties(request.getMetadata());
+      } catch (Exception e) {
+        LOGGER.error("Caught exception while deserializing request metadata", 
e);
+        sendErrorAndComplete("Caught exception while deserializing request 
metadata: " + e.getMessage());
+        return;
+      }
+      // Override the cluster-level _sendStats decision for this request: 
stats travel out-of-band on the bidi stream
+      // (via the OpChainCompletionListener), so we suppress the mailbox-side 
stats path. The override is read by
+      // QueryRunner.effectiveSendStats(...).
+      Map<String, String> reqMetadata = new HashMap<>(deserializedMetadata);
+      
reqMetadata.put(CommonConstants.MultiStageQueryRunner.KEY_OF_STATS_REPORTING_MODE,
+          CommonConstants.MultiStageQueryRunner.STATS_REPORTING_MODE_STREAM);
+      try {
+        _requestId = Long.parseLong(reqMetadata.get(MetadataKeys.REQUEST_ID));
+      } catch (Exception ignored) {
+        // _requestId stays at -1; cancel-on-stream-close will just be a no-op.
+      }
+      // Count how many opchains will run on this server: sum of 
WorkerMetadata across all stage plans.
+      int opChainCount = 0;
+      for (Worker.StagePlan stagePlan : request.getStagePlanList()) {
+        opChainCount += stagePlan.getStageMetadata().getWorkerMetadataCount();

Review Comment:
   `_expectedOpChains` counts only main per-worker opchains, but 
pipeline-breaker opchains also fire the same completion listener — they go 
through `scheduler.register` in `PipelineBreakerExecutor` with the same 
requestId, and they carry the *same* `OpChainId` (stageId, workerId) as the 
stage's main opchain. So a stage with a pipeline breaker fires the listener 
twice but is only counted once: `_completedOpChains` hits the expected total 
early, `ServerDone` fires, and the real opchain's `OpChainComplete` then gets 
dropped by the `!_completed.get()` guard at the send site.
   
   Depending on completion order the broker either records the 
pipeline-breaker's tree as that stage's stats (with `missing=0`, so nothing 
signals it's wrong), or hits a shape mismatch and marks some other stage 
`missing`/`mergeFailed`. Repro: `... WHERE key IN (SELECT key FROM small)` — 
the default planner turns that into a dynamic-broadcast semi-join with a 
`PIPELINE_BREAKER` on a dispatched stage.
   
   The current tests miss it: `testJoinQuery` is a plain inner join (no PB), 
and `assertFullCoverage` only checks the counters, never the per-stage tree. 
Simplest fix is probably to not fire the stream listener for PB opchains (skip 
when the root is `Type.PIPELINE_BREAKER` — they shouldn't be reported as a 
stage anyway). Would be good to add a semi-join regression test that asserts 
the actual tree per stage.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/streaming/StreamingQuerySession.java:
##########
@@ -0,0 +1,316 @@
+/**
+ * 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.query.service.dispatch.streaming;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+import javax.annotation.Nullable;
+import org.apache.pinot.common.proto.Worker;
+import org.apache.pinot.query.runtime.plan.MultiStageStatsTreeDecoder;
+import org.apache.pinot.query.runtime.plan.StageStatsTreeNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/**
+ * Broker-side per-query state for the {@code SubmitWithStream} dispatch path. 
Owns the per-stage tree accumulator,
+ * the outstanding-opchain count, the per-stage coverage counters, and the set 
of open server streams.
+ *
+ * <p>Concurrency model — all mutating methods acquire the per-session lock, 
so the accumulator and counters need no
+ * additional internal synchronization. gRPC client {@code onNext} callbacks 
land on I/O threads and call into this
+ * session directly. Stat decoding (proto → {@link StageStatsTreeNode}) is 
done <em>outside</em> the lock to minimise
+ * lock hold time; only the map mutations that update the accumulator are 
performed under the lock.
+ *
+ * <p>Completion semantics — {@link #awaitCompletion(long, TimeUnit)} returns 
{@code true} as soon as every expected
+ * opchain has reported (early completion), and {@code false} if the timeout 
fires first. The dispatcher should call
+ * it <strong>only after</strong> the broker receiving mailbox has finished, 
so that a successful return means both
+ * "data done" and "stats fully accounted for". When it returns {@code false} 
the per-stage coverage exposes which
+ * stages are missing.
+ */
+public class StreamingQuerySession {
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(StreamingQuerySession.class);
+
+  private final long _requestId;
+  private final int _expectedOpChains;
+  private final CountDownLatch _completionLatch;
+  /**
+   * Guards {@link #_stageAccumulator}, {@link #_respondedByStage}, {@link 
#_mergeFailedByStage},
+   * {@link #_openStreams}, and {@link #_peerErrorObserved}. Lock hold time is 
proportional to the merge work (a few
+   * map operations), not to proto decode; see {@link #recordOpChainComplete} 
for why decode is done outside.
+   *
+   * <p>If lock contention becomes a bottleneck at high QPS, a virtual-thread 
actor (one VT per query draining from
+   * an {@code ArrayBlockingQueue}, with gRPC I/O threads simply enqueuing) 
would eliminate the lock entirely and
+   * avoid any contention between concurrent inbound callbacks.
+   */
+  private final ReentrantLock _lock = new ReentrantLock();
+
+  /** Per-stage merged accumulator. Mutated under {@link #_lock}. */
+  private final Map<Integer, StageStatsTreeNode> _stageAccumulator = new 
HashMap<>();
+  /** Per-stage count of opchains that have responded successfully and merged 
cleanly. */
+  private final Map<Integer, Integer> _respondedByStage = new HashMap<>();
+  /** Per-stage count of opchains that responded but the broker couldn't merge 
their payload. */
+  private final Map<Integer, Integer> _mergeFailedByStage = new HashMap<>();
+
+  /** Set of open server streams. Iteration order is insertion order so cancel 
fan-out is deterministic. */
+  private final Set<StreamingServerHandle> _openStreams = new 
LinkedHashSet<>();
+
+  /** True after the first peer error (success=false OpChainComplete or stream 
onError). Used to trigger fan-out
+   * cancel idempotently. */
+  private boolean _peerErrorObserved = false;
+
+  public StreamingQuerySession(long requestId, int expectedOpChains) {
+    _requestId = requestId;
+    _expectedOpChains = expectedOpChains;
+    _completionLatch = new CountDownLatch(expectedOpChains);
+  }
+
+  public long getRequestId() {
+    return _requestId;
+  }
+
+  public int getExpectedOpChains() {
+    return _expectedOpChains;
+  }
+
+  /**
+   * Registers an open server stream so the session can iterate them later for 
fan-out cancel. Must be called by the
+   * dispatcher when the {@code SubmitWithStream} call is opened.
+   */
+  public void registerStream(StreamingServerHandle stream) {
+    _lock.lock();
+    try {
+      _openStreams.add(stream);
+    } finally {
+      _lock.unlock();
+    }
+  }
+
+  /**
+   * Removes a stream from the open-streams set. Called when the server emits 
{@code ServerDone} (clean close) or the
+   * stream errors. Idempotent.
+   */
+  public void unregisterStream(StreamingServerHandle stream) {
+    _lock.lock();
+    try {
+      _openStreams.remove(stream);
+    } finally {
+      _lock.unlock();
+    }
+  }
+
+  /**
+   * Records an {@link Worker.OpChainComplete} message decoded from a server 
stream. Decrements the outstanding count
+   * and merges the contained tree into the per-stage accumulator (or marks 
the stage {@code mergeFailed} on a shape
+   * mismatch / decode failure). Also records {@code success=false} reports as 
peer errors so fan-out cancel can fire.
+   *
+   * <p>Decoding (proto → {@link StageStatsTreeNode}) is performed 
<em>before</em> acquiring {@link #_lock} because
+   * the input proto is immutable and {@link 
MultiStageStatsTreeDecoder.Decoded} is a fresh allocation. Only the map
+   * mutations are done under the lock, which keeps lock hold time 
proportional to the (small) merge work rather than
+   * the full recursive decode.
+   */
+  public void recordOpChainComplete(Worker.OpChainComplete message) {
+    int stageId = message.getStageId();
+    boolean isSuccess = message.getSuccess();
+    Worker.MultiStageStatsTree statsTree = message.getStats();
+
+    // Decode outside the lock — proto is immutable, Decoded is a fresh 
allocation with no shared state.
+    MultiStageStatsTreeDecoder.Decoded decoded = null;
+    MultiStageStatsTreeDecoder.DecodeFailedException decodeError = null;
+    if (statsTree.hasCurrentStage()) {
+      try {
+        decoded = MultiStageStatsTreeDecoder.decode(statsTree);
+      } catch (MultiStageStatsTreeDecoder.DecodeFailedException e) {
+        decodeError = e;
+      }
+    }
+
+    boolean shouldFanOutCancel = false;
+    _lock.lock();
+    try {
+      if (!isSuccess) {
+        if (!_peerErrorObserved) {
+          _peerErrorObserved = true;
+          shouldFanOutCancel = true;
+        }
+      }
+      if (decodeError != null) {
+        LOGGER.warn("Decode failed for opchain stage={} worker={} on request 
{}: {}",
+            stageId, message.getWorkerId(), _requestId, 
decodeError.getMessage());
+        incrementLocked(_mergeFailedByStage, stageId);
+      } else if (decoded != null) {
+        mergeIntoAccumulatorLocked(decoded.getCurrentStageId(), 
decoded.getCurrentStage());
+        for (Map.Entry<Integer, StageStatsTreeNode> upstream : 
decoded.getUpstreamStages().entrySet()) {
+          mergeIntoAccumulatorLocked(upstream.getKey(), upstream.getValue());
+        }
+        incrementLocked(_respondedByStage, stageId);

Review Comment:
   Minor: on a shape mismatch the merge increments `_mergeFailedByStage` (in 
the helper) and then this line also increments `_respondedByStage`, so 
`responded + mergeFailed` can exceed `expected` for the stage (the `missing = 
max(0, …)` in the dispatcher hides the negative). Results are fine, but the 
coverage triple won't reconcile in exactly the version-skew case someone would 
be reading it for.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/plan/PlanNodeToOpChain.java:
##########
@@ -96,16 +96,56 @@ public static OpChain convert(PlanNode node, 
OpChainExecutionContext context) {
    */
   public static OpChain convert(PlanNode node, OpChainExecutionContext context,
       BiConsumer<PlanNode, MultiStageOperator> tracker) {
-    MyVisitor visitor = new MyVisitor(tracker);
+    // Assign deterministic stage-scoped ids to every PlanNode reachable from 
the root before constructing operators,
+    // so the encoder can attach plan_node_ids to each StageStatsNode without 
needing to mutate or re-walk the plan.
+    // Both broker and server perform this same pre-walk over the same plan 
structure, producing matching ids.
+    // Skip the walk (and the per-opchain map population) when stats are not 
going to be sent — this is the common case
+    // in legacy mode and avoids O(depth) allocations on the hot path.
+    if (context.isSendStats()) {

Review Comment:
   This gate is `isSendStats()`, which is true by default (SAFE mode) — not 
"stream mode on". So the id pre-walk + the leaf `collectPlanNodeSubTree` walk + 
the two `IdentityHashMap`s all run on every legacy query, even though only the 
stream-mode encoder ever reads those maps. The comment below ("skip … common 
case in legacy mode") doesn't hold.
   
   It's cheap per stage, but it's avoidable work added to the default path — 
gating on stream mode instead would keep legacy untouched.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/QueryDispatcher.java:
##########
@@ -225,10 +247,204 @@ public QueryResult submitAndReduce(RequestContext 
context, DispatchableSubPlan d
     }
   }
 
-  /// Tries to recover from an exception thrown during query dispatching.
+  /// Streaming variant of {@link #submitAndReduce}: opens one {@code 
SubmitWithStream} bidi RPC per server, runs the
+  /// broker's stage 0 reducer, and once the receiving mailbox finishes awaits 
the per-stage stats with early
+  /// completion (returns as soon as every expected opchain has reported, or 
when the wait window fires — whichever
+  /// happens first). Stats from the session accumulator are then merged into 
the broker's local stage 0 stats to
+  /// build the final {@link QueryResult}.
+  ///
+  /// The wait window is bounded by the query's remaining timeout: if {@code 
submitWithStream + runReducer} consumed
+  /// most of the budget, the per-stage stats may end up partial (visible via 
the per-stage {@code mergeFailed} /
+  /// {@code missing} counts the session exposes).
   ///
-  /// [QueryException] and [TimeoutException] are handled by returning a 
[QueryResult] with the error code and stats,
-  /// while other exceptions are not known, so they are directly rethrown.
+  /// Cancel is handled via {@link StreamingQuerySession#fanOutCancel()} — no 
unary Cancel RPCs are issued for this
+  /// query path. On any error, fan-out cancel is broadcast over the open 
streams, then the broker waits for remaining
+  /// stats before building the final result.
+  ///
+  /// <b>Mixed-version policy.</b> No automatic fallback to the unary {@link 
#submit} path. Enabling
+  /// {@link CommonConstants.Broker.Request.QueryOptionKey#STREAM_STATS} 
requires every server in the
+  /// cluster to implement {@code SubmitWithStream}; if any server returns 
{@code UNIMPLEMENTED} or any other
+  /// transport error during dispatch, {@link #submitWithStream} surfaces the 
throwable through the ack queue,
+  /// {@link #processResults} throws, and this method fans out cancel via the 
session before propagating the failure.
+  private QueryResult submitAndReduceWithStream(RequestContext context, 
DispatchableSubPlan dispatchableSubPlan,
+      long timeoutMs, Map<String, String> queryOptions)
+      throws Exception {
+    long requestId = context.getRequestId();
+    long deadlineMs = System.currentTimeMillis() + timeoutMs;
+    Set<QueryServerInstance> servers = new HashSet<>();
+
+    // The session's expected-opchain count must equal the total number of 
opchains across every (server, non-root
+    // stage) pair — that's how many OpChainComplete messages we expect to 
receive.
+    Set<DispatchablePlanFragment> stagePlansWithoutRoot = 
dispatchableSubPlan.getQueryStagesWithoutRoot();
+    int totalExpected = 0;
+    Map<Integer, Integer> expectedByStage = new HashMap<>();
+    for (DispatchablePlanFragment stagePlan : stagePlansWithoutRoot) {
+      int stageId = stagePlan.getPlanFragment().getFragmentId();
+      int stageCount = 0;
+      for (List<Integer> workerIds : 
stagePlan.getServerInstanceToWorkerIdMap().values()) {
+        stageCount += workerIds.size();
+      }
+      totalExpected += stageCount;
+      expectedByStage.put(stageId, stageCount);
+    }
+    StreamingQuerySession session = new StreamingQuerySession(requestId, 
totalExpected);
+
+    try {
+      submitWithStream(requestId, dispatchableSubPlan, timeoutMs, servers, 
queryOptions, session);
+      QueryResult brokerResult = runReducer(dispatchableSubPlan, queryOptions, 
_mailboxService);
+
+      // Receiving mailbox finished — data is ready. Wait for stats on a 
best-effort basis; cap at
+      // STATS_DRAIN_ON_SUCCESS_MS so a single slow opchain cannot delay the 
client response.
+      long statsWaitMs = Math.min(STATS_DRAIN_MS, Math.max(0, deadlineMs - 
System.currentTimeMillis()));

Review Comment:
   Two small things here:
   1. The description says the success path waits the full remaining timeout, 
but this correctly caps at `min(50ms, remaining)` — code's fine, description is 
stale.
   2. Healthy queries still pay this wait: the `OpChainComplete`s are emitted 
*after* the data EOS that `runReducer` already returned on, so the latch is 
rarely at zero yet and a successful stream-mode query waits up to ~50ms it 
wouldn't in legacy. Bounded and fine, but might be worth making 
`STATS_DRAIN_MS` configurable.
   
   (The comment just above refers to `STATS_DRAIN_ON_SUCCESS_MS`, which doesn't 
exist — it's `STATS_DRAIN_MS`.)



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/executor/OpChainSchedulerService.java:
##########
@@ -69,6 +72,25 @@ public class OpChainSchedulerService {
   private final ReadWriteLock[] _queryLocks;
   private final Cache<Long, Boolean> _cancelledQueryCache;
   private final Metrics _metrics = new Metrics();
+  /// Per-request opchain completion listeners used by the stream-mode 
stats-reporting path. A listener is registered
+  /// before the broker submits the query and fires once for every opchain 
that runs on this server for that request.
+  /// Listeners are responsible for unregistering themselves once they've 
consumed all expected events.
+  private final ConcurrentMap<Long, OpChainCompletionListener> 
_completionListeners = new ConcurrentHashMap<>();
+  /// Maps requestId → QueryExecutionContext for O(1) cancel. An entry is 
added when the first opchain for a request
+  /// registers and removed when cancel() fires or when the last opchain for 
that request completes. The counter map
+  /// below tracks how many opchains are still active per request so we know 
when to clean up.
+  ///
+  /// Consistency invariant: all mutations of BOTH maps for the same requestId 
must occur inside
+  /// `_activeOpChainsByRequest.compute(requestId, …)`. The compute() bin-lock 
for the requestId key serializes
+  /// all register and decrement operations, keeping the two maps coherent. 
cancel() acquires the query write lock
+  /// BEFORE calling compute() so that register()'s read lock cannot overlap 
with the eviction window.
+  ///
+  /// NOTE: `_opChainCache.put()` in registerInternal must stay inside the 
read lock. cancel()'s cache-invalidation
+  /// forEach runs OUTSIDE the write lock (after the cancelled-query cache is 
written). It relies on the read lock
+  /// exclusion to guarantee that no register() call is mid-flight between the 
cache.put and the counter increment
+  /// when the forEach observes the cache entry.
+  private final ConcurrentMap<Long, QueryExecutionContext> 
_executionContextByRequest = new ConcurrentHashMap<>();
+  private final ConcurrentMap<Long, AtomicInteger> _activeOpChainsByRequest = 
new ConcurrentHashMap<>();

Review Comment:
   These two maps have no TTL and are cleaned up only by the completion 
callback or `cancel()`. But `_executorService.submit(task)` below can throw — 
the MSE executor is wrapped in `HardLimitExecutor` + heap-throttle — and on 
rejection the `directExecutor` callback never fires, so the entry (and the 
pinned `QueryExecutionContext`) leaks until a later cancel. The old code only 
left a TTL'd `_opChainCache` entry. A try/catch around the submit that backs 
these out on failure would close the gap.



##########
pinot-common/src/main/proto/worker.proto:
##########
@@ -100,3 +109,81 @@ message MailboxInfo {
 message Properties {
   map<string, string> property = 1;
 }
+
+// 
=============================================================================
+// SubmitWithStream — stream-mode stats reporting messages
+// 
=============================================================================
+
+// Messages flowing from broker to server on the SubmitWithStream stream.
+// First message MUST be a `submit`. Subsequent messages may carry cancel.
+message BrokerToServer {
+  oneof payload {
+    // Plan submission. Same shape as today's unary Submit request.
+    QueryRequest submit = 1;
+    // Optional cancel message. In Phase A the broker still uses the unary
+    // Cancel RPC; this field is reserved and processed by the server but only
+    // emitted by the broker in Phase B.
+    CancelRequest cancel = 2;
+  }
+}
+
+// Messages flowing from server to broker on the SubmitWithStream stream.
+// The first server message is a `submit_ack`. Each opchain that runs on this
+// server emits exactly one `opchain` message when it finishes (success or 
error).
+// After the last opchain has reported, the server emits `done` and 
half-closes.
+message ServerToBroker {
+  oneof payload {
+    // Synchronous-style ack for the submission. Same shape as today's unary
+    // QueryResponse. Carries early errors (plan parsing, malformed metadata).
+    QueryResponse submit_ack = 1;
+    // Per-stage stats from one opchain that ran on this server.
+    OpChainComplete opchain = 2;
+    // Final marker — the server has no more messages for this query.
+    ServerDone done = 3;
+  }
+}
+
+// Sent once per opchain that ran on this server, regardless of 
success/failure.
+message OpChainComplete {
+  int32 stage_id = 1;
+  int32 worker_id = 2;
+  bool success = 3;
+  string error_msg = 4;            // populated when success = false
+  MultiStageStatsTree stats = 5;   // structured, tree-shaped stats payload
+}
+
+// Final marker on the server-to-broker stream.
+message ServerDone {
+}
+
+// Multi-stage stats produced by one opchain. Carries the current stage as an
+// explicit operator tree, plus any upstream-stage trees this opchain
+// accumulated (today these reach an opchain via pipeline-breaker stats and via
+// mailbox-receive merges; in stream mode each upstream stage is also reported
+// by its own server, so the broker merges duplicates per stage).
+message MultiStageStatsTree {
+  int32 current_stage_id = 1;
+  StageStatsNode current_stage = 2;
+  // Sparse map of upstream stage id -> tree.
+  map<int32, StageStatsNode> upstream_stages = 3;
+}
+
+// One node of the operator tree for a single stage.
+message StageStatsNode {
+  // The MultiStageOperator.Type id, same byte we serialize today via
+  // MultiStageOperator.Type.getId().
+  int32 operator_type_id = 1;
+  // Plan node ids that compile down to this operator. One-to-many; the leaf
+  // operator typically owns the whole sub-tree of v1 plan nodes below the leaf
+  // boundary. Stable per request via PlanNode.getNodeId().

Review Comment:
   Doc nit: these ids aren't `PlanNode.getNodeId()` — they're a stage-scoped 
sequential counter from `assignPlanNodeIds`. Same with the "broker and server 
perform the same walk" note in `OpChainExecutionContext`/`PlanNodeToOpChain`: 
the ids are serialized on the wire, so it's really "all workers of a stage walk 
the same plan".



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to