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

yashmayya 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 1ecbd5c99db Fail op chains of terminated MSE queries instead of 
dropping them (#19854)
1ecbd5c99db is described below

commit 1ecbd5c99db783075b04aa365f5a302fa11b3691
Author: Yash Mayya <[email protected]>
AuthorDate: Fri Oct 16 09:28:14 2026 -0700

    Fail op chains of terminated MSE queries instead of dropping them (#19854)
---
 .../apache/pinot/query/runtime/QueryRunner.java    |   9 +-
 .../runtime/executor/OpChainSchedulerService.java  | 131 ++++++++++----
 .../pinot/query/runtime/plan/OpChainConverter.java |   6 +-
 .../plan/pipeline/PipelineBreakerOperator.java     |  12 ++
 .../pinot/query/service/server/QueryServer.java    |   9 +-
 .../apache/pinot/query/QueryServerEnclosure.java   |   9 +-
 .../HardLimitExecutorOpChainRegressionTest.java    |  13 +-
 .../executor/OpChainSchedulerServiceTest.java      | 175 +++++++++++++++----
 .../plan/OpChainConverterDispatcherTest.java       |   5 +-
 .../plan/pipeline/PipelineBreakerExecutorTest.java |  51 ++++++
 .../queries/GatedExecutorServicePlugin.java        | 142 +++++++++++++++
 .../query/runtime/queries/QueryRunnerTestBase.java |  10 +-
 .../QueryTerminatedBeforeStageStartsTest.java      | 190 +++++++++++++++++++++
 .../apache/pinot/spi/query/QueryThreadContext.java |  10 +-
 14 files changed, 678 insertions(+), 94 deletions(-)

diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/QueryRunner.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/QueryRunner.java
index d632202afe8..c65756cda57 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/QueryRunner.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/QueryRunner.java
@@ -332,11 +332,10 @@ public class QueryRunner {
       // This can fail if the executor rejects the task.
       _opChainScheduler.register(opChain);
     } catch (RuntimeException e) {
-      // register() can fail after the op chain was built — the query was 
cancelled/terminated before scheduling
-      // (checkTermination / cancelled-query cache), or the executor rejected 
the task (e.g. under load shedding). In
-      // those cases the op chain is never scheduled, so the scheduler's 
FutureCallback, which normally close()s the
-      // chain on completion, never fires — leaking any operator resources 
that are released only in close(). Close the
-      // built chain here (idempotent; guarded so a close failure cannot mask 
the original error).
+      // register() can fail after the op chain was built, when the executor 
rejects the task (e.g. under load
+      // shedding). In that case the op chain is never scheduled, so the 
scheduler's FutureCallback, which normally
+      // close()s the chain on completion, never fires — leaking any operator 
resources that are released only in
+      // close(). Close the built chain here (idempotent; guarded so a close 
failure cannot mask the original error).
       if (opChain != null) {
         try {
           opChain.close();
diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/executor/OpChainSchedulerService.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/executor/OpChainSchedulerService.java
index 24cbdef09ec..694d54ebd41 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/executor/OpChainSchedulerService.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/executor/OpChainSchedulerService.java
@@ -21,6 +21,7 @@ package org.apache.pinot.query.runtime.executor;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
+import com.google.common.util.concurrent.AbstractFuture;
 import com.google.common.util.concurrent.FutureCallback;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFutureTask;
@@ -35,6 +36,7 @@ import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReadWriteLock;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
 import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
 import org.apache.pinot.common.datatable.StatMap;
 import org.apache.pinot.common.metrics.MseMeter;
 import org.apache.pinot.common.metrics.MseMetrics;
@@ -46,7 +48,6 @@ import org.apache.pinot.query.runtime.operator.OpChain;
 import org.apache.pinot.query.runtime.operator.OpChainId;
 import org.apache.pinot.query.runtime.plan.MultiStageQueryStats;
 import org.apache.pinot.spi.env.PinotConfiguration;
-import org.apache.pinot.spi.exception.QueryCancelledException;
 import org.apache.pinot.spi.exception.QueryErrorCode;
 import org.apache.pinot.spi.exception.TerminationException;
 import org.apache.pinot.spi.metrics.PinotMeter;
@@ -112,44 +113,38 @@ public class OpChainSchedulerService {
         .build();
   }
 
+  /// Schedules the op chain on the executor.
+  ///
+  /// If the query is terminated, or was cancelled on this server (which 
terminates it), the op chain does not run: it
+  /// is cancelled with the termination error on the calling thread, which 
sends that error to its receivers. The op
+  /// chain must not be dropped, because its receivers would then wait for 
data until the query deadline.
+  ///
+  /// @throws RuntimeException if the executor refuses the op chain
   public void register(OpChain operatorChain) {
     QueryExecutionContext executionContext = 
QueryThreadContext.get().getExecutionContext();
-    // Check if query is already terminated before acquiring the read lock.
-    checkTermination(operatorChain, executionContext);
     // Acquire read lock for the query to ensure that the query is not 
cancelled while scheduling the operator chain.
     long requestId = operatorChain.getId().getRequestId();
     Lock readLock = getQueryLock(requestId).readLock();
+    TerminationException terminateException;
     readLock.lock();
     try {
-      // Check if query is already terminated again after acquiring the read 
lock.
-      checkTermination(operatorChain, executionContext);
-      // Do not schedule the operator chain if the query has been cancelled.
       if (_cancelledQueryCache.getIfPresent(requestId) != null) {
         LOGGER.debug("({}): Query has been cancelled", operatorChain);
         executionContext.terminate(QueryErrorCode.QUERY_CANCELLATION, 
"Cancelled on: " + _instanceId);
-        throw new QueryCancelledException(
-            "Query has been cancelled before op-chain: " + 
operatorChain.getId() + " being scheduled");
-      } else {
+      }
+      terminateException = executionContext.getTerminateException();
+      if (terminateException == null) {
         registerInternal(operatorChain, executionContext);
+        return;
       }
     } finally {
       readLock.unlock();
     }
-  }
-
-  private void checkTermination(OpChain operatorChain, QueryExecutionContext 
executionContext) {
-    TerminationException terminateException = 
executionContext.getTerminateException();
-    if (terminateException != null) {
-      LOGGER.debug("({}): Query has been terminated", operatorChain, 
terminateException);
-      if (terminateException.getErrorCode() == 
QueryErrorCode.QUERY_CANCELLATION) {
-        throw new QueryCancelledException(
-            "Query has been cancelled before op-chain: " + 
operatorChain.getId() + " being scheduled");
-      } else {
-        throw new QueryCancelledException(
-            "Query has been terminated before op-chain: " + 
operatorChain.getId() + " being scheduled: "
-                + terminateException.getErrorCode() + " - " + 
terminateException.getMessage(), terminateException);
-      }
-    }
+    // Not scheduled, so that the error does not depend on the executor 
accepting the op chain
+    LOGGER.debug("({}): Query has been terminated", operatorChain, 
terminateException);
+    notifyCompletionListener(operatorChain.getId(), operatorChain, null, 
terminateException);
+    operatorChain.cancel(terminateException);
+    operatorChain.close();
   }
 
   private void registerInternal(OpChain operatorChain, QueryExecutionContext 
executionContext) {
@@ -173,11 +168,17 @@ public class OpChainSchedulerService {
     // that by passing null.
     AtomicReference<MultiStageQueryStats> statsRef = new AtomicReference<>();
 
-    // Create a ListenableFutureTask to ensure the opChain is cancelled even 
if the task is not scheduled
+    // The FutureCallback below runs on the thread that runs the op chain, 
once the op chain completes
     ListenableFutureTask<Void> listenableFutureTask = 
ListenableFutureTask.create(new TraceRunnable() {
       @Override
       public void runJob() {
         _metrics.onOpChainStarted();
+        // The query was terminated while the op chain waited for a thread. 
Fail without doing any work: the
+        // FutureCallback then cancels the op chain with the termination 
error, which sends that error to the receivers.
+        TerminationException terminateException = 
executionContext.getTerminateException();
+        if (terminateException != null) {
+          throw terminateException;
+        }
         LOGGER.trace("({}): Executing", operatorChain);
         MseBlock result = rootOperator.nextBlock();
         while (result.isData()) {
@@ -228,19 +229,25 @@ public class OpChainSchedulerService {
       }
     }, MoreExecutors.directExecutor());
 
+    OpChainTask opChainTask = new OpChainTask(listenableFutureTask);
     try {
-      _executorService.submit(listenableFutureTask);
+      // Use execute() and not submit(): submit() registers its Future with 
the execution context, and terminating the
+      // query would cancel that Future, which drops the op chain if it has 
not started yet. See OpChainTask.
+      _executorService.execute(opChainTask);
     } catch (RuntimeException e) {
-      // The MSE executor is wrapped in HardLimitExecutor + heap throttling, 
so submit() can throw
-      // RejectedExecutionException. When it does, the task never runs, so the 
directExecutor FutureCallback above
-      // (which decrements the active-opchain counter and removes the 
per-request context entry) never fires. Back
-      // that bookkeeping out here so the per-request context entry does not 
leak until a later cancel, then rethrow
-      // so the caller propagates the failure as a stage error. The caller, 
QueryRunner#processQueryBlocking,
-      // close()s the op chain on this rethrow, releasing operator resources 
that the never-fired FutureCallback
-      // would otherwise have closed.
+      // The MSE executor is wrapped in HardLimitExecutor + heap throttling, 
so execute() can throw (e.g.
+      // IllegalStateException when the limit is reached). When it does, the 
task never runs, so the directExecutor
+      // FutureCallback above (which decrements the active-opchain counter and 
removes the per-request context entry)
+      // never fires. Back that bookkeeping out here so the per-request 
context entry does not leak until a later
+      // cancel, then rethrow so the caller propagates the failure as a stage 
error. The caller,
+      // QueryRunner#processQueryBlocking, close()s the op chain on this 
rethrow, releasing operator resources that the
+      // never-fired FutureCallback would otherwise have closed.
       decrementActiveOpChains(requestId);
       throw e;
     }
+    // Registered after execute(), so that a task the executor refuses is not 
kept by the context. If the query is
+    // terminated in between, addTask() cancels the task right away.
+    executionContext.addTask(opChainTask);
   }
 
   private void decrementActiveOpChains(long requestId) {
@@ -302,12 +309,12 @@ public class OpChainSchedulerService {
   /// opchain finishes after being interrupted. In legacy mode, cancel-path 
stats are not collected.
   ///
   /// If no opchains are currently registered (pre-registration cancel race), 
the requestId is marked in the
-  /// cancelled-query cache so that any future registrations for that 
requestId are immediately rejected.
+  /// cancelled-query cache so that the op chains registered later for that 
requestId fail without running.
   public void cancel(long requestId) {
     // Acquire the write lock BEFORE eviction so that register()'s readLock 
cannot overlap with the window
     // between the context eviction and the cancelled-query cache write. 
Without the write lock first, a
     // concurrent register() could pass the cache check (cache not yet 
written) after the context was already
-    // evicted, slipping through and starting an opchain that should have been 
rejected.
+    // evicted, slipping through and running an opchain of a cancelled query.
     AtomicReference<QueryExecutionContext> ctxRef = new AtomicReference<>();
     Lock writeLock = getQueryLock(requestId).writeLock();
     writeLock.lock();
@@ -325,8 +332,8 @@ public class OpChainSchedulerService {
     }
     QueryExecutionContext context = ctxRef.get();
     if (context != null) {
-      // terminate() interrupts all registered tasks (via addTask) and sets 
the termination flag so that
-      // checkTermination() blocks any subsequent registrations for the same 
requestId.
+      // terminate() interrupts all registered tasks (via addTask) and sets 
the termination flag, so that op chains
+      // that have not started yet, and the ones registered later for the same 
requestId, fail without running.
       context.terminate(QueryErrorCode.QUERY_CANCELLATION, "Cancelled on: " + 
_instanceId);
     }
   }
@@ -335,6 +342,56 @@ public class OpChainSchedulerService {
     return _queryLocks[(int) (requestId & QUERY_LOCK_MASK)];
   }
 
+  /// Runs an op chain. It is registered with the [QueryExecutionContext] of 
the query, so that terminating the query
+  /// interrupts the op chain.
+  ///
+  /// [QueryExecutionContext#terminate] cancels the registered tasks. A 
`Future` from [ExecutorService#submit] that is
+  /// cancelled before its task starts never runs the task. An op chain 
dropped this way never sends an EOS or an error
+  /// to its receivers, so they wait for data until the query deadline. 
Cancelling this task only interrupts the op
+  /// chain while it runs. An op chain that has not started yet still runs, 
and fails on its first check: the context
+  /// sets the termination error before it cancels the tasks.
+  ///
+  /// `_lock` makes sure that an interrupt reaches the thread only while it 
runs the op chain, and never the next task
+  /// of that thread.
+  private static final class OpChainTask extends AbstractFuture<Void> 
implements Runnable {
+    private final Runnable _opChainRunner;
+    private final Object _lock = new Object();
+    @GuardedBy("_lock")
+    @Nullable
+    private Thread _thread;
+
+    OpChainTask(Runnable opChainRunner) {
+      _opChainRunner = opChainRunner;
+    }
+
+    @Override
+    public void run() {
+      synchronized (_lock) {
+        _thread = Thread.currentThread();
+      }
+      try {
+        _opChainRunner.run();
+      } finally {
+        synchronized (_lock) {
+          _thread = null;
+        }
+        // _thread is null, so interruptTask() can no longer interrupt this 
thread. Clear an interrupt that it sent
+        // earlier, after the op chain stopped checking for interrupts.
+        Thread.interrupted();
+        set(null);
+      }
+    }
+
+    @Override
+    protected void interruptTask() {
+      synchronized (_lock) {
+        if (_thread != null) {
+          _thread.interrupt();
+        }
+      }
+    }
+  }
+
   private static class Metrics {
     private final MseMetrics _mseMetrics = MseMetrics.get();
     private final PinotMeter _startedOpchains = 
_mseMetrics.getMeteredValue(MseMeter.OPCHAINS_STARTED);
diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/plan/OpChainConverter.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/plan/OpChainConverter.java
index cd87d58f60d..1f3cf546d2e 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/plan/OpChainConverter.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/plan/OpChainConverter.java
@@ -29,7 +29,6 @@ import org.apache.pinot.query.runtime.operator.ErrorOperator;
 import org.apache.pinot.query.runtime.operator.MailboxSendOperator;
 import org.apache.pinot.query.runtime.operator.MultiStageOperator;
 import org.apache.pinot.query.runtime.operator.OpChain;
-import org.apache.pinot.spi.exception.QueryErrorCode;
 
 
 /// Extension point for alternative plan-to-[OpChain] converters. The active 
implementation is chosen by
@@ -57,7 +56,8 @@ public interface OpChainConverter {
   ///                can keep track of this mapping.
   OpChain convert(PlanNode node, OpChainExecutionContext context, 
BiConsumer<PlanNode, MultiStageOperator> tracker);
 
-  /// Builds a minimal op-chain that sends an already-detected stage error to 
downstream receivers.
+  /// Builds a minimal op-chain that sends an already-detected stage error, 
with its error code, to downstream
+  /// receivers.
   ///
   /// This is used, for example, when a pipeline breaker detects an error and 
needs to propagate it to downstream
   /// stages.
@@ -71,7 +71,7 @@ public interface OpChainConverter {
     }
     MailboxSendNode rootNode = (MailboxSendNode) stagePlan.getRootNode();
     String message = extractErrorMessage(errorBlock);
-    MultiStageOperator errorOp = new ErrorOperator(context, 
QueryErrorCode.QUERY_EXECUTION, message);
+    MultiStageOperator errorOp = new ErrorOperator(context, 
errorBlock.getMainErrorCode(), message);
     MultiStageOperator sendOp = new MailboxSendOperator(context, errorOp, 
rootNode);
     return new OpChain(context, sendOp);
   }
diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/plan/pipeline/PipelineBreakerOperator.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/plan/pipeline/PipelineBreakerOperator.java
index a9f10d7a0d9..318ee51c9e2 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/plan/pipeline/PipelineBreakerOperator.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/plan/pipeline/PipelineBreakerOperator.java
@@ -101,6 +101,18 @@ public class PipelineBreakerOperator extends 
MultiStageOperator {
     return _errorBlock;
   }
 
+  /// Keeps the cancellation reason as the error, so that a pipeline breaker 
cancelled before it collected its results
+  /// (e.g. the query was terminated before it started) does not look like it 
collected no rows.
+  @Override
+  public void cancel(Throwable e) {
+    if (_errorBlock == null) {
+      // Not only an Exception: the scheduler also cancels an op chain that 
failed with an Error, e.g. an
+      // OutOfMemoryError while the pipeline breaker collects its results
+      _errorBlock = ErrorMseBlock.fromException(e instanceof Exception ? 
(Exception) e : new RuntimeException(e));
+    }
+    super.cancel(e);
+  }
+
   @Override
   public String toExplainString() {
     return EXPLAIN_NAME;
diff --git 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/server/QueryServer.java
 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/server/QueryServer.java
index bd5bde1ee96..5388a26a92e 100644
--- 
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/server/QueryServer.java
+++ 
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/service/server/QueryServer.java
@@ -755,11 +755,10 @@ public class QueryServer extends 
PinotQueryWorkerGrpc.PinotQueryWorkerImplBase {
       } catch (Throwable t) {
         LOGGER.warn("Caught exception cancelling request {} via 
SubmitWithStream", cancel.getRequestId(), t);
       }
-      // Once this stream's request is cancelled, no further meaningful 
OpChainComplete reports will arrive — in
-      // particular, opchains cancelled before they register never fire the 
completion listener, so _completedOpChains
-      // would never reach _expectedOpChains and ServerDone would never be 
sent. Close the stream promptly so the
-      // broker stops waiting out its full drain window. Gated on the ack so 
the broker still sees submit_ack before
-      // ServerDone; cleanupListener and sendDoneAndComplete are both 
idempotent, so racing onOpChainComplete is safe.
+      // Once this stream's request is cancelled, no further meaningful 
OpChainComplete reports will arrive. Close the
+      // stream promptly so the broker stops waiting out its full drain 
window. Gated on the ack so the broker still
+      // sees submit_ack before ServerDone; cleanupListener and 
sendDoneAndComplete are both idempotent, so racing
+      // onOpChainComplete is safe.
       if (cancel.getRequestId() == _requestId) {
         _ackSentFuture.thenRun(() -> {
           cleanupListener();
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/QueryServerEnclosure.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/QueryServerEnclosure.java
index 42510b40f1a..706df8fe8d8 100644
--- 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/QueryServerEnclosure.java
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/QueryServerEnclosure.java
@@ -82,7 +82,14 @@ public class QueryServerEnclosure {
 
   public CompletableFuture<Void> processQuery(WorkerMetadata workerMetadata, 
StagePlan stagePlan,
       Map<String, String> requestMetadataMap) {
-    QueryExecutionContext executionContext = 
QueryExecutionContext.forMseServerRequest(requestMetadataMap, "serverId");
+    return processQuery(workerMetadata, stagePlan, requestMetadataMap,
+        QueryExecutionContext.forMseServerRequest(requestMetadataMap, 
"serverId"));
+  }
+
+  /// Like [#processQuery(WorkerMetadata, StagePlan, Map)], but runs the stage 
in the given execution context. Like the
+  /// query server, callers can share one context between all the stages of a 
query on this server.
+  public CompletableFuture<Void> processQuery(WorkerMetadata workerMetadata, 
StagePlan stagePlan,
+      Map<String, String> requestMetadataMap, QueryExecutionContext 
executionContext) {
     QueryThreadContext.MseWorkerInfo mseWorkerInfo =
         new 
QueryThreadContext.MseWorkerInfo(stagePlan.getStageMetadata().getStageId(), 
workerMetadata.getWorkerId());
     try (QueryThreadContext ignore = QueryThreadContext.open(executionContext, 
mseWorkerInfo,
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/executor/HardLimitExecutorOpChainRegressionTest.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/executor/HardLimitExecutorOpChainRegressionTest.java
index ad5b8008286..2b458cf5c68 100644
--- 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/executor/HardLimitExecutorOpChainRegressionTest.java
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/executor/HardLimitExecutorOpChainRegressionTest.java
@@ -24,7 +24,6 @@ import java.util.Map;
 import java.util.concurrent.AbstractExecutorService;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import org.apache.pinot.common.datatable.StatMap;
 import org.apache.pinot.query.mailbox.MailboxService;
@@ -54,7 +53,9 @@ import static org.testng.Assert.assertTrue;
 /// decrements the per-request counter never fired, and the exception was 
swallowed by the `FutureTask` created by
 /// `AbstractExecutorService.submit`, so nothing was logged either.
 ///
-/// Now the limit is checked only once, on submit. A task that passes that 
check always runs.
+/// Now the limit is checked only once, on submit. A task that passes that 
check always runs. The scheduler also hands
+/// op chains to the executor with `execute()` now, not `submit()`, so an 
exception from the executor is no longer
+/// swallowed by a `FutureTask` either: the test runs the queued task 
directly, and such an exception fails it.
 public class HardLimitExecutorOpChainRegressionTest {
 
   /// Executor that queues tasks instead of running them so the test controls 
when each one starts.
@@ -139,11 +140,9 @@ public class HardLimitExecutorOpChainRegressionTest {
     threadA.start();
     assertTrue(aStarted.await(10, TimeUnit.SECONDS), "A did not start");
 
-    // Start B: it passed the submit-time check, so it must run even though A 
pushed the counter to the limit.
-    Runnable taskB = deferred._queued.get(1);
-    taskB.run();
-    assertTrue(taskB instanceof Future, "AbstractExecutorService.submit wraps 
the task in a FutureTask");
-    ((Future<?>) taskB).get(10, TimeUnit.SECONDS);
+    // Start B: it passed the submit-time check, so it must run even though A 
pushed the counter to the limit. It runs
+    // on this thread, so an exception from the executor would fail the test 
here.
+    deferred._queued.get(1).run();
     verify(operatorB).nextBlock();
     verify(operatorB).close();
 
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/executor/OpChainSchedulerServiceTest.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/executor/OpChainSchedulerServiceTest.java
index a1d21d8d151..eab550f6e99 100644
--- 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/executor/OpChainSchedulerServiceTest.java
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/executor/OpChainSchedulerServiceTest.java
@@ -20,6 +20,7 @@ package org.apache.pinot.query.runtime.executor;
 
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.Callable;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -39,9 +40,11 @@ import 
org.apache.pinot.query.runtime.operator.MultiStageOperator;
 import org.apache.pinot.query.runtime.operator.OpChain;
 import org.apache.pinot.query.runtime.plan.MultiStageQueryStats;
 import org.apache.pinot.query.runtime.plan.OpChainExecutionContext;
-import org.apache.pinot.spi.exception.QueryCancelledException;
+import org.apache.pinot.spi.exception.QueryErrorCode;
+import org.apache.pinot.spi.executor.DecoratorExecutorService;
 import org.apache.pinot.spi.executor.ExecutorServiceUtils;
 import org.apache.pinot.spi.query.QueryThreadContext;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mockito;
 import org.mockito.MockitoAnnotations;
 import org.testng.annotations.AfterClass;
@@ -53,8 +56,9 @@ import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertSame;
 import static org.testng.Assert.assertTrue;
-import static org.testng.Assert.fail;
 
 
 public class OpChainSchedulerServiceTest {
@@ -203,6 +207,127 @@ public class OpChainSchedulerServiceTest {
     Mockito.verify(_operatorA, Mockito.times(1)).cancel(Mockito.any());
   }
 
+  /// An op chain that is still waiting for a thread when the query is 
terminated must still fail with the termination
+  /// reason. Otherwise it never sends an EOS or an error to its receivers, 
and they wait until the query deadline.
+  @Test
+  public void shouldCancelOpChainWhenQueryIsTerminatedBeforeItStarts()
+      throws InterruptedException {
+    ExecutorService singleThreadExecutor =
+        Executors.newSingleThreadExecutor(new 
NamedThreadFactory("single_worker_on_" + getClass().getSimpleName()));
+    try {
+      // Keep the only thread busy, so that the op chain waits in the executor 
queue until the query is terminated.
+      CountDownLatch releaseThread = new CountDownLatch(1);
+      singleThreadExecutor.execute(() -> {
+        try {
+          releaseThread.await();
+        } catch (InterruptedException e) {
+          Thread.currentThread().interrupt();
+        }
+      });
+      CountDownLatch cancelLatch = new CountDownLatch(1);
+      AtomicReference<Throwable> cancelCause = new AtomicReference<>();
+      Mockito.doAnswer(inv -> {
+        cancelCause.set(inv.getArgument(0));
+        cancelLatch.countDown();
+        return null;
+      }).when(_operatorA).cancel(Mockito.any());
+
+      OpChainSchedulerService schedulerService =
+          new 
OpChainSchedulerService(QueryThreadContext.contextAwareExecutorService(singleThreadExecutor));
+      try (QueryThreadContext threadContext = 
QueryThreadContext.openForMseTest()) {
+        schedulerService.register(getChain(_operatorA));
+        threadContext.getExecutionContext()
+            .terminate(QueryErrorCode.SERVER_RESOURCE_LIMIT_EXCEEDED, "OOM 
killed on SERVER");
+      }
+      releaseThread.countDown();
+
+      assertTrue(cancelLatch.await(10, TimeUnit.SECONDS), "expected the op 
chain to be cancelled");
+      assertEquals(QueryErrorCode.fromThrowable(cancelCause.get(), 
QueryErrorCode.UNKNOWN),
+          QueryErrorCode.SERVER_RESOURCE_LIMIT_EXCEEDED);
+      assertEquals(cancelCause.get().getMessage(), "OOM killed on SERVER");
+      Mockito.verify(_operatorA, Mockito.timeout(10_000)).close();
+      Mockito.verify(_operatorA, Mockito.never()).nextBlock();
+    } finally {
+      singleThreadExecutor.shutdownNow();
+    }
+  }
+
+  /// An op chain registered after its query is terminated does not run, and 
must not be dropped either. It is
+  /// cancelled with the termination error before register() returns, without 
going through the executor, so that its
+  /// receivers get that error.
+  @Test
+  public void shouldCancelOpChainRegisteredAfterQueryIsTerminated() {
+    AtomicReference<Throwable> listenerError = new AtomicReference<>();
+    OpChainSchedulerService schedulerService = new 
OpChainSchedulerService(_executor);
+    schedulerService.registerCompletionListener(123L, (id, root, stats, ctx, 
error) -> listenerError.set(error));
+    try (QueryThreadContext threadContext = 
QueryThreadContext.openForMseTest()) {
+      threadContext.getExecutionContext()
+          .terminate(QueryErrorCode.SERVER_RESOURCE_LIMIT_EXCEEDED, "OOM 
killed on SERVER");
+      schedulerService.register(getChain(_operatorA));
+    }
+
+    ArgumentCaptor<Throwable> cancelCause = 
ArgumentCaptor.forClass(Throwable.class);
+    Mockito.verify(_operatorA).cancel(cancelCause.capture());
+    assertEquals(QueryErrorCode.fromThrowable(cancelCause.getValue(), 
QueryErrorCode.UNKNOWN),
+        QueryErrorCode.SERVER_RESOURCE_LIMIT_EXCEEDED);
+    assertEquals(cancelCause.getValue().getMessage(), "OOM killed on SERVER");
+    Mockito.verify(_operatorA).close();
+    Mockito.verify(_operatorA, Mockito.never()).nextBlock();
+    assertSame(listenerError.get(), cancelCause.getValue());
+    assertEquals(schedulerService.activeRequestCount(), 0);
+  }
+
+  /// Terminating the query interrupts the thread that runs the op chain. The 
interrupt must not outlive the op chain,
+  /// or it would hit the next task of that thread.
+  @Test
+  public void shouldNotLeakInterruptAfterOpChainCompletes()
+      throws InterruptedException {
+    CountDownLatch registered = new CountDownLatch(1);
+    Mockito.when(_operatorA.nextBlock()).thenAnswer(inv -> {
+      registered.await();
+      return SuccessMseBlock.INSTANCE;
+    });
+    Mockito.doAnswer(inv -> 
MultiStageQueryStats.emptyStats(0)).when(_operatorA).calculateStats();
+    // Records whether the thread is still interrupted when the task returns, 
which the next task of a pooled thread
+    // would see
+    AtomicBoolean interruptedAfterTask = new AtomicBoolean();
+    CountDownLatch taskDone = new CountDownLatch(1);
+    ExecutorService threadPool = Executors.newCachedThreadPool();
+    ExecutorService recordingExecutor = new 
DecoratorExecutorService(threadPool) {
+      @Override
+      protected <T> Callable<T> decorate(Callable<T> task) {
+        return task;
+      }
+
+      @Override
+      protected Runnable decorate(Runnable task) {
+        return () -> {
+          task.run();
+          interruptedAfterTask.set(Thread.currentThread().isInterrupted());
+          taskDone.countDown();
+        };
+      }
+    };
+    try {
+      OpChainSchedulerService schedulerService =
+          new 
OpChainSchedulerService(QueryThreadContext.contextAwareExecutorService(recordingExecutor));
+      try (QueryThreadContext threadContext = 
QueryThreadContext.openForMseTest()) {
+        // Terminate the query while the op chain closes, once it no longer 
checks for interrupts
+        Mockito.doAnswer(inv -> threadContext.getExecutionContext()
+                .terminate(QueryErrorCode.SERVER_RESOURCE_LIMIT_EXCEEDED, "OOM 
killed on SERVER"))
+            .when(_operatorA).close();
+        schedulerService.register(getChain(_operatorA));
+      }
+      registered.countDown();
+
+      assertTrue(taskDone.await(10, TimeUnit.SECONDS), "expected the op chain 
to complete");
+      Mockito.verify(_operatorA).close();
+      assertFalse(interruptedAfterTask.get(), "The interrupt of the op chain 
leaked into its thread");
+    } finally {
+      threadPool.shutdownNow();
+    }
+  }
+
   @Test
   public void shouldFireCompletionListenerOnSuccess()
       throws InterruptedException {
@@ -294,28 +419,23 @@ public class OpChainSchedulerServiceTest {
     Mockito.verify(_operatorA, Mockito.times(1)).cancel(Mockito.any());
   }
 
+  /// The cancel of a query can reach the server before its stages. The op 
chains registered after that do not run:
+  /// they are cancelled with the cancellation error.
   @Test
-  public void 
shouldThrowQueryCancelledExceptionWhenRegisteringOpChainAfterQueryCancellation()
 {
-    Mockito.when(_operatorA.nextBlock()).thenReturn(SuccessMseBlock.INSTANCE);
-    Mockito.doAnswer(inv -> 
MultiStageQueryStats.emptyStats(1)).when(_operatorA).calculateStats();
-
+  public void shouldCancelOpChainRegisteredAfterQueryCancellation() {
     OpChainSchedulerService schedulerService = new 
OpChainSchedulerService(_executor);
+    // No op chain of the query is registered yet, so only the cancelled-query 
cache keeps the cancellation
+    schedulerService.cancel(123L);
     try (QueryThreadContext ignore = QueryThreadContext.openForMseTest()) {
       schedulerService.register(getChain(_operatorA));
-
-      // First cancel the query with the same requestId (123L as defined in 
getChain method)
-      schedulerService.cancel(123L);
-
-      // Now try to register an OpChain for the same query - should throw 
QueryCancelledException
-      try {
-        schedulerService.register(getChain(_operatorA));
-        fail("Expected QueryCancelledException to be thrown when registering 
OpChain after query cancellation");
-      } catch (QueryCancelledException e) {
-        String message = e.getMessage();
-        assertTrue(message.contains("Query has been cancelled before 
op-chain"));
-        assertTrue(message.contains("being scheduled"));
-      }
     }
+
+    ArgumentCaptor<Throwable> cancelCause = 
ArgumentCaptor.forClass(Throwable.class);
+    Mockito.verify(_operatorA).cancel(cancelCause.capture());
+    assertEquals(QueryErrorCode.fromThrowable(cancelCause.getValue(), 
QueryErrorCode.UNKNOWN),
+        QueryErrorCode.QUERY_CANCELLATION);
+    Mockito.verify(_operatorA).close();
+    Mockito.verify(_operatorA, Mockito.never()).nextBlock();
   }
 
   /// Registers two opchains for the same request, waits for both to complete, 
and verifies that the per-request
@@ -374,9 +494,6 @@ public class OpChainSchedulerServiceTest {
         try {
           schedulerService.register(getChain(_operatorA));
           registrationSucceeded[0] = true;
-        } catch (QueryCancelledException e) {
-          // Expected if cancellation happens before registration
-          registrationSucceeded[0] = false;
         } finally {
           registrationCompleted.countDown();
         }
@@ -397,13 +514,13 @@ public class OpChainSchedulerServiceTest {
       // The registration should have succeeded since it started before 
cancellation
       assertTrue(registrationSucceeded[0], "Registration should have 
succeeded");
 
-      // Now try to register another OpChain with the same requestId - should 
fail
-      try {
-        schedulerService.register(getChain(_operatorA));
-        fail("Expected QueryCancelledException for subsequent registration 
after cancellation");
-      } catch (QueryCancelledException e) {
-        assertTrue(e.getMessage().contains("Query has been cancelled before 
op-chain"));
-      }
+      // An op chain registered for the same query after the cancellation does 
not run, but is cancelled
+      MultiStageOperator operatorB = Mockito.mock(MultiStageOperator.class);
+      Mockito.when(operatorB.copyStatMaps()).thenAnswer(inv -> new 
StatMap<>(MailboxSendOperator.StatKey.class));
+      schedulerService.register(getChain(operatorB));
+      Mockito.verify(operatorB, 
Mockito.timeout(10_000)).cancel(Mockito.argThat(
+          t -> QueryErrorCode.fromThrowable(t, QueryErrorCode.UNKNOWN) == 
QueryErrorCode.QUERY_CANCELLATION));
+      Mockito.verify(operatorB, Mockito.never()).nextBlock();
     }
   }
 }
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/plan/OpChainConverterDispatcherTest.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/plan/OpChainConverterDispatcherTest.java
index f2f23446966..c9aebfe1039 100644
--- 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/plan/OpChainConverterDispatcherTest.java
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/plan/OpChainConverterDispatcherTest.java
@@ -86,7 +86,8 @@ public class OpChainConverterDispatcherTest {
         new OpChainExecutionContext(mailboxService, 123L, "cid-123", 
System.currentTimeMillis() + 60_000,
             System.currentTimeMillis() + 60_000, "broker", Map.of(), 
stageMetadata, workerMetadata, null, false, false);
     StagePlan stagePlan = new StagePlan(createMailboxSendNode(schema, stageId, 
receiverStageId), stageMetadata);
-    ErrorMseBlock errorBlock = 
ErrorMseBlock.fromError(QueryErrorCode.QUERY_EXECUTION, "simulated pipeline 
failure");
+    ErrorMseBlock errorBlock =
+        ErrorMseBlock.fromError(QueryErrorCode.SERVER_RESOURCE_LIMIT_EXCEEDED, 
"simulated pipeline failure");
 
     OpChain opChain = OpChainConverterDispatcher.sendEarlyError(context, 
stagePlan, errorBlock);
     try {
@@ -98,7 +99,7 @@ public class OpChainConverterDispatcherTest {
       Assert.assertTrue(block instanceof ErrorMseBlock);
       ErrorMseBlock emittedError = (ErrorMseBlock) block;
       QueryErrorCode mainErrorCode = emittedError.getMainErrorCode();
-      Assert.assertEquals(mainErrorCode, QueryErrorCode.QUERY_EXECUTION);
+      Assert.assertEquals(mainErrorCode, 
QueryErrorCode.SERVER_RESOURCE_LIMIT_EXCEEDED);
       String message = emittedError.getErrorMessages().get(mainErrorCode);
       Assert.assertTrue(message.contains("simulated pipeline failure"),
           "Unexpected error message from early-error opchain: " + message);
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/plan/pipeline/PipelineBreakerExecutorTest.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/plan/pipeline/PipelineBreakerExecutorTest.java
index d3baa636c94..2b5524a9f8a 100644
--- 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/plan/pipeline/PipelineBreakerExecutorTest.java
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/plan/pipeline/PipelineBreakerExecutorTest.java
@@ -43,11 +43,13 @@ import org.apache.pinot.query.routing.SharedMailboxInfos;
 import org.apache.pinot.query.routing.StageMetadata;
 import org.apache.pinot.query.routing.StagePlan;
 import org.apache.pinot.query.routing.WorkerMetadata;
+import org.apache.pinot.query.runtime.blocks.ErrorMseBlock;
 import org.apache.pinot.query.runtime.blocks.MseBlock;
 import org.apache.pinot.query.runtime.blocks.SuccessMseBlock;
 import org.apache.pinot.query.runtime.executor.OpChainSchedulerService;
 import org.apache.pinot.query.runtime.operator.OperatorTestUtil;
 import org.apache.pinot.spi.accounting.ThreadAccountantUtils;
+import org.apache.pinot.spi.exception.QueryErrorCode;
 import org.apache.pinot.spi.executor.ExecutorServiceUtils;
 import org.apache.pinot.spi.query.QueryExecutionContext;
 import org.apache.pinot.spi.query.QueryThreadContext;
@@ -61,6 +63,8 @@ import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
 
 import static org.apache.pinot.common.utils.DataSchema.ColumnDataType.INT;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 
@@ -243,6 +247,53 @@ public class PipelineBreakerExecutorTest {
     latch.countDown();
   }
 
+  /// The query is terminated before its pipeline breaker starts. The pipeline 
breaker does not run, and its result
+  /// carries the termination error instead of looking like it collected no 
rows.
+  @Test
+  public void 
shouldReturnTerminationErrorWhenQueryIsTerminatedBeforePBStarts() {
+    MailboxReceiveNode mailboxReceiveNode = getPBReceiveNode(1);
+    StagePlan stagePlan = new StagePlan(mailboxReceiveNode, _stageMetadata);
+    
when(_mailboxService.getReceivingMailbox(MAILBOX_ID_1)).thenReturn(_mailbox1);
+
+    QueryExecutionContext executionContext =
+        new QueryExecutionContext(QueryExecutionContext.QueryType.MSE, 0, "0", 
Accounting.DEFAULT_WORKLOAD_NAME,
+            System.currentTimeMillis(), Long.MAX_VALUE, Long.MAX_VALUE, 
"brokerId", "serverId", "");
+    executionContext.terminate(QueryErrorCode.SERVER_RESOURCE_LIMIT_EXCEEDED, 
"OOM killed on SERVER");
+    PipelineBreakerResult pipelineBreakerResult;
+    try (QueryThreadContext ignore = QueryThreadContext.open(executionContext,
+        new QueryThreadContext.MseWorkerInfo(1, 2), 
ThreadAccountantUtils.getNoOpAccountant())) {
+      pipelineBreakerResult = 
PipelineBreakerExecutor.executePipelineBreakers(_scheduler, _mailboxService,
+          _workerMetadata, stagePlan, Map.of(), true, true);
+    }
+
+    Assert.assertNotNull(pipelineBreakerResult);
+    ErrorMseBlock errorBlock = pipelineBreakerResult.getErrorBlock();
+    Assert.assertNotNull(errorBlock);
+    Assert.assertEquals(errorBlock.getErrorMessages(),
+        Map.of(QueryErrorCode.SERVER_RESOURCE_LIMIT_EXCEEDED, "OOM killed on 
SERVER"));
+    verify(_mailbox1, never()).poll();
+  }
+
+  /// The scheduler cancels an op chain that fails with an Error too. The 
pipeline breaker keeps that Error as its
+  /// error instead of looking like it collected no rows.
+  @Test
+  public void shouldReturnErrorBlockWhenPBFailsWithError() {
+    MailboxReceiveNode mailboxReceiveNode = getPBReceiveNode(1);
+    StagePlan stagePlan = new StagePlan(mailboxReceiveNode, _stageMetadata);
+    
when(_mailboxService.getReceivingMailbox(MAILBOX_ID_1)).thenReturn(_mailbox1);
+    when(_mailbox1.poll()).thenThrow(new OutOfMemoryError("Java heap space 
(test)"));
+
+    PipelineBreakerResult pipelineBreakerResult =
+        executePipelineBreakers(_scheduler, _mailboxService, _workerMetadata, 
stagePlan, Map.of(), 0, Long.MAX_VALUE);
+
+    Assert.assertNotNull(pipelineBreakerResult);
+    ErrorMseBlock errorBlock = pipelineBreakerResult.getErrorBlock();
+    Assert.assertNotNull(errorBlock);
+    String message = errorBlock.getErrorMessages().get(QueryErrorCode.UNKNOWN);
+    Assert.assertNotNull(message, "Unexpected error: " + 
errorBlock.getErrorMessages());
+    Assert.assertTrue(message.contains("Java heap space (test)"), message);
+  }
+
   @Test
   public void shouldReturnWhenAnyPBReturnsEmpty() {
     MailboxReceiveNode mailboxReceiveNode1 = getPBReceiveNode(1);
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/GatedExecutorServicePlugin.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/GatedExecutorServicePlugin.java
new file mode 100644
index 00000000000..efd33023dd3
--- /dev/null
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/GatedExecutorServicePlugin.java
@@ -0,0 +1,142 @@
+/**
+ * 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.runtime.queries;
+
+import com.google.auto.service.AutoService;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.AbstractExecutorService;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.pinot.spi.executor.ExecutorServicePlugin;
+import org.apache.pinot.spi.executor.ExecutorServiceProvider;
+
+
+/// [ExecutorServicePlugin] for tests that need to act after an op chain is 
scheduled but before it starts.
+///
+/// While the gate is closed, the tasks that the threads of these executors 
submit are parked instead of run, until
+/// [#openGate()]. In a query runner, these threads run 
`QueryRunner#processQueryBlocking`, so the parked tasks are the
+/// op chains that it schedules. The stage submissions come from other threads 
and run normally.
+///
+/// The gate is static, so it is shared by all the executors of this type in 
the JVM. Tests that close it must open it
+/// again when they finish.
+@AutoService(ExecutorServicePlugin.class)
+public class GatedExecutorServicePlugin implements ExecutorServicePlugin {
+  public static final String ID = "gatedForTest";
+
+  private static final Set<Thread> EXECUTOR_THREADS = 
ConcurrentHashMap.newKeySet();
+  private static final AtomicInteger THREAD_COUNT = new AtomicInteger();
+  private static final List<ParkedTask> PARKED_TASKS = new ArrayList<>();
+  private static boolean _gateClosed;
+
+  @Override
+  public String id() {
+    return ID;
+  }
+
+  @Override
+  public ExecutorServiceProvider provider() {
+    return (conf, confPrefix, baseName) -> new 
GatedExecutorService(Executors.newCachedThreadPool(runnable -> {
+      Thread thread = new Thread(runnable, baseName + "-gated-" + 
THREAD_COUNT.incrementAndGet());
+      thread.setDaemon(true);
+      EXECUTOR_THREADS.add(thread);
+      return thread;
+    }));
+  }
+
+  public static synchronized void closeGate() {
+    _gateClosed = true;
+  }
+
+  /// Opens the gate and starts the parked tasks.
+  public static void openGate() {
+    List<ParkedTask> parkedTasks;
+    synchronized (GatedExecutorServicePlugin.class) {
+      _gateClosed = false;
+      parkedTasks = new ArrayList<>(PARKED_TASKS);
+      PARKED_TASKS.clear();
+    }
+    for (ParkedTask parkedTask : parkedTasks) {
+      parkedTask._executorService.execute(parkedTask._task);
+    }
+  }
+
+  public static synchronized int getNumParkedTasks() {
+    return PARKED_TASKS.size();
+  }
+
+  private static class ParkedTask {
+    final ExecutorService _executorService;
+    final Runnable _task;
+
+    ParkedTask(ExecutorService executorService, Runnable task) {
+      _executorService = executorService;
+      _task = task;
+    }
+  }
+
+  private static class GatedExecutorService extends AbstractExecutorService {
+    private final ExecutorService _delegate;
+
+    GatedExecutorService(ExecutorService delegate) {
+      _delegate = delegate;
+    }
+
+    @Override
+    public void execute(Runnable task) {
+      synchronized (GatedExecutorServicePlugin.class) {
+        if (_gateClosed && EXECUTOR_THREADS.contains(Thread.currentThread())) {
+          PARKED_TASKS.add(new ParkedTask(_delegate, task));
+          return;
+        }
+      }
+      _delegate.execute(task);
+    }
+
+    @Override
+    public void shutdown() {
+      _delegate.shutdown();
+    }
+
+    @Override
+    public List<Runnable> shutdownNow() {
+      return _delegate.shutdownNow();
+    }
+
+    @Override
+    public boolean isShutdown() {
+      return _delegate.isShutdown();
+    }
+
+    @Override
+    public boolean isTerminated() {
+      return _delegate.isTerminated();
+    }
+
+    @Override
+    public boolean awaitTermination(long timeout, TimeUnit unit)
+        throws InterruptedException {
+      return _delegate.awaitTermination(timeout, unit);
+    }
+  }
+}
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/QueryRunnerTestBase.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/QueryRunnerTestBase.java
index f9a9983f761..ea375c4069d 100644
--- 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/QueryRunnerTestBase.java
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/QueryRunnerTestBase.java
@@ -202,12 +202,20 @@ public abstract class QueryRunnerTestBase extends 
QueryTestSet {
           new StageMetadata(stageId, workerMetadataList, 
dispatchableStagePlan.getCustomProperties());
       StagePlan stagePlan = new 
StagePlan(dispatchableStagePlan.getPlanFragment().getFragmentRoot(), 
stageMetadata);
       for (WorkerMetadata workerMetadata : workerMetadataList) {
-        submissionStubs.add(serverEnclosure.processQuery(workerMetadata, 
stagePlan, requestMetadataMap));
+        submissionStubs.add(serverEnclosure.processQuery(workerMetadata, 
stagePlan, requestMetadataMap,
+            getExecutionContext(serverEnclosure, requestMetadataMap)));
       }
     }
     return submissionStubs;
   }
 
+  /// Returns the execution context in which the given server runs a stage 
worker. By default, each worker gets its own
+  /// context, while the query server shares one context between all the 
stages of a query.
+  protected QueryExecutionContext getExecutionContext(QueryServerEnclosure 
server,
+      Map<String, String> requestMetadataMap) {
+    return QueryExecutionContext.forMseServerRequest(requestMetadataMap, 
"serverId");
+  }
+
   protected List<Object[]> queryH2(String sql)
       throws Exception {
     int firstSemi = sql.indexOf(';');
diff --git 
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/QueryTerminatedBeforeStageStartsTest.java
 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/QueryTerminatedBeforeStageStartsTest.java
new file mode 100644
index 00000000000..0655d4df5e1
--- /dev/null
+++ 
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/QueryTerminatedBeforeStageStartsTest.java
@@ -0,0 +1,190 @@
+/**
+ * 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.runtime.queries;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import org.apache.pinot.common.response.broker.QueryProcessingException;
+import org.apache.pinot.query.QueryEnvironmentTestBase;
+import org.apache.pinot.query.QueryServerEnclosure;
+import org.apache.pinot.query.mailbox.MailboxService;
+import org.apache.pinot.query.planner.physical.DispatchablePlanFragment;
+import org.apache.pinot.query.routing.QueryServerInstance;
+import org.apache.pinot.query.service.dispatch.QueryDispatcher;
+import org.apache.pinot.query.testutils.MockInstanceDataManagerFactory;
+import org.apache.pinot.query.testutils.QueryTestUtils;
+import org.apache.pinot.spi.config.instance.InstanceType;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.exception.QueryErrorCode;
+import org.apache.pinot.spi.query.QueryExecutionContext;
+import org.apache.pinot.spi.utils.CommonConstants.MultiStageQueryRunner;
+import org.apache.pinot.spi.utils.CommonConstants.Server;
+import org.apache.pinot.util.TestUtils;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+
+
+/// A server can terminate a query before some of its stages run, for example 
when the resource accountant kills the
+/// query to protect the heap. These stages must still send the termination 
error to their receivers. Otherwise the
+/// broker gets no error and waits for data until the query times out.
+///
+/// Like the query server, each server runs all the stages of a query in one 
[QueryExecutionContext], and the test
+/// terminates the context of one server.
+public class QueryTerminatedBeforeStageStartsTest extends QueryRunnerTestBase {
+  private static final long QUERY_TIMEOUT_MS = 30_000L;
+  private static final String QUERY_OPTIONS = "SET timeoutMs = " + 
QUERY_TIMEOUT_MS + "; ";
+  private static final String GROUP_BY_QUERY = QUERY_OPTIONS + "SELECT col1, 
COUNT(*) FROM a GROUP BY col1";
+  /// The leaf stage of `a` runs a pipeline breaker, which collects the keys 
from `b` before the stage starts.
+  private static final String DYNAMIC_BROADCAST_QUERY = QUERY_OPTIONS
+      + "SELECT /*+ joinOptions(join_strategy='dynamic_broadcast') */ col1 
FROM a WHERE a.col1 IN "
+      + "(SELECT b.col2 FROM b WHERE b.col3 < 10) AND a.col3 > 0";
+  private static final String KILL_MESSAGE = "OOM killed on SERVER: test";
+  /// The broker must report the kill long before the query times out.
+  private static final long MAX_REPORT_TIME_MS = 10_000L;
+
+  private QueryServerEnclosure _killedServer;
+  private final Map<QueryServerEnclosure, QueryExecutionContext> 
_executionContexts = new ConcurrentHashMap<>();
+  private volatile boolean _killBeforeScheduling;
+
+  @BeforeClass
+  public void setUp()
+      throws Exception {
+    MockInstanceDataManagerFactory factory1 = new 
MockInstanceDataManagerFactory("server1");
+    
factory1.registerTable(QueryRunnerTest.SCHEMA_BUILDER.setSchemaName("a").build(),
 "a_REALTIME");
+    
factory1.registerTable(QueryRunnerTest.SCHEMA_BUILDER.setSchemaName("b").build(),
 "b_REALTIME");
+    factory1.addSegment("a_REALTIME", QueryRunnerTest.buildRows("a_REALTIME"));
+    factory1.addSegment("b_REALTIME", QueryRunnerTest.buildRows("b_REALTIME"));
+    MockInstanceDataManagerFactory factory2 = new 
MockInstanceDataManagerFactory("server2");
+    
factory2.registerTable(QueryRunnerTest.SCHEMA_BUILDER.setSchemaName("a").build(),
 "a_REALTIME");
+    factory2.addSegment("a_REALTIME", QueryRunnerTest.buildRows("a_REALTIME"));
+
+    _reducerHostname = "localhost";
+    _reducerPort = QueryTestUtils.getAvailablePort();
+    Map<String, Object> reducerConfig = new HashMap<>();
+    reducerConfig.put(MultiStageQueryRunner.KEY_OF_QUERY_RUNNER_HOSTNAME, 
_reducerHostname);
+    reducerConfig.put(MultiStageQueryRunner.KEY_OF_QUERY_RUNNER_PORT, 
_reducerPort);
+    _mailboxService =
+        new MailboxService(_reducerHostname, _reducerPort, 
InstanceType.BROKER, new PinotConfiguration(reducerConfig));
+    _mailboxService.start();
+
+    // The gated executor lets a test act after the op chains are scheduled 
but before they start.
+    Map<String, Object> serverConfig =
+        Map.of(Server.MULTISTAGE_EXECUTOR_CONFIG_PREFIX + ".type", 
GatedExecutorServicePlugin.ID);
+    QueryServerEnclosure server1 = new QueryServerEnclosure(factory1, 
serverConfig);
+    server1.start();
+    QueryServerEnclosure server2 = new QueryServerEnclosure(factory2, 
serverConfig);
+    server2.start();
+    int port1 = server1.getPort();
+    int port2 = server2.getPort();
+    _servers.put(new QueryServerInstance("Server_localhost_" + port1, 
"localhost", port1, port1), server1);
+    _servers.put(new QueryServerInstance("Server_localhost_" + port2, 
"localhost", port2, port2), server2);
+    _killedServer = server1;
+
+    _queryEnvironment = 
QueryEnvironmentTestBase.getQueryEnvironment(_reducerPort, port1, port2,
+        factory1.getRegisteredSchemaMap(), 
factory1.buildTableSegmentNameMap(), factory2.buildTableSegmentNameMap(),
+        null);
+  }
+
+  @AfterClass
+  public void tearDown() {
+    for (QueryServerEnclosure server : _servers.values()) {
+      server.shutDown();
+    }
+    _mailboxService.shutdown();
+  }
+
+  @BeforeMethod
+  public void resetQueryState() {
+    _executionContexts.clear();
+    _killBeforeScheduling = false;
+  }
+
+  @AfterMethod
+  public void openGate() {
+    GatedExecutorServicePlugin.openGate();
+  }
+
+  @DataProvider(name = "queries")
+  public Object[][] queries() {
+    return new Object[][]{{GROUP_BY_QUERY}, {DYNAMIC_BROADCAST_QUERY}};
+  }
+
+  /// The server kills the query after the broker dispatched it, but before 
the server scheduled any of its stages.
+  @Test(dataProvider = "queries")
+  public void testKillBeforeStagesAreScheduled(String query) {
+    _killBeforeScheduling = true;
+    long startTimeMs = System.currentTimeMillis();
+    QueryDispatcher.QueryResult result = queryRunner(query, false);
+    assertKillReported(result, startTimeMs);
+  }
+
+  /// The server kills the query after it scheduled the op chains of its 
stages, but before they started.
+  @Test
+  public void testKillAfterStagesAreScheduledBeforeTheyStart()
+      throws Exception {
+    int numWorkers = 0;
+    for (DispatchablePlanFragment stagePlan : 
planQuery(GROUP_BY_QUERY).getQueryPlan().getQueryStagesWithoutRoot()) {
+      numWorkers += stagePlan.getWorkerMetadataList().size();
+    }
+    int numOpChains = numWorkers;
+    GatedExecutorServicePlugin.closeGate();
+    CompletableFuture<QueryDispatcher.QueryResult> resultFuture =
+        CompletableFuture.supplyAsync(() -> queryRunner(GROUP_BY_QUERY, 
false));
+    TestUtils.waitForCondition(aVoid -> 
GatedExecutorServicePlugin.getNumParkedTasks() == numOpChains, 10_000L,
+        "Op chains were not scheduled");
+    long killTimeMs = System.currentTimeMillis();
+    
_executionContexts.get(_killedServer).terminate(QueryErrorCode.SERVER_RESOURCE_LIMIT_EXCEEDED,
 KILL_MESSAGE);
+    GatedExecutorServicePlugin.openGate();
+    assertKillReported(resultFuture.get(2 * QUERY_TIMEOUT_MS, 
TimeUnit.MILLISECONDS), killTimeMs);
+  }
+
+  private static void assertKillReported(QueryDispatcher.QueryResult result, 
long fromTimeMs) {
+    long elapsedMs = System.currentTimeMillis() - fromTimeMs;
+    QueryProcessingException processingException = 
result.getProcessingException();
+    assertNotNull(processingException, "Expected the query to fail");
+    assertEquals(processingException.getErrorCode(), 
QueryErrorCode.SERVER_RESOURCE_LIMIT_EXCEEDED.getId(),
+        "Unexpected error after " + elapsedMs + "ms: " + 
processingException.getMessage());
+    assertTrue(processingException.getMessage().contains(KILL_MESSAGE), 
processingException.getMessage());
+    assertTrue(elapsedMs < MAX_REPORT_TIME_MS, "The kill was reported after " 
+ elapsedMs + "ms");
+  }
+
+  /// Like the query server, each server runs all the stages of the query in 
one execution context.
+  @Override
+  protected QueryExecutionContext getExecutionContext(QueryServerEnclosure 
server,
+      Map<String, String> requestMetadataMap) {
+    return _executionContexts.computeIfAbsent(server, key -> {
+      QueryExecutionContext context = 
QueryExecutionContext.forMseServerRequest(requestMetadataMap, "serverId");
+      if (_killBeforeScheduling && key == _killedServer) {
+        context.terminate(QueryErrorCode.SERVER_RESOURCE_LIMIT_EXCEEDED, 
KILL_MESSAGE);
+      }
+      return context;
+    });
+  }
+}
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/query/QueryThreadContext.java 
b/pinot-spi/src/main/java/org/apache/pinot/spi/query/QueryThreadContext.java
index ce07866de43..4b6092acf9c 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/query/QueryThreadContext.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/query/QueryThreadContext.java
@@ -309,16 +309,18 @@ public class QueryThreadContext implements AutoCloseable {
   }
 
   /// Returns a new [ExecutorService] whose tasks will be executed with the 
[QueryThreadContext] initialized with the
-  /// state of the thread submitting the tasks. Tasks are registered for 
cancellation when the query is terminated.
+  /// state of the thread submitting the tasks. The [Future] of each task 
passed to `submit()` is registered for
+  /// cancellation when the query is terminated. Tasks passed to `execute()` 
are not registered.
   public static ExecutorService contextAwareExecutorService(ExecutorService 
executorService) {
     return contextAwareExecutorService(executorService, true);
   }
 
   /// Returns a new [ExecutorService] whose tasks will be executed with the 
[QueryThreadContext] initialized with the
   /// state of the thread submitting the tasks.
-  /// @param registerTaskForCancellation If true, tasks are registered with 
QueryExecutionContext.addTask() and will
-  ///                                    be cancelled (via Thread.interrupt()) 
when the query is terminated. Set to
-  ///                                    false for executors where interrupt 
could cause corruption
+  /// @param registerTaskForCancellation If true, tasks passed to `submit()` 
are registered with
+  ///                                    QueryExecutionContext.addTask() and 
will be cancelled (via Thread.interrupt())
+  ///                                    when the query is terminated. Set to 
false for executors where interrupt could
+  ///                                    cause corruption
   public static ExecutorService contextAwareExecutorService(ExecutorService 
executorService,
       boolean registerTaskForCancellation) {
     Consumer<Future<?>> onSubmit = registerTaskForCancellation


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

Reply via email to