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 d3e53e20210 Implement streaming distinct leaf stage operator for 
multi-stage engine (#19419)
d3e53e20210 is described below

commit d3e53e202107605fb68f6e10aef6f55e25152d29
Author: Yash Mayya <[email protected]>
AuthorDate: Wed Sep 2 16:41:57 2026 -0400

    Implement streaming distinct leaf stage operator for multi-stage engine 
(#19419)
---
 .../MultiStageBrokerRequestHandler.java            |  11 +
 .../MultiStageBrokerRequestHandlerTest.java        |  58 ++
 .../common/utils/config/QueryOptionsUtils.java     |   6 +
 .../common/utils/config/QueryOptionsUtilsTest.java |   7 +-
 .../StreamingDistinctCombineOperator.java          | 193 ++++++
 .../apache/pinot/core/plan/CombinePlanNode.java    |  33 +-
 .../core/plan/maker/InstancePlanMakerImplV2.java   |  10 +
 .../core/query/request/context/QueryContext.java   |  10 +
 .../StreamingDistinctCombineOperatorTest.java      | 649 +++++++++++++++++++++
 .../tests/MultiStageEngineIntegrationTest.java     |  84 +++
 .../apache/pinot/spi/utils/CommonConstants.java    |  32 +
 11 files changed, 1086 insertions(+), 7 deletions(-)

diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
index 07a9497b368..08005e3be51 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
@@ -147,6 +147,8 @@ public class MultiStageBrokerRequestHandler extends 
BaseBrokerRequestHandler {
   private final boolean _streamStatsDefault;
   @Nullable
   protected final String _defaultStreamingGroupByFlushThreshold;
+  @Nullable
+  protected final String _defaultStreamingDistinctFlushThreshold;
 
   protected final PinotMeter _stagesStartedMeter = 
BrokerMeter.MSE_STAGES_STARTED.getGlobalMeter();
   protected final PinotMeter _stagesFinishedMeter = 
BrokerMeter.MSE_STAGES_COMPLETED.getGlobalMeter();
@@ -246,6 +248,11 @@ public class MultiStageBrokerRequestHandler extends 
BaseBrokerRequestHandler {
     // null indicates "feature disabled", which matches the 
broker-config-unset case.
     _defaultStreamingGroupByFlushThreshold =
         streamingGroupByFlushThreshold > 0 ? 
Integer.toString(streamingGroupByFlushThreshold) : null;
+    int streamingDistinctFlushThreshold = _config.getProperty(
+        
CommonConstants.Broker.CONFIG_OF_MSE_STREAMING_DISTINCT_FLUSH_THRESHOLD,
+        CommonConstants.Broker.DEFAULT_MSE_STREAMING_DISTINCT_FLUSH_THRESHOLD);
+    _defaultStreamingDistinctFlushThreshold =
+        streamingDistinctFlushThreshold > 0 ? 
Integer.toString(streamingDistinctFlushThreshold) : null;
   }
 
   @Override
@@ -604,6 +611,10 @@ public class MultiStageBrokerRequestHandler extends 
BaseBrokerRequestHandler {
       
queryOptions.putIfAbsent(CommonConstants.Broker.Request.QueryOptionKey.STREAMING_GROUP_BY_FLUSH_THRESHOLD,
           _defaultStreamingGroupByFlushThreshold);
     }
+    if (_defaultStreamingDistinctFlushThreshold != null) {
+      
queryOptions.putIfAbsent(CommonConstants.Broker.Request.QueryOptionKey.STREAMING_DISTINCT_FLUSH_THRESHOLD,
+          _defaultStreamingDistinctFlushThreshold);
+    }
   }
 
   /// Extension hook for preparing a compiled query's planner-visible options 
after compilation, authorization and
diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandlerTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandlerTest.java
index 7b3e90c8d4a..e04423ccf31 100644
--- 
a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandlerTest.java
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandlerTest.java
@@ -167,9 +167,63 @@ public class MultiStageBrokerRequestHandlerTest extends 
QueryEnvironmentTestBase
         "No option should be injected when the broker default is unset");
   }
 
+  @Test
+  public void 
testApplyBrokerDefaultQueryOptionsInjectsStreamingDistinctFlushThreshold()
+      throws Exception {
+    MultiStageBrokerRequestHandler handler = 
newHandlerWithStreamingDistinctFlushThreshold("5000");
+
+    Map<String, String> queryOptions = new HashMap<>();
+    handler.applyBrokerDefaultQueryOptions(queryOptions);
+    
Assert.assertEquals(queryOptions.get(QueryOptionKey.STREAMING_DISTINCT_FLUSH_THRESHOLD),
 "5000",
+        "Broker default should be injected when query option is absent");
+    
Assert.assertFalse(queryOptions.containsKey(QueryOptionKey.STREAMING_GROUP_BY_FLUSH_THRESHOLD),
+        "The distinct default must not also enable streaming group-by");
+  }
+
+  @Test
+  public void 
testApplyBrokerDefaultQueryOptionsStreamingDistinctPerQueryOverrideWins()
+      throws Exception {
+    MultiStageBrokerRequestHandler handler = 
newHandlerWithStreamingDistinctFlushThreshold("5000");
+
+    Map<String, String> queryOptions = new HashMap<>();
+    queryOptions.put(QueryOptionKey.STREAMING_DISTINCT_FLUSH_THRESHOLD, "0");
+    handler.applyBrokerDefaultQueryOptions(queryOptions);
+    
Assert.assertEquals(queryOptions.get(QueryOptionKey.STREAMING_DISTINCT_FLUSH_THRESHOLD),
 "0",
+        "Per-query SET = 0 must override the broker default");
+
+    queryOptions.clear();
+    queryOptions.put(QueryOptionKey.STREAMING_DISTINCT_FLUSH_THRESHOLD, "100");
+    handler.applyBrokerDefaultQueryOptions(queryOptions);
+    
Assert.assertEquals(queryOptions.get(QueryOptionKey.STREAMING_DISTINCT_FLUSH_THRESHOLD),
 "100",
+        "Per-query SET must take precedence over the broker default");
+  }
+
+  @Test
+  public void 
testApplyBrokerDefaultQueryOptionsNoStreamingDistinctInjectionWhenConfigUnset()
+      throws Exception {
+    MultiStageBrokerRequestHandler handler = 
newHandlerWithStreamingDistinctFlushThreshold(null);
+
+    Map<String, String> queryOptions = new HashMap<>();
+    handler.applyBrokerDefaultQueryOptions(queryOptions);
+    
Assert.assertFalse(queryOptions.containsKey(QueryOptionKey.STREAMING_DISTINCT_FLUSH_THRESHOLD),
+        "No option should be injected when the broker default is unset");
+  }
+
   private static MultiStageBrokerRequestHandler 
newHandlerWithStreamingGroupByFlushThreshold(
       @Nullable String streamingGroupByFlushThreshold)
       throws Exception {
+    return newHandlerWithFlushThresholds(streamingGroupByFlushThreshold, null);
+  }
+
+  private static MultiStageBrokerRequestHandler 
newHandlerWithStreamingDistinctFlushThreshold(
+      @Nullable String streamingDistinctFlushThreshold)
+      throws Exception {
+    return newHandlerWithFlushThresholds(null, 
streamingDistinctFlushThreshold);
+  }
+
+  private static MultiStageBrokerRequestHandler newHandlerWithFlushThresholds(
+      @Nullable String streamingGroupByFlushThreshold, @Nullable String 
streamingDistinctFlushThreshold)
+      throws Exception {
     PinotConfiguration config = new PinotConfiguration();
     config.setProperty(MultiStageQueryRunner.KEY_OF_QUERY_RUNNER_HOSTNAME, 
"localhost");
     config.setProperty(MultiStageQueryRunner.KEY_OF_QUERY_RUNNER_PORT, 
Integer.toString(NetUtils.findOpenPort()));
@@ -177,6 +231,10 @@ public class MultiStageBrokerRequestHandlerTest extends 
QueryEnvironmentTestBase
       
config.setProperty(CommonConstants.Broker.CONFIG_OF_MSE_STREAMING_GROUP_BY_FLUSH_THRESHOLD,
           streamingGroupByFlushThreshold);
     }
+    if (streamingDistinctFlushThreshold != null) {
+      
config.setProperty(CommonConstants.Broker.CONFIG_OF_MSE_STREAMING_DISTINCT_FLUSH_THRESHOLD,
+          streamingDistinctFlushThreshold);
+    }
     BrokerQueryEventListenerFactory.init(config);
     BrokerMetrics.register(mock(BrokerMetrics.class));
 
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java
index 4b0ee0463e2..a48c88cd146 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java
@@ -549,6 +549,12 @@ public class QueryOptionsUtils {
     return 
checkedParseIntNonNegative(QueryOptionKey.STREAMING_GROUP_BY_FLUSH_THRESHOLD, 
value);
   }
 
+  @Nullable
+  public static Integer getStreamingDistinctFlushThreshold(Map<String, String> 
queryOptions) {
+    String value = 
queryOptions.get(QueryOptionKey.STREAMING_DISTINCT_FLUSH_THRESHOLD);
+    return 
checkedParseIntNonNegative(QueryOptionKey.STREAMING_DISTINCT_FLUSH_THRESHOLD, 
value);
+  }
+
   public static boolean isNullHandlingEnabled(Map<String, String> 
queryOptions) {
     return 
Boolean.parseBoolean(queryOptions.get(QueryOptionKey.ENABLE_NULL_HANDLING));
   }
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/utils/config/QueryOptionsUtilsTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/utils/config/QueryOptionsUtilsTest.java
index dceac3385eb..0b8155403aa 100644
--- 
a/pinot-common/src/test/java/org/apache/pinot/common/utils/config/QueryOptionsUtilsTest.java
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/utils/config/QueryOptionsUtilsTest.java
@@ -39,7 +39,8 @@ public class QueryOptionsUtilsTest {
   private static final List<String> POSITIVE_INT_KEYS =
       List.of(NUM_REPLICA_GROUPS_TO_QUERY, MAX_EXECUTION_THREADS, 
NUM_GROUPS_LIMIT, MAX_INITIAL_RESULT_HOLDER_CAPACITY,
           MAX_STREAMING_PENDING_BLOCKS, MAX_ROWS_IN_JOIN, MAX_ROWS_IN_WINDOW);
-  private static final List<String> NON_NEGATIVE_INT_KEYS = 
List.of(MULTI_STAGE_LEAF_LIMIT);
+  private static final List<String> NON_NEGATIVE_INT_KEYS =
+      List.of(MULTI_STAGE_LEAF_LIMIT, STREAMING_GROUP_BY_FLUSH_THRESHOLD, 
STREAMING_DISTINCT_FLUSH_THRESHOLD);
   private static final List<String> UNBOUNDED_INT_KEYS =
       List.of(MIN_SEGMENT_GROUP_TRIM_SIZE, MIN_SERVER_GROUP_TRIM_SIZE, 
MIN_BROKER_GROUP_TRIM_SIZE,
           GROUP_TRIM_THRESHOLD);
@@ -336,6 +337,10 @@ public class QueryOptionsUtilsTest {
       // Non-negative ints
       case MULTI_STAGE_LEAF_LIMIT:
         return QueryOptionsUtils.getMultiStageLeafLimit(map);
+      case STREAMING_GROUP_BY_FLUSH_THRESHOLD:
+        return QueryOptionsUtils.getStreamingGroupByFlushThreshold(map);
+      case STREAMING_DISTINCT_FLUSH_THRESHOLD:
+        return QueryOptionsUtils.getStreamingDistinctFlushThreshold(map);
       // Unbounded ints
       case MIN_SEGMENT_GROUP_TRIM_SIZE:
         return QueryOptionsUtils.getMinSegmentGroupTrimSize(map);
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/streaming/StreamingDistinctCombineOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/streaming/StreamingDistinctCombineOperator.java
new file mode 100644
index 00000000000..0ed892c8101
--- /dev/null
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/streaming/StreamingDistinctCombineOperator.java
@@ -0,0 +1,193 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.core.operator.streaming;
+
+import com.google.common.base.Preconditions;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.apache.pinot.core.common.Operator;
+import org.apache.pinot.core.operator.blocks.results.BaseResultsBlock;
+import 
org.apache.pinot.core.operator.blocks.results.BaseResultsBlock.EarlyTerminationReason;
+import org.apache.pinot.core.operator.blocks.results.DistinctResultsBlock;
+import org.apache.pinot.core.operator.blocks.results.ExceptionResultsBlock;
+import org.apache.pinot.core.operator.blocks.results.MetadataResultsBlock;
+import 
org.apache.pinot.core.operator.combine.merger.DistinctResultsBlockMerger;
+import org.apache.pinot.core.query.request.context.QueryContext;
+import org.apache.pinot.spi.exception.QueryErrorCode;
+import org.apache.pinot.spi.query.QueryThreadContext;
+
+
+/// Streaming combine operator for distinct queries. Instead of accumulating 
every distinct value into a single
+/// [org.apache.pinot.core.query.distinct.table.DistinctTable] before 
returning (like
+/// [org.apache.pinot.core.operator.combine.DistinctCombineOperator]), this 
operator flushes the accumulated values
+/// once the table reaches a configurable threshold.
+///
+/// This bounds server memory usage for high-cardinality distinct queries on 
MSE leaf stages, while still performing
+/// partial de-duplication to reduce data volume compared to skipping 
leaf-stage de-duplication entirely (the
+/// `is_skip_leaf_stage_group_by` hint).
+///
+/// The downstream FINAL stage de-duplicates partial results from multiple 
flushes correctly because:
+///
+/// - Hash exchange routes the same distinct key to the same FINAL worker (the 
exchange is keyed on the group keys,
+///   which for a zero-aggregate-call aggregate are exactly the distinct 
columns)
+/// - Set union is associative, commutative and idempotent, so a value emitted 
in more than one flush window is a
+///   no-op downstream
+///
+/// That downstream stage is a precondition, not an implementation detail: a 
flushed block carries only part of the
+/// distinct set, and one value can span several flush windows. Leaves that 
must return final results are rejected in
+/// the constructor as a backstop -- the real gate is in 
[org.apache.pinot.core.plan.CombinePlanNode], but this
+/// operator is public and nothing else would catch a direct construction, 
which would silently emit duplicate rows.
+///
+/// Unlike [StreamingGroupByCombineOperator], no `detachFromWorkerThreadState` 
hook is needed: a per-segment
+/// `DistinctTable` is allocated per 
[org.apache.pinot.core.operator.query.DistinctOperator] invocation and the
+/// dictionary-based executors materialize actual values in `getResult()`, so 
a block handed to the consumer thread
+/// is self-contained and references no reused worker-thread-local state.
+///
+/// Two limits to be aware of when enabling this:
+///
+/// - It bounds only the server-level accumulated table. The per-segment 
`DistinctTable`s that feed it are still
+///   built in full (unlike group-by, whose per-segment maps are capped by 
`numGroupsLimit`), so peak leaf heap is
+///   roughly `flushThreshold` plus one table per in-flight worker thread. In 
particular, when a single segment
+///   already holds more than `flushThreshold` distinct values, its block is 
adopted and flushed with nothing merged
+///   into it, so the leaf performs no cross-segment de-duplication at all and 
only amplifies rows. Deliberately not
+///   "fixed" by requiring a merge before each flush: that would let the 
accumulator grow to two segments' worth and
+///   raise the very ceiling this operator exists to lower. Bounding the 
per-segment tables is the real fix.
+/// - The distinct guardrails weaken. All three (`maxRowsInDistinct`, 
`maxRowsWithoutChangeInDistinct`,
+///   `maxExecutionTimeMsInDistinct`) are evaluated inside 
[DistinctResultsBlockMerger#mergeResultsBlocks], which
+///   only runs for blocks merged into an existing accumulator -- never for 
the first block of a flush window, which
+///   is adopted. So in the no-merge regime above, none of them can fire at 
all. When merging does happen,
+///   `maxRowsInDistinct` and `maxExecutionTimeMsInDistinct` stay per-query 
bounds because [#mergeBlock] deliberately
+///   carries the scanned-doc count across windows, but 
`maxRowsWithoutChangeInDistinct` still degrades: its counter
+///   only advances when a merge leaves the table's size unchanged, and every 
window restarts from an empty table.
+@SuppressWarnings("rawtypes")
+public class StreamingDistinctCombineOperator extends 
BaseStreamingCombineOperator<DistinctResultsBlock> {
+  private static final String EXPLAIN_NAME = "STREAMING_COMBINE_DISTINCT";
+
+  private final int _flushThreshold;
+
+  // Main-thread-only state for accumulating distinct results
+  private DistinctResultsBlock _mergedBlock;
+  private long _numDocsScanned;
+  private EarlyTerminationReason _earlyTerminationReason = 
EarlyTerminationReason.NONE;
+
+  public StreamingDistinctCombineOperator(List<Operator> operators, 
QueryContext queryContext,
+      ExecutorService executorService, int flushThreshold) {
+    super(new DistinctResultsBlockMerger(queryContext), operators, 
queryContext, executorService);
+    Preconditions.checkState(
+        !queryContext.isServerReturnFinalResult() && 
!queryContext.isServerReturnFinalResultKeyUnpartitioned(),
+        "Streaming distinct combine requires a leaf whose results are 
de-duplicated by a later stage");
+    _flushThreshold = flushThreshold;
+  }
+
+  @Override
+  public String toExplainString() {
+    return EXPLAIN_NAME;
+  }
+
+  /// Disables the worker-side early-termination check, matching 
[StreamingGroupByCombineOperator].
+  ///
+  /// [BaseStreamingCombineOperator#processSegments] calls this on the 
PRODUCING thread with a block it has already
+  /// published to the consumer. This operator adopts that same block as its 
accumulator and mutates it (merging
+  /// other tables into its `DistinctTable`, and writing `numDocsScanned` / 
the early-termination reason), none of
+  /// which is synchronized -- so letting the worker read `isSatisfied()` or 
the termination reason off it afterwards
+  /// is a data race on a plain `HashSet` mid-`add`/`rehash`. The sibling 
streaming operators are each safe for one
+  /// of two reasons: the group-by one never lets a worker read a published 
block, and the selection-only one never
+  /// mutates one. This operator would otherwise be the only one doing both.
+  ///
+  /// Nothing is lost: the consumer still evaluates satisfaction itself in 
[#mergeBlock] via the results-block
+  /// merger, and the cross-segment early exit is already given up in this 
mode by construction (flushing empties the
+  /// accumulator long before it can reach LIMIT -- see the gate in
+  /// [org.apache.pinot.core.plan.CombinePlanNode]). Returning `false` also 
removes the hazard of a worker returning
+  /// early without emitting its `LAST_RESULTS_BLOCK`.
+  @Override
+  protected boolean isQuerySatisfied(DistinctResultsBlock resultsBlock, Object 
tracker) {
+    return false;
+  }
+
+  /// Polls per-segment result blocks from worker threads, merges them into 
the accumulated distinct table, and
+  /// flushes when the table reaches the flush threshold. Returns one block 
per call:
+  /// - A DistinctResultsBlock when flushing accumulated values
+  /// - A MetadataResultsBlock when all operators are done and remaining data 
has been flushed
+  /// - An ExceptionResultsBlock on error or timeout
+  @Override
+  protected BaseResultsBlock getNextBlock() {
+    long endTimeMs = _queryContext.getEndTimeMs();
+    try {
+      while (!_querySatisfied && _numOperatorsFinished < _numOperators) {
+        QueryThreadContext.checkTermination(this::getExplainName);
+        BaseResultsBlock resultsBlock =
+            _blockingQueue.poll(endTimeMs - System.currentTimeMillis(), 
TimeUnit.MILLISECONDS);
+        if (resultsBlock == null) {
+          throw QueryErrorCode.EXECUTION_TIMEOUT.asException("Timed out while 
streaming distinct results");
+        }
+        if (resultsBlock instanceof ExceptionResultsBlock) {
+          return checkTerminateExceptionAndAttachExecutionStats(resultsBlock);
+        }
+        if (resultsBlock == LAST_RESULTS_BLOCK) {
+          _numOperatorsFinished++;
+          continue;
+        }
+        mergeBlock((DistinctResultsBlock) resultsBlock);
+        if (_mergedBlock.getDistinctTable().size() >= _flushThreshold) {
+          return flush();
+        }
+      }
+    } catch (Exception e) {
+      return createExceptionResultsBlockAndAttachExecutionStats(e, "streaming 
distinct results");
+    }
+    // All operators done (or the query is satisfied) — flush any remaining 
accumulated data
+    if (_mergedBlock != null && _mergedBlock.getDistinctTable().size() > 0) {
+      return flush();
+    }
+    // Return final metadata block. The early-termination reason is carried 
over from the accumulated block: the
+    // blocking path reports it on the single results block, but here that 
block has already been streamed out, so
+    // without this the reason (and hence BrokerResponse.isPartialResult) 
would be silently dropped.
+    MetadataResultsBlock metadataBlock = new MetadataResultsBlock();
+    metadataBlock.setEarlyTerminationReason(_earlyTerminationReason);
+    return attachExecutionStats(metadataBlock);
+  }
+
+  /// Merges a per-segment block into the accumulated result. The first block 
of each flush window is adopted as the
+  /// accumulator (mirroring 
[org.apache.pinot.core.operator.combine.BaseSingleBlockCombineOperator#mergeResults]),
+  /// which avoids having to construct a `DistinctTable` of the right subtype 
from a `DataSchema`.
+  private void mergeBlock(DistinctResultsBlock blockToMerge) {
+    QueryThreadContext.checkTerminationAndSampleUsage(EXPLAIN_NAME);
+    if (_mergedBlock == null) {
+      _mergedBlock = blockToMerge;
+    } else {
+      _resultsBlockMerger.mergeResultsBlocks(_mergedBlock, blockToMerge);
+    }
+    // Carry the running doc count across flush windows so that 
maxRowsInDistinct stays a per-query bound instead of
+    // silently becoming a per-flush-window one. NOTE: This count is not 
serialized with a data block (the block's
+    // DataTable carries only rows), so overwriting it here cannot double 
count in the response metadata.
+    _numDocsScanned += blockToMerge.getNumDocsScanned();
+    _mergedBlock.setNumDocsScanned(_numDocsScanned);
+    if (_mergedBlock.getEarlyTerminationReason() != 
EarlyTerminationReason.NONE) {
+      _earlyTerminationReason = _mergedBlock.getEarlyTerminationReason();
+    }
+    _querySatisfied = _resultsBlockMerger.isQuerySatisfied(_mergedBlock);
+  }
+
+  private DistinctResultsBlock flush() {
+    DistinctResultsBlock block = _mergedBlock;
+    _mergedBlock = null;
+    return block;
+  }
+}
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/plan/CombinePlanNode.java 
b/pinot-core/src/main/java/org/apache/pinot/core/plan/CombinePlanNode.java
index dd3a720412b..b753fa74d26 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/plan/CombinePlanNode.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/plan/CombinePlanNode.java
@@ -34,6 +34,7 @@ import 
org.apache.pinot.core.operator.combine.SelectionOnlyCombineOperator;
 import org.apache.pinot.core.operator.combine.SelectionOrderByCombineOperator;
 import 
org.apache.pinot.core.operator.combine.SequentialSortedGroupByCombineOperator;
 import org.apache.pinot.core.operator.combine.SortedGroupByCombineOperator;
+import 
org.apache.pinot.core.operator.streaming.StreamingDistinctCombineOperator;
 import 
org.apache.pinot.core.operator.streaming.StreamingGroupByCombineOperator;
 import 
org.apache.pinot.core.operator.streaming.StreamingSelectionOnlyCombineOperator;
 import org.apache.pinot.core.query.executor.ResultsBlockStreamer;
@@ -130,15 +131,35 @@ public class CombinePlanNode implements PlanNode {
         // Use streaming operator only for non-empty selection-only query
         return new StreamingSelectionOnlyCombineOperator(operators, 
_queryContext, _executorService);
       }
-      // Streaming flushes partial aggregates, so it needs an aggregation 
above to merge them back together.
+      // Streaming flushes partial results, so it needs an aggregation above 
to merge them back together.
       // Leaves that must return final results are excluded, see 
StreamingGroupByCombineOperator.
-      int flushThreshold = _queryContext.getStreamingGroupByFlushThreshold();
-      if (flushThreshold > 0
+      boolean leafReturnsFinalResult =
+          _queryContext.isServerReturnFinalResult() || 
_queryContext.isServerReturnFinalResultKeyUnpartitioned();
+      int groupByFlushThreshold = 
_queryContext.getStreamingGroupByFlushThreshold();
+      if (groupByFlushThreshold > 0
           && QueryContextUtils.isAggregationQuery(_queryContext)
           && _queryContext.getGroupByExpressions() != null
-          && !_queryContext.isServerReturnFinalResult()
-          && !_queryContext.isServerReturnFinalResultKeyUnpartitioned()) {
-        return new StreamingGroupByCombineOperator(operators, _queryContext, 
_executorService, flushThreshold);
+          && !leafReturnsFinalResult) {
+        return new StreamingGroupByCombineOperator(operators, _queryContext, 
_executorService, groupByFlushThreshold);
+      }
+      int distinctFlushThreshold = 
_queryContext.getStreamingDistinctFlushThreshold();
+      if (distinctFlushThreshold > 0 && 
QueryContextUtils.isDistinctQuery(_queryContext)
+          // With ORDER BY, the DistinctTable keeps a bounded top-LIMIT heap 
instead of accumulating every value, so
+          // it is already memory-bounded and streaming would only ship more 
rows.
+          && _queryContext.getOrderByExpressions() == null
+          // NOTE: The planner pushes LIMIT (plus OFFSET) into the leaf 
aggregate for any distinct query that has
+          // one -- see 
PinotAggregateExchangeNodeInsertRule#isGroupTrimmingEnabled, which is 
unconditionally on for
+          // aggregates with no aggregate calls. So the leaf table is normally 
bounded at LIMIT already, and this
+          // feature is for the case where that LIMIT is far larger than the 
memory we want to spend.
+          //
+          // This branch DOES give up the cross-segment early exit: 
DistinctResultsBlockMerger#isQuerySatisfied can
+          // only fire once the accumulated table reaches LIMIT, and flushing 
empties it well before that, so every
+          // segment gets scanned. That is the deliberate trade -- a lower 
memory ceiling for more scan work. When
+          // LIMIT is at or below the threshold there is no memory to save, so 
the short-circuit wins instead.
+          && _queryContext.getLimit() > distinctFlushThreshold
+          && !leafReturnsFinalResult) {
+        return new StreamingDistinctCombineOperator(operators, _queryContext, 
_executorService,
+            distinctFlushThreshold);
       }
     }
     if (QueryContextUtils.isAggregationQuery(_queryContext)) {
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/plan/maker/InstancePlanMakerImplV2.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/plan/maker/InstancePlanMakerImplV2.java
index dc8021857c1..0668a8aca0f 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/plan/maker/InstancePlanMakerImplV2.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/plan/maker/InstancePlanMakerImplV2.java
@@ -338,6 +338,16 @@ public class InstancePlanMakerImplV2 implements PlanMaker {
         
queryContext.setStreamingGroupByFlushThreshold(streamingGroupByFlushThreshold);
       }
     }
+
+    // Set distinct query options. NOTE: This is intentionally outside the 
group-by block above, because DISTINCT is a
+    // separate query class from aggregation (see 
QueryContextUtils.isDistinctQuery).
+    if (QueryContextUtils.isDistinctQuery(queryContext)) {
+      // Set streamingDistinctFlushThreshold
+      Integer streamingDistinctFlushThreshold = 
QueryOptionsUtils.getStreamingDistinctFlushThreshold(queryOptions);
+      if (streamingDistinctFlushThreshold != null) {
+        
queryContext.setStreamingDistinctFlushThreshold(streamingDistinctFlushThreshold);
+      }
+    }
   }
 
   @Override
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
index 62a7a0e54c1..d97c340bc0d 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
@@ -133,6 +133,8 @@ public class QueryContext {
   private int _effectiveSegmentGroupTrimSize;
   // Flush threshold for streaming group-by (0 = disabled)
   private int _streamingGroupByFlushThreshold;
+  // Flush threshold for streaming distinct (0 = disabled)
+  private int _streamingDistinctFlushThreshold;
   // Whether null handling is enabled
   private boolean _nullHandlingEnabled;
   // Whether server returns the final result
@@ -498,6 +500,14 @@ public class QueryContext {
     _streamingGroupByFlushThreshold = streamingGroupByFlushThreshold;
   }
 
+  public int getStreamingDistinctFlushThreshold() {
+    return _streamingDistinctFlushThreshold;
+  }
+
+  public void setStreamingDistinctFlushThreshold(int 
streamingDistinctFlushThreshold) {
+    _streamingDistinctFlushThreshold = streamingDistinctFlushThreshold;
+  }
+
   public boolean isNullHandlingEnabled() {
     return _nullHandlingEnabled;
   }
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/streaming/StreamingDistinctCombineOperatorTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/streaming/StreamingDistinctCombineOperatorTest.java
new file mode 100644
index 00000000000..fee7ee70e16
--- /dev/null
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/streaming/StreamingDistinctCombineOperatorTest.java
@@ -0,0 +1,649 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.core.operator.streaming;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.apache.commons.io.FileUtils;
+import org.apache.pinot.core.common.Operator;
+import org.apache.pinot.core.operator.blocks.results.BaseResultsBlock;
+import 
org.apache.pinot.core.operator.blocks.results.BaseResultsBlock.EarlyTerminationReason;
+import org.apache.pinot.core.operator.blocks.results.DistinctResultsBlock;
+import org.apache.pinot.core.operator.blocks.results.MetadataResultsBlock;
+import org.apache.pinot.core.operator.combine.BaseCombineOperator;
+import org.apache.pinot.core.operator.combine.DistinctCombineOperator;
+import org.apache.pinot.core.plan.CombinePlanNode;
+import org.apache.pinot.core.plan.PlanNode;
+import org.apache.pinot.core.plan.maker.InstancePlanMakerImplV2;
+import org.apache.pinot.core.plan.maker.PlanMaker;
+import org.apache.pinot.core.query.executor.ResultsBlockStreamer;
+import org.apache.pinot.core.query.request.context.QueryContext;
+import 
org.apache.pinot.core.query.request.context.utils.QueryContextConverterUtils;
+import 
org.apache.pinot.segment.local.indexsegment.immutable.ImmutableSegmentLoader;
+import 
org.apache.pinot.segment.local.segment.creator.impl.SegmentIndexCreationDriverImpl;
+import org.apache.pinot.segment.local.segment.readers.GenericRowRecordReader;
+import org.apache.pinot.segment.spi.IndexSegment;
+import org.apache.pinot.segment.spi.SegmentContext;
+import org.apache.pinot.segment.spi.creator.SegmentGeneratorConfig;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.data.readers.GenericRow;
+import org.apache.pinot.spi.utils.CommonConstants.Server;
+import org.apache.pinot.spi.utils.ReadMode;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.*;
+
+
+/// Test for [StreamingDistinctCombineOperator].
+public class StreamingDistinctCombineOperatorTest {
+  private static final File TEMP_DIR = new File(FileUtils.getTempDirectory(), 
"StreamingDistinctCombineOperatorTest");
+  private static final String RAW_TABLE_NAME = "testTable";
+  private static final String SEGMENT_NAME_PREFIX = "testSegment_";
+
+  private static final int NUM_SEGMENTS = 4;
+  // Every segment holds the same 50 distinct values, each appearing twice. 
Identical content across segments is
+  // deliberate: it guarantees that a value flushed in one window is 
re-emitted in a later one, which is exactly the
+  // idempotence property the downstream FINAL stage relies on.
+  private static final int NUM_DISTINCT_VALUES = 50;
+  private static final int NUM_DOCS_PER_SEGMENT = NUM_DISTINCT_VALUES * 2;
+  private static final long TOTAL_NUM_DOCS = (long) NUM_SEGMENTS * 
NUM_DOCS_PER_SEGMENT;
+
+  private static final String INT_COLUMN = "intColumn";
+  private static final String DICT_STRING_COLUMN = "dictStringColumn";
+  private static final String RAW_STRING_COLUMN = "rawStringColumn";
+
+  // The MSE leaf stage pushes no LIMIT down, so the leaf query runs with 
Integer.MAX_VALUE. That is what makes
+  // DistinctTable.hasLimit() false and sends every executor down the 
unbounded add path, so model it here.
+  private static final String NO_LIMIT = " LIMIT " + Integer.MAX_VALUE;
+  // A filter (any filter) keeps DistinctPlanNode off the 
DictionaryBasedDistinctOperator fast path, which reads the
+  // whole dictionary instead of scanning.
+  private static final String MATCH_ALL = " WHERE intColumn >= 0";
+
+  private static final TableConfig TABLE_CONFIG =
+      new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
+          .setNoDictionaryColumns(List.of(RAW_STRING_COLUMN))
+          // Only used by testIndexBasedDistinctOperatorPath; inert unless 
useIndexBasedDistinctOperator is set.
+          .setInvertedIndexColumns(List.of(DICT_STRING_COLUMN)).build();
+  private static final Schema SCHEMA = new Schema.SchemaBuilder()
+      .addSingleValueDimension(INT_COLUMN, FieldSpec.DataType.INT)
+      .addSingleValueDimension(DICT_STRING_COLUMN, FieldSpec.DataType.STRING)
+      .addSingleValueDimension(RAW_STRING_COLUMN, FieldSpec.DataType.STRING)
+      .build();
+
+  private static final PlanMaker PLAN_MAKER = new InstancePlanMakerImplV2();
+  private static final ExecutorService EXECUTOR = 
Executors.newCachedThreadPool();
+
+  private List<IndexSegment> _indexSegments;
+
+  @BeforeClass
+  public void setUp()
+      throws Exception {
+    FileUtils.deleteDirectory(TEMP_DIR);
+    _indexSegments = new ArrayList<>(NUM_SEGMENTS);
+    for (int i = 0; i < NUM_SEGMENTS; i++) {
+      _indexSegments.add(createOfflineSegment(i));
+    }
+  }
+
+  /// Builds the shared fixture: every segment holds the SAME 50 values. See 
[#NUM_DISTINCT_VALUES] for why.
+  private IndexSegment createOfflineSegment(int index)
+      throws Exception {
+    List<GenericRow> records = new ArrayList<>(NUM_DOCS_PER_SEGMENT);
+    for (int i = 0; i < NUM_DISTINCT_VALUES; i++) {
+      for (int j = 0; j < 2; j++) {
+        GenericRow record = new GenericRow();
+        record.putValue(INT_COLUMN, i);
+        record.putValue(DICT_STRING_COLUMN, "d" + i);
+        record.putValue(RAW_STRING_COLUMN, "r" + i);
+        records.add(record);
+      }
+    }
+
+    SegmentGeneratorConfig segmentGeneratorConfig = new 
SegmentGeneratorConfig(TABLE_CONFIG, SCHEMA);
+    segmentGeneratorConfig.setTableName(RAW_TABLE_NAME);
+    String segmentName = SEGMENT_NAME_PREFIX + index;
+    segmentGeneratorConfig.setSegmentName(segmentName);
+    segmentGeneratorConfig.setOutDir(TEMP_DIR.getPath());
+
+    SegmentIndexCreationDriverImpl driver = new 
SegmentIndexCreationDriverImpl();
+    driver.init(segmentGeneratorConfig, new GenericRowRecordReader(records));
+    driver.build();
+
+    return ImmutableSegmentLoader.load(new File(TEMP_DIR, segmentName), 
ReadMode.mmap);
+  }
+
+  /// With identical segments the first adopted block already exceeds any 
useful threshold, so it is flushed with
+  /// nothing merged into it and the accumulate-across-segments path is never 
reached. This fixture gives each
+  /// segment a disjoint range so the accumulator has to merge before it trips 
the threshold.
+  @Test
+  public void testAccumulatorMergesAcrossSegmentsBeforeFlushing()
+      throws Exception {
+    File disjointDir = new File(FileUtils.getTempDirectory(), 
"StreamingDistinctCombineOperatorTest_disjoint");
+    FileUtils.deleteDirectory(disjointDir);
+    List<IndexSegment> segments = new ArrayList<>(NUM_SEGMENTS);
+    try {
+      for (int index = 0; index < NUM_SEGMENTS; index++) {
+        List<GenericRow> records = new ArrayList<>(NUM_DISTINCT_VALUES);
+        for (int i = index * NUM_DISTINCT_VALUES; i < (index + 1) * 
NUM_DISTINCT_VALUES; i++) {
+          GenericRow record = new GenericRow();
+          record.putValue(INT_COLUMN, i);
+          record.putValue(DICT_STRING_COLUMN, "d" + i);
+          record.putValue(RAW_STRING_COLUMN, "r" + i);
+          records.add(record);
+        }
+        SegmentGeneratorConfig segmentGeneratorConfig = new 
SegmentGeneratorConfig(TABLE_CONFIG, SCHEMA);
+        segmentGeneratorConfig.setTableName(RAW_TABLE_NAME);
+        String segmentName = "disjointSegment_" + index;
+        segmentGeneratorConfig.setSegmentName(segmentName);
+        segmentGeneratorConfig.setOutDir(disjointDir.getPath());
+        SegmentIndexCreationDriverImpl driver = new 
SegmentIndexCreationDriverImpl();
+        driver.init(segmentGeneratorConfig, new 
GenericRowRecordReader(records));
+        driver.build();
+        segments.add(ImmutableSegmentLoader.load(new File(disjointDir, 
segmentName), ReadMode.mmap));
+      }
+
+      // Threshold above one segment's cardinality (50), so at least two 
segments must merge before a flush.
+      QueryContext queryContext =
+          newQueryContext("SELECT DISTINCT intColumn FROM testTable" + 
MATCH_ALL + NO_LIMIT);
+      queryContext.setMaxExecutionThreads(1);
+      List<Operator> operators = new ArrayList<>(segments.size());
+      for (IndexSegment segment : segments) {
+        operators.add(PLAN_MAKER.makeSegmentPlanNode(new 
SegmentContext(segment), queryContext).run());
+      }
+      StreamingDistinctCombineOperator combineOperator =
+          new StreamingDistinctCombineOperator(operators, queryContext, 
EXECUTOR, 75);
+
+      Set<Object> values = new HashSet<>();
+      int numBlocks = 0;
+      combineOperator.start();
+      try {
+        BaseResultsBlock block = combineOperator.nextBlock();
+        while (!(block instanceof MetadataResultsBlock)) {
+          assertNull(block.getErrorMessages(), "Expected no errors but got: " 
+ block.getErrorMessages());
+          numBlocks++;
+          for (Object[] row : block.getRows()) {
+            values.add(row[0]);
+          }
+          block = combineOperator.nextBlock();
+        }
+      } finally {
+        combineOperator.stop();
+      }
+
+      // 4 disjoint segments of 50 against a threshold of 75 means each flush 
covers two segments.
+      assertEquals(numBlocks, 2, "Expected each flush window to span two 
merged segments");
+      Set<Object> expected = new HashSet<>();
+      for (int i = 0; i < NUM_SEGMENTS * NUM_DISTINCT_VALUES; i++) {
+        expected.add(i);
+      }
+      assertEquals(values, expected);
+    } finally {
+      for (IndexSegment segment : segments) {
+        segment.destroy();
+      }
+      FileUtils.deleteDirectory(disjointDir);
+    }
+  }
+
+  /// maxRowsInDistinct bounds scanned docs for the whole query. Each flush 
starts from an empty accumulator, so
+  /// the scanned-doc count has to be carried across windows or the bound 
silently becomes per-window and never
+  /// trips. Also pins that the reason reaches the terminal metadata block: it 
is recorded on the accumulated block,
+  /// which by then has already been streamed out, so without the carry-over 
the broker would report a truncated
+  /// result as complete.
+  ///
+  /// The threshold here is above one segment's cardinality (50) on purpose. 
Below it every block flushes on
+  /// adoption, `mergeResultsBlocks` never runs, and no distinct guardrail can 
fire at all - see the class Javadoc
+  /// on [StreamingDistinctCombineOperator].
+  @Test
+  public void testMaxRowsInDistinctIsPerQueryAcrossFlushWindows() {
+    // 4 segments x 100 docs = 400 scanned; bound well below that.
+    FlushResult bounded = runStreaming(
+        "SET maxRowsInDistinct = 150; SELECT DISTINCT intColumn FROM 
testTable" + MATCH_ALL + NO_LIMIT, 75);
+    assertEquals(bounded._earlyTerminationReason, 
EarlyTerminationReason.DISTINCT_MAX_ROWS,
+        "maxRowsInDistinct must terminate the query across flush windows, not 
per window");
+
+    // Same query without the bound returns the full set and reports no early 
termination.
+    FlushResult unbounded = runStreaming("SELECT DISTINCT intColumn FROM 
testTable" + MATCH_ALL + NO_LIMIT, 75);
+    assertEquals(unbounded._earlyTerminationReason, 
EarlyTerminationReason.NONE);
+    assertEquals(distinctValues(unbounded), expectedIntValues());
+    // Asserted on the unbounded run only. The bounded one stops polling as 
soon as the merger reports the query
+    // satisfied, so its terminal metadata block is built while the worker 
threads are still being cancelled and the
+    // scanned-doc total is whatever they happened to reach. See 
testStreamingDistinctProducesMultipleBlocks for the
+    // multi-flush-window version of this assertion.
+    assertEquals(unbounded._numDocsScanned, TOTAL_NUM_DOCS);
+  }
+
+  /// The threshold has to reach the leaf QueryContext through the real 
query-option path, and must be left alone
+  /// for non-distinct queries.
+  @Test
+  public void testFlushThresholdQueryOptionWiring() {
+    assertEquals(planned("SET streamingDistinctFlushThreshold = 100; SELECT 
DISTINCT intColumn FROM testTable"), 100);
+    assertEquals(planned("SET streamingDistinctFlushThreshold = 0; SELECT 
DISTINCT intColumn FROM testTable"), 0);
+    assertEquals(planned("SELECT DISTINCT intColumn FROM testTable"), 0);
+    // A group-by aggregation is not a distinct query, so the distinct 
threshold must stay unset.
+    assertEquals(planned(
+        "SET streamingDistinctFlushThreshold = 100; SELECT intColumn, COUNT(*) 
FROM testTable GROUP BY intColumn"), 0);
+  }
+
+  /// Goes through the real streaming-plan entry point (which is what applies 
the query options on the server)
+  /// rather than setting the QueryContext field directly.
+  private int planned(String query) {
+    QueryContext queryContext = newQueryContext(query);
+    List<SegmentContext> segmentContexts = new ArrayList<>(NUM_SEGMENTS);
+    for (IndexSegment indexSegment : _indexSegments) {
+      segmentContexts.add(new SegmentContext(indexSegment));
+    }
+    PLAN_MAKER.makeStreamingInstancePlan(segmentContexts, queryContext, 
EXECUTOR, block -> {
+    });
+    return queryContext.getStreamingDistinctFlushThreshold();
+  }
+
+  /// The routing guard in CombinePlanNode is the real gate, but the operator 
is public, so the constructor keeps a
+  /// backstop. Asserted directly because a slipped negation here would leave 
the backstop silently inert while every
+  /// routing test still passed.
+  @Test
+  public void testConstructorRejectsLeafReturningFinalResults() {
+    for (boolean keyUnpartitioned : new boolean[]{false, true}) {
+      QueryContext queryContext = newQueryContext("SELECT DISTINCT intColumn 
FROM testTable" + NO_LIMIT);
+      queryContext.setServerReturnFinalResult(!keyUnpartitioned);
+      
queryContext.setServerReturnFinalResultKeyUnpartitioned(keyUnpartitioned);
+      assertThrows(IllegalStateException.class,
+          () -> new StreamingDistinctCombineOperator(List.of(), queryContext, 
EXECUTOR, 10));
+    }
+    // Control: the same construction succeeds with neither flag set, so the 
assertions above cannot be passing
+    // because of some unrelated IllegalStateException.
+    QueryContext queryContext = newQueryContext("SELECT DISTINCT intColumn 
FROM testTable" + NO_LIMIT);
+    assertNotNull(new StreamingDistinctCombineOperator(List.of(), 
queryContext, EXECUTOR, 10));
+  }
+
+  @Test
+  public void testStreamingDistinctProducesMultipleBlocks() {
+    // 50 distinct values with a flush threshold of 10 forces several flushes
+    FlushResult result = runStreaming("SELECT DISTINCT intColumn FROM 
testTable" + MATCH_ALL + NO_LIMIT, 10);
+
+    assertTrue(result._numBlocks > 1, "Expected multiple data blocks but got " 
+ result._numBlocks);
+    assertEquals(distinctValues(result), expectedIntValues());
+    // Guards the "carrying the count across flush windows cannot double 
count" claim in mergeBlock(): each segment's
+    // docs must be counted exactly once no matter how many windows they were 
spread over. Holds because
+    // attachExecutionStats() recomputes the total from the segment operators 
rather than from the accumulated block,
+    // which is precisely the coupling a later refactor could break quietly.
+    assertEquals(result._numDocsScanned, TOTAL_NUM_DOCS);
+  }
+
+  /// Pins the correctness argument the design rests on: the leaf may emit the 
same value in more than one flush
+  /// window, and de-duplicating the union still yields the exact answer.
+  @Test
+  public void testDuplicateValuesAcrossFlushWindowsAreIdempotent() {
+    FlushResult result = runStreaming("SELECT DISTINCT intColumn FROM 
testTable" + MATCH_ALL + NO_LIMIT, 10);
+
+    assertTrue(result._rows.size() > NUM_DISTINCT_VALUES,
+        "Expected duplicate values across flush windows, but only " + 
result._rows.size() + " rows were emitted");
+    assertEquals(distinctValues(result), expectedIntValues());
+  }
+
+  @Test
+  public void testHighThresholdProducesSingleBlock() {
+    FlushResult result = runStreaming("SELECT DISTINCT intColumn FROM 
testTable" + MATCH_ALL + NO_LIMIT, 10_000);
+
+    assertEquals(result._numBlocks, 1, "Expected a single data block when the 
threshold exceeds the cardinality");
+    assertEquals(result._rows.size(), NUM_DISTINCT_VALUES);
+    assertEquals(distinctValues(result), expectedIntValues());
+  }
+
+  /// Single raw (non-dictionary-encoded) column — StringDistinctExecutor / 
StringDistinctTable.
+  @Test
+  public void testRawStringColumn() {
+    FlushResult result = runStreaming("SELECT DISTINCT rawStringColumn FROM 
testTable" + MATCH_ALL + NO_LIMIT, 10);
+
+    assertTrue(result._numBlocks > 1);
+    Set<Object> expected = new HashSet<>();
+    for (int i = 0; i < NUM_DISTINCT_VALUES; i++) {
+      expected.add("r" + i);
+    }
+    assertEquals(distinctValues(result), expected);
+  }
+
+  /// Single dictionary-encoded column reached through the scan path — 
DictionaryBasedSingleColumnDistinctExecutor.
+  /// Its getResult() materializes actual values from the segment dictionary, 
which is what makes the flushed block
+  /// self-contained (no detachFromWorkerThreadState hook needed). 
Segment-local dict ids leaking out here would show
+  /// up as values outside 0..49.
+  @Test
+  public void testDictionaryEncodedColumnMaterializesValues() {
+    FlushResult result = runStreaming("SELECT DISTINCT dictStringColumn FROM 
testTable" + MATCH_ALL + NO_LIMIT, 10);
+
+    Set<Object> expected = new HashSet<>();
+    for (int i = 0; i < NUM_DISTINCT_VALUES; i++) {
+      expected.add("d" + i);
+    }
+    assertEquals(distinctValues(result), expected);
+  }
+
+  /// All columns dictionary-encoded — 
DictionaryBasedMultiColumnDistinctExecutor.
+  @Test
+  public void testMultiColumnAllDictionaryEncoded() {
+    FlushResult result =
+        runStreaming("SELECT DISTINCT intColumn, dictStringColumn FROM 
testTable" + MATCH_ALL + NO_LIMIT, 10);
+
+    assertTrue(result._numBlocks > 1);
+    Set<List<Object>> expected = new HashSet<>();
+    for (int i = 0; i < NUM_DISTINCT_VALUES; i++) {
+      expected.add(List.of(i, "d" + i));
+    }
+    assertEquals(distinctRows(result), expected);
+  }
+
+  /// At least one raw column — RawMultiColumnDistinctExecutor / 
MultiColumnDistinctTable.
+  @Test
+  public void testMultiColumnWithRawColumn() {
+    FlushResult result =
+        runStreaming("SELECT DISTINCT intColumn, rawStringColumn FROM 
testTable" + MATCH_ALL + NO_LIMIT, 10);
+
+    assertTrue(result._numBlocks > 1);
+    Set<List<Object>> expected = new HashSet<>();
+    for (int i = 0; i < NUM_DISTINCT_VALUES; i++) {
+      expected.add(List.of(i, "r" + i));
+    }
+    assertEquals(distinctRows(result), expected);
+  }
+
+  /// DistinctPlanNode can also select the index-based operators when 
useIndexBasedDistinctOperator is set. They
+  /// build their DistinctTable differently from the scan path, so make sure 
their blocks flow through the streaming
+  /// combine correctly (in particular that real values, not segment-local 
dict ids, come out).
+  @Test
+  public void testIndexBasedDistinctOperatorPath() {
+    FlushResult result = runStreaming(
+        "SET useIndexBasedDistinctOperator = true; SELECT DISTINCT 
dictStringColumn FROM testTable" + MATCH_ALL
+            + NO_LIMIT, 10);
+
+    Set<Object> expected = new HashSet<>();
+    for (int i = 0; i < NUM_DISTINCT_VALUES; i++) {
+      expected.add("d" + i);
+    }
+    assertEquals(distinctValues(result), expected);
+  }
+
+  /// Without a filter, DistinctPlanNode picks 
DictionaryBasedDistinctOperator, which reads the dictionary directly
+  /// rather than scanning. The streaming combine must handle those blocks too.
+  @Test
+  public void testDictionaryBasedDistinctOperatorPath() {
+    FlushResult result = runStreaming("SELECT DISTINCT intColumn FROM 
testTable" + NO_LIMIT, 10);
+
+    assertEquals(distinctValues(result), expectedIntValues());
+  }
+
+  /// When no rows match, every segment yields an empty table and the operator 
must return only the metadata block —
+  /// never an empty data block, and never an EmptyDistinctTable as the 
accumulator (it throws on mergeDistinctTable).
+  @Test
+  public void testNoMatchingRowsReturnsOnlyMetadata() {
+    FlushResult result = runStreaming("SELECT DISTINCT intColumn FROM 
testTable WHERE intColumn < 0" + NO_LIMIT, 10);
+
+    assertEquals(result._numBlocks, 0, "Expected no data blocks when nothing 
matches");
+    assertTrue(result._rows.isEmpty());
+  }
+
+  /// A finite LIMIT below the flush threshold still bounds the table, and the 
streaming operator must not lose rows
+  /// when the per-segment executors short-circuit on 
DistinctTable.isSatisfied().
+  @Test
+  public void testFiniteLimitBelowThreshold() {
+    FlushResult result = runStreaming("SELECT DISTINCT intColumn FROM 
testTable" + MATCH_ALL + " LIMIT 20", 10_000);
+
+    Set<Object> values = distinctValues(result);
+    assertEquals(values.size(), 20, "Expected exactly LIMIT distinct values");
+    assertTrue(expectedIntValues().containsAll(values), "Returned values must 
be a subset of the real distinct set");
+  }
+
+  /// CombinePlanNode must only pick the streaming operator when every guard 
holds.
+  @Test
+  public void testCombinePlanNodeSelection() {
+    // Streaming applies: distinct, no ORDER BY, unbounded limit, not 
returning final results
+    assertTrue(buildCombineOperator("SELECT DISTINCT intColumn FROM testTable" 
+ NO_LIMIT, 10, false)
+        instanceof StreamingDistinctCombineOperator, "Expected the streaming 
operator for an unbounded distinct");
+
+    // Threshold not set (0) — feature off
+    assertTrue(buildCombineOperator("SELECT DISTINCT intColumn FROM testTable" 
+ NO_LIMIT, 0, false)
+        instanceof DistinctCombineOperator, "Expected the blocking operator 
when the threshold is 0");
+
+    // LIMIT <= threshold already bounds the table and gives the isSatisfied() 
short-circuit
+    assertTrue(buildCombineOperator("SELECT DISTINCT intColumn FROM testTable 
LIMIT 10", 10, false)
+        instanceof DistinctCombineOperator, "Expected the blocking operator 
when LIMIT bounds the table");
+
+    // ORDER BY keeps a bounded top-LIMIT heap
+    assertTrue(
+        buildCombineOperator("SELECT DISTINCT intColumn FROM testTable ORDER 
BY intColumn LIMIT 1000", 10, false)
+            instanceof DistinctCombineOperator, "Expected the blocking 
operator with ORDER BY");
+
+    // No aggregate above the leaf guaranteed to de-duplicate across flush 
windows
+    assertTrue(buildCombineOperator("SELECT DISTINCT intColumn FROM testTable" 
+ NO_LIMIT, 10, true, false)
+        instanceof DistinctCombineOperator, "Expected the blocking operator 
when the server returns final results");
+    assertTrue(buildCombineOperator("SELECT DISTINCT intColumn FROM testTable" 
+ NO_LIMIT, 10, false, true)
+        instanceof DistinctCombineOperator,
+        "Expected the blocking operator when the leaf returns final results 
for unpartitioned keys");
+
+    // A group-by aggregation must keep its own operator even with the 
distinct threshold set
+    assertFalse(
+        buildCombineOperator("SELECT intColumn, COUNT(*) FROM testTable GROUP 
BY intColumn" + NO_LIMIT, 10, false)
+            instanceof StreamingDistinctCombineOperator, "The distinct 
threshold must not capture group-by queries");
+  }
+
+  /// A null is tracked by a flag on the DistinctTable rather than as a set 
entry, and each flush window starts from a
+  /// fresh accumulator. The null must therefore survive the flush boundary: 
it is counted by size(), emitted by
+  /// getRows(), and re-emitted in later windows (harmless, since the 
downstream de-duplication is idempotent).
+  @Test
+  public void testNullHandling()
+      throws Exception {
+    File nullDir = new File(FileUtils.getTempDirectory(), 
"StreamingDistinctCombineOperatorTest_null");
+    FileUtils.deleteDirectory(nullDir);
+    String nullableColumn = "nullableColumn";
+    TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
+        .setNullHandlingEnabled(true).build();
+    Schema schema = new Schema.SchemaBuilder()
+        .addSingleValueDimension(INT_COLUMN, FieldSpec.DataType.INT)
+        .addSingleValueDimension(nullableColumn, FieldSpec.DataType.STRING)
+        .build();
+
+    List<IndexSegment> segments = new ArrayList<>(NUM_SEGMENTS);
+    try {
+      for (int index = 0; index < NUM_SEGMENTS; index++) {
+        List<GenericRow> records = new ArrayList<>(NUM_DISTINCT_VALUES);
+        for (int i = 0; i < NUM_DISTINCT_VALUES; i++) {
+          GenericRow record = new GenericRow();
+          record.putValue(INT_COLUMN, i);
+          // Half the rows are null, so every segment contributes both real 
values and the null marker.
+          if (i % 2 == 0) {
+            record.putValue(nullableColumn, "n" + i);
+          } else {
+            record.putValue(nullableColumn, null);
+          }
+          records.add(record);
+        }
+        SegmentGeneratorConfig segmentGeneratorConfig = new 
SegmentGeneratorConfig(tableConfig, schema);
+        segmentGeneratorConfig.setTableName(RAW_TABLE_NAME);
+        String segmentName = "nullSegment_" + index;
+        segmentGeneratorConfig.setSegmentName(segmentName);
+        segmentGeneratorConfig.setOutDir(nullDir.getPath());
+        SegmentIndexCreationDriverImpl driver = new 
SegmentIndexCreationDriverImpl();
+        driver.init(segmentGeneratorConfig, new 
GenericRowRecordReader(records));
+        driver.build();
+        segments.add(ImmutableSegmentLoader.load(new File(nullDir, 
segmentName), ReadMode.mmap));
+      }
+
+      QueryContext queryContext =
+          newQueryContext("SELECT DISTINCT nullableColumn FROM testTable" + 
MATCH_ALL + NO_LIMIT);
+      queryContext.setNullHandlingEnabled(true);
+      List<Operator> operators = new ArrayList<>(segments.size());
+      for (IndexSegment segment : segments) {
+        operators.add(PLAN_MAKER.makeSegmentPlanNode(new 
SegmentContext(segment), queryContext).run());
+      }
+      StreamingDistinctCombineOperator combineOperator =
+          new StreamingDistinctCombineOperator(operators, queryContext, 
EXECUTOR, 5);
+
+      Set<Object> values = new HashSet<>();
+      int numBlocks = 0;
+      combineOperator.start();
+      try {
+        BaseResultsBlock block = combineOperator.nextBlock();
+        while (!(block instanceof MetadataResultsBlock)) {
+          assertNull(block.getErrorMessages(), "Expected no errors but got: " 
+ block.getErrorMessages());
+          numBlocks++;
+          for (Object[] row : block.getRows()) {
+            values.add(row[0]);
+          }
+          block = combineOperator.nextBlock();
+        }
+      } finally {
+        combineOperator.stop();
+      }
+
+      assertTrue(numBlocks > 1, "Expected multiple flush windows but got " + 
numBlocks);
+      Set<Object> expected = new HashSet<>();
+      expected.add(null);
+      for (int i = 0; i < NUM_DISTINCT_VALUES; i += 2) {
+        expected.add("n" + i);
+      }
+      assertEquals(values, expected, "The null must survive the flush 
boundary");
+    } finally {
+      for (IndexSegment segment : segments) {
+        segment.destroy();
+      }
+      FileUtils.deleteDirectory(nullDir);
+    }
+  }
+
+  private BaseCombineOperator<?> buildCombineOperator(String query, int 
flushThreshold,
+      boolean serverReturnFinalResult) {
+    return buildCombineOperator(query, flushThreshold, 
serverReturnFinalResult, false);
+  }
+
+  private BaseCombineOperator<?> buildCombineOperator(String query, int 
flushThreshold,
+      boolean serverReturnFinalResult, boolean 
serverReturnFinalResultKeyUnpartitioned) {
+    QueryContext queryContext = newQueryContext(query);
+    queryContext.setStreamingDistinctFlushThreshold(flushThreshold);
+    queryContext.setServerReturnFinalResult(serverReturnFinalResult);
+    
queryContext.setServerReturnFinalResultKeyUnpartitioned(serverReturnFinalResultKeyUnpartitioned);
+    List<PlanNode> planNodes = new ArrayList<>(NUM_SEGMENTS);
+    for (IndexSegment indexSegment : _indexSegments) {
+      planNodes.add(PLAN_MAKER.makeSegmentPlanNode(new 
SegmentContext(indexSegment), queryContext));
+    }
+    ResultsBlockStreamer streamer = block -> {
+    };
+    return new CombinePlanNode(planNodes, queryContext, EXECUTOR, 
streamer).run();
+  }
+
+  private FlushResult runStreaming(String query, int flushThreshold) {
+    QueryContext queryContext = newQueryContext(query);
+    List<Operator> operators = new ArrayList<>(NUM_SEGMENTS);
+    for (IndexSegment indexSegment : _indexSegments) {
+      operators.add(PLAN_MAKER.makeSegmentPlanNode(new 
SegmentContext(indexSegment), queryContext).run());
+    }
+    StreamingDistinctCombineOperator combineOperator =
+        new StreamingDistinctCombineOperator(operators, queryContext, 
EXECUTOR, flushThreshold);
+
+    List<Object[]> rows = new ArrayList<>();
+    int numBlocks = 0;
+    EarlyTerminationReason earlyTerminationReason;
+    long numDocsScanned;
+    combineOperator.start();
+    try {
+      BaseResultsBlock block = combineOperator.nextBlock();
+      while (!(block instanceof MetadataResultsBlock)) {
+        assertNull(block.getErrorMessages(), "Expected no errors but got: " + 
block.getErrorMessages());
+        assertTrue(block instanceof DistinctResultsBlock,
+            "Expected DistinctResultsBlock but got: " + block.getClass());
+        numBlocks++;
+        rows.addAll(block.getRows());
+        block = combineOperator.nextBlock();
+      }
+      // The reason is set on the accumulated block, which has already been 
streamed out by then, so the terminal
+      // metadata block is the only place the broker can still learn the 
results were truncated.
+      earlyTerminationReason = block.getEarlyTerminationReason();
+      numDocsScanned = block.getNumDocsScanned();
+    } finally {
+      combineOperator.stop();
+    }
+    return new FlushResult(numBlocks, rows, earlyTerminationReason, 
numDocsScanned);
+  }
+
+  private static QueryContext newQueryContext(String query) {
+    QueryContext queryContext = 
QueryContextConverterUtils.getQueryContext(query);
+    queryContext.setEndTimeMs(System.currentTimeMillis() + 
Server.DEFAULT_QUERY_EXECUTOR_TIMEOUT_MS);
+    return queryContext;
+  }
+
+  private static Set<Object> expectedIntValues() {
+    Set<Object> expected = new HashSet<>();
+    for (int i = 0; i < NUM_DISTINCT_VALUES; i++) {
+      expected.add(i);
+    }
+    return expected;
+  }
+
+  private static Set<Object> distinctValues(FlushResult result) {
+    Set<Object> values = new HashSet<>();
+    for (Object[] row : result._rows) {
+      assertEquals(row.length, 1);
+      values.add(row[0]);
+    }
+    return values;
+  }
+
+  private static Set<List<Object>> distinctRows(FlushResult result) {
+    Set<List<Object>> distinct = new HashSet<>();
+    for (Object[] row : result._rows) {
+      distinct.add(Arrays.asList(row));
+    }
+    return distinct;
+  }
+
+  private static class FlushResult {
+    final int _numBlocks;
+    final List<Object[]> _rows;
+    final EarlyTerminationReason _earlyTerminationReason;
+    final long _numDocsScanned;
+
+    FlushResult(int numBlocks, List<Object[]> rows, EarlyTerminationReason 
earlyTerminationReason,
+        long numDocsScanned) {
+      _numBlocks = numBlocks;
+      _rows = rows;
+      _earlyTerminationReason = earlyTerminationReason;
+      _numDocsScanned = numDocsScanned;
+    }
+  }
+
+  @AfterClass
+  public void tearDown()
+      throws IOException {
+    for (IndexSegment indexSegment : _indexSegments) {
+      indexSegment.destroy();
+    }
+    FileUtils.deleteDirectory(TEMP_DIR);
+  }
+}
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineIntegrationTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineIntegrationTest.java
index ce6db81ee8b..e5f83cb01a3 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineIntegrationTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineIntegrationTest.java
@@ -30,6 +30,7 @@ import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -2336,6 +2337,89 @@ public class MultiStageEngineIntegrationTest extends 
BaseClusterIntegrationTestS
     }
   }
 
+  private static final String STREAMING_DISTINCT_OPTION = "SET 
streamingDistinctFlushThreshold = 2; ";
+
+  /// Verifies that the streaming distinct feature produces correct results 
when the query option
+  /// streamingDistinctFlushThreshold is set. Compares streaming results 
against a baseline query without the option.
+  ///
+  /// The threshold is set well below the real cardinality so the leaf flushes 
several times and the same value is
+  /// emitted in more than one flush window - the case the intermediate stage 
has to de-duplicate.
+  ///
+  /// NOTE: None of these queries may carry an ORDER BY. The planner pushes 
collations into the leaf aggregate for
+  /// aggregates with no aggregate calls 
(PinotAggregateExchangeNodeInsertRule#isGroupTrimmingEnabled), which makes
+  /// the leaf QueryContext order-by non-null and disqualifies the streaming 
operator - the assertions would then
+  /// compare the blocking path against itself and pass no matter what the new 
operator does.
+  @Test
+  public void testStreamingDistinct()
+      throws Exception {
+    // Single column
+    assertStreamingDistinctMatchesBaseline("SELECT DISTINCT Carrier FROM 
mytable LIMIT 1000");
+
+    // Multiple columns
+    assertStreamingDistinctMatchesBaseline("SELECT DISTINCT Carrier, 
OriginState FROM mytable LIMIT 1000");
+
+    // The GROUP BY spelling of the same thing: an aggregate with no aggregate 
calls, which the leaf stage rewrites
+    // into a single-stage DISTINCT query 
(NonAggregationGroupByToDistinctQueryRewriter).
+    assertStreamingDistinctMatchesBaseline("SELECT Carrier FROM mytable GROUP 
BY Carrier LIMIT 1000");
+  }
+
+  /// The streaming operator must be skipped when the leaf-stage LIMIT is at 
or below the flush threshold (the limit
+  /// already bounds the table and keeps the early-termination short-circuit) 
and when the query has an ORDER BY.
+  @Test
+  public void testStreamingDistinctNotUsedWhenGuardsFail()
+      throws Exception {
+    // LIMIT equal to and below the threshold of 2
+    assertStreamingDistinctOperatorUsed("SELECT DISTINCT Carrier FROM mytable 
LIMIT 2", false);
+    assertStreamingDistinctOperatorUsed("SELECT DISTINCT Carrier FROM mytable 
LIMIT 1", false);
+    // ORDER BY is pushed into the leaf aggregate, so the leaf sees a bounded 
top-LIMIT heap
+    assertStreamingDistinctOperatorUsed("SELECT DISTINCT Carrier FROM mytable 
ORDER BY Carrier LIMIT 1000", false);
+    // Control: same shape, no ORDER BY, LIMIT above the threshold
+    assertStreamingDistinctOperatorUsed("SELECT DISTINCT Carrier FROM mytable 
LIMIT 1000", true);
+  }
+
+  private void assertStreamingDistinctMatchesBaseline(String query)
+      throws Exception {
+    // Guard against a vacuous comparison: without this, a query that silently 
takes the blocking path in both runs
+    // would pass whatever the streaming operator did.
+    assertStreamingDistinctOperatorUsed(query, true);
+
+    JsonNode baselineRows = postQuery(query).get("resultTable").get("rows");
+    assertTrue(baselineRows.size() > 0, "Baseline query should return results: 
" + query);
+
+    JsonNode streamingRows =
+        postQuery(STREAMING_DISTINCT_OPTION + 
query).get("resultTable").get("rows");
+
+    // Results are unordered, so compare as sorted multisets. This still 
catches both duplicates and dropped rows.
+    assertEquals(sortedRows(streamingRows), sortedRows(baselineRows),
+        "Streaming distinct returned a different row multiset for: " + query);
+  }
+
+  /// Asserts which leaf-stage combine operator the server actually picks. 
`explainAskingServers` pulls the
+  /// single-stage leaf plan in under `LeafStageCombineOperator`, where 
operator names appear in the UpperCamel form
+  /// produced by `BaseOperator#getExplainName()` - so 
`STREAMING_COMBINE_DISTINCT` renders as
+  /// `StreamingCombineDistinct`. Note `CombineDistinct` is a substring of it, 
so only the streaming name can be
+  /// tested for presence/absence unambiguously.
+  private void assertStreamingDistinctOperatorUsed(String query, boolean 
expected)
+      throws Exception {
+    String plan = postQuery("SET explainAskingServers=true; " + 
STREAMING_DISTINCT_OPTION + "EXPLAIN PLAN FOR " + query)
+        .toString();
+    assertEquals(plan.contains("StreamingCombineDistinct"), expected,
+        "Unexpected combine operator selection for: " + query + "\nPlan: " + 
plan);
+  }
+
+  private static List<List<String>> sortedRows(JsonNode rows) {
+    List<List<String>> result = new ArrayList<>(rows.size());
+    for (JsonNode row : rows) {
+      List<String> values = new ArrayList<>(row.size());
+      for (JsonNode value : row) {
+        values.add(value.asText());
+      }
+      result.add(values);
+    }
+    result.sort(Comparator.comparing(Object::toString));
+    return result;
+  }
+
   private JsonNode getQueryResultForDBTest(String column, String tableName, 
@Nullable String database,
       Map<String, String> headers)
       throws Exception {
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java 
b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
index 71298ee7cb3..508a838e88f 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
@@ -546,6 +546,17 @@ public class CommonConstants {
     public static final String 
CONFIG_OF_MSE_STREAMING_GROUP_BY_FLUSH_THRESHOLD =
         "pinot.broker.mse.streaming.group.by.flush.threshold";
     public static final int DEFAULT_MSE_STREAMING_GROUP_BY_FLUSH_THRESHOLD = 
-1;
+
+    /// Default flush threshold for the streaming distinct leaf-stage operator 
on MSE. When positive, the broker
+    /// injects this value as the `streamingDistinctFlushThreshold` query 
option for MSE queries that do not already
+    /// specify it, opting the cluster into the streaming distinct behavior by 
default. Setting the query option
+    /// explicitly (including to `0` to disable) always wins over the broker 
default.
+    ///
+    /// See [Request.QueryOptionKey#STREAMING_DISTINCT_FLUSH_THRESHOLD] for 
the conditions a query must meet before
+    /// the threshold takes effect; queries that do not meet them are 
unaffected by this default.
+    public static final String 
CONFIG_OF_MSE_STREAMING_DISTINCT_FLUSH_THRESHOLD =
+        "pinot.broker.mse.streaming.distinct.flush.threshold";
+    public static final int DEFAULT_MSE_STREAMING_DISTINCT_FLUSH_THRESHOLD = 
-1;
     // Whether to infer partition hint by default or not.
     // This value can always be overridden by INFER_PARTITION_HINT query option
     public static final String CONFIG_OF_INFER_PARTITION_HINT = 
"pinot.broker.multistage.infer.partition.hint";
@@ -797,6 +808,27 @@ public class CommonConstants {
         /// Flush threshold for streaming group-by on MSE leaf stages.
         public static final String STREAMING_GROUP_BY_FLUSH_THRESHOLD = 
"streamingGroupByFlushThreshold";
 
+        /// Flush threshold for streaming distinct on MSE leaf stages. When 
positive, the leaf flushes its
+        /// accumulated distinct values downstream once they reach this count 
and starts a fresh table, bounding
+        /// server memory and pushing the residual de-duplication into the 
partitioned intermediate stage.
+        ///
+        /// The value is also a feature gate, so it is a silent no-op unless 
ALL of the following hold. Setting it
+        /// produces no error and no diagnostic when they do not:
+        ///
+        /// - the query is a DISTINCT query (an MSE aggregate with no 
aggregate calls; a group-by with real
+        ///   aggregations uses [#STREAMING_GROUP_BY_FLUSH_THRESHOLD] instead)
+        /// - it has no ORDER BY — an ordered distinct already keeps a bounded 
top-LIMIT heap
+        /// - the leaf-stage LIMIT is strictly greater than this threshold — a 
smaller LIMIT already bounds the
+        ///   table and gives the early-termination short-circuit, which 
streaming would throw away
+        /// - the leaf is not returning final results (the 
`is_partitioned_by_group_by_keys` and
+        ///   `is_leaf_return_final_result` hints), because then no stage 
above the leaf is guaranteed to
+        ///   de-duplicate across flush windows
+        ///
+        /// NOTE: This relies on a downstream stage de-duplicating the partial 
flushes, which is what the MSE hash
+        /// exchange over the distinct columns provides. Do not set it on the 
gRPC streaming query path, where
+        /// there is no such stage and the client would observe duplicate rows 
across flush windows.
+        public static final String STREAMING_DISTINCT_FLUSH_THRESHOLD = 
"streamingDistinctFlushThreshold";
+
         public static final String NUM_REPLICA_GROUPS_TO_QUERY = 
"numReplicaGroupsToQuery";
         public static final String ORDERED_PREFERRED_POOLS = 
"orderedPreferredPools";
         public static final String USE_FIXED_REPLICA = "useFixedReplica";


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

Reply via email to