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


##########
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:
   You're right that there is no automatic fallback — that is intentional 
(matching the `submitAndReduceWithStream` doc): stream mode requires every 
server to implement `SubmitWithStream`. To make the rolling-upgrade hazard fail 
*loud* instead of as an opaque query failure, 1476e498ba detects gRPC 
`UNIMPLEMENTED` in `StreamingDispatchObserver.onError` and logs a clear message 
telling the operator to disable stream stats (query option / 
`pinot.broker.mse.stream.stats`) until all servers are upgraded. I'll also fix 
the PR description's compat table to drop the "fallback" wording and match the 
code.



##########
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:
   Acknowledged — this is a default-path behavior change (legacy `tryRecover` 
returned partial stats via `cancelWithStats`; it now returns empty stats on a 
known dispatch exception, trading per-failure cancel fan-out for lower 
error-path load). I reworded the `QueryRunner.cancel` Javadoc in 1476e498ba so 
it no longer reads as if the consumer was already gone — it makes clear this 
change removes both the synchronous collection and its only consumer 
(`tryRecover`). I'll add a release-note line to the PR description for the 
behavior change.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/service/dispatch/streaming/StreamingQuerySession.java:
##########
@@ -0,0 +1,317 @@
+/**
+ * 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; the work per call is short (decode + merge + decrement) 
so doing it on the I/O thread is fine.
+ *
+ * <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;
+  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.
+   */
+  public void recordOpChainComplete(Worker.OpChainComplete message) {
+    int stageId = message.getStageId();
+    boolean isSuccess = message.getSuccess();
+    Worker.MultiStageStatsTree statsTree = message.getStats();
+
+    boolean shouldFanOutCancel = false;
+    _lock.lock();
+    try {
+      if (!isSuccess) {
+        if (!_peerErrorObserved) {
+          _peerErrorObserved = true;
+          shouldFanOutCancel = true;

Review Comment:
   The fan-out is now debounced to once per query for the first peer error 
(`_peerErrorObserved`), so a flaky server no longer triggers N-1 cancels per 
affected query. For observability, 1476e498ba adds the 
`MSE_STREAM_STATS_CANCEL_FANOUTS` broker meter (counts non-empty cancel 
broadcasts) so the storm condition is visible in prod. Distinguishing a 
server-returned error from a pure transport close (so transport blips don't 
cancel peers) is a reasonable refinement I'd like to do as a follow-up rather 
than in this PR.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/service/server/QueryServer.java:
##########
@@ -450,6 +454,255 @@ 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;
+
+    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).
+      LOGGER.warn("SubmitWithStream stream error for request {}: {}", 
_requestId, t.getMessage());
+      _completed.set(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();
+      }
+      final int expected = opChainCount;
+      _expectedOpChains.set(expected);
+
+      // Register the per-request completion listener BEFORE submitting. 
Otherwise short opchains could finish before
+      // we've registered and we'd miss their events.
+      if (_requestId >= 0 && expected > 0) {
+        _queryRunner.registerOpChainCompletionListener(_requestId, 
this::onOpChainComplete);
+      }
+
+      long timeoutMs = 
Long.parseLong(reqMetadata.get(QueryOptionKey.TIMEOUT_MS));
+      CompletableFuture.runAsync(() -> submitInternal(request, reqMetadata), 
_submissionExecutorService)
+          .orTimeout(timeoutMs, TimeUnit.MILLISECONDS)
+          .whenComplete((result, error) -> {
+            if (error != null) {
+              LOGGER.error("Caught exception while submitting request: {}", 
_requestId, error);
+              sendSubmitAck(buildErrorResponse("Caught exception while 
submitting request: " + error.getMessage()));
+              // Submission failed — no opchains will run, so emit ServerDone 
immediately and clean up.
+              cleanupListener();
+              sendDoneAndComplete();
+            } else {
+              sendSubmitAck(buildOkResponse());
+              // If for some reason expected was 0 (empty plan), close the 
stream now.
+              if (expected == 0) {
+                cleanupListener();
+                sendDoneAndComplete();
+              }
+            }
+          });
+    }
+
+    /**
+     * Fires once per opchain on this server completing. Encodes the stats 
into a {@link Worker.MultiStageStatsTree},
+     * emits an {@link Worker.OpChainComplete}, and emits {@link 
Worker.ServerDone} once all expected opchains have
+     * reported.
+     */
+    private void onOpChainComplete(OpChainId opChainId, MultiStageOperator 
rootOperator,
+        @Nullable MultiStageQueryStats stats, OpChainExecutionContext context, 
@Nullable Throwable error) {
+      Worker.OpChainComplete.Builder builder = 
Worker.OpChainComplete.newBuilder()
+          .setStageId(opChainId.getStageId())
+          .setWorkerId(opChainId.getVirtualServerId())
+          .setSuccess(error == null);
+      if (error != null) {
+        builder.setErrorMsg(error.getMessage() == null ? 
error.getClass().getSimpleName() : error.getMessage());
+      }
+      if (stats != null) {
+        try {
+          builder.setStats(MultiStageStatsTreeEncoder.encode(rootOperator, 
stats, context));

Review Comment:
   Re the encoder limitation noted here: the pipeline-breaker "follow-up" that 
`MultiStageStatsTreeEncoder`'s Javadoc referred to is now implemented in 
2965797a77 (the encoder grafts the folded pipeline-breaker subtree so a 
leaf-with-PB stage encodes correctly). The all-or-nothing encoder contract and 
the `onNext`/increment liveness fix discussed above still stand.



-- 
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