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


##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/AggregateOperator.java:
##########
@@ -268,12 +367,87 @@ private MseBlock.Eos consumeGroupBy() {
     MseBlock block = _input.nextBlock();
     while (block.isData()) {
       _groupByExecutor.processBlock((MseBlock.Data) block);
+      if (_spillThreshold > 0 && _groupByExecutor.getNumGroups() >= 
_spillThreshold) {
+        spillCurrentGroups();
+      }
       checkTerminationAndSampleUsage();
       block = _input.nextBlock();
     }
     return (MseBlock.Eos) block;
   }
 
+  private void spillCurrentGroups() {
+    assert _groupByExecutor != null;
+    if (_groupByExecutor.getNumGroups() == 0) {
+      return;
+    }
+    if (_spillManager == null) {
+      _spillManager =
+          new AggregationSpillManager(_spillPartitions, _groupKeyIds.length, 
getSpillSchema(), _aggFunctions);
+      _spillDirectory = _spillManager.getSpillDirectory();
+    }
+    AggregationSpillManager.SpillResult spillResult =
+        _spillManager.spill(_groupByExecutor.getIntermediateResultIterator());
+    _statMap.merge(StatKey.SPILL_COUNT, 1L);
+    _statMap.merge(StatKey.SPILLED_ROWS, spillResult.getRows());
+    _statMap.merge(StatKey.SPILLED_BYTES, spillResult.getBytes());
+    _groupByExecutor = newInputGroupByExecutor();
+  }
+
+  private DataSchema getSpillSchema() {
+    String[] columnNames = _resultSchema.getColumnNames().clone();
+    ColumnDataType[] columnDataTypes = 
_resultSchema.getColumnDataTypes().clone();
+    int numKeys = _groupKeyIds.length;
+    for (int i = 0; i < _aggFunctions.length; i++) {
+      columnDataTypes[numKeys + i] = _aggFunctions[i].getType() == 
AggregationFunctionType.ANYVALUE
+          ? ColumnDataType.OBJECT : 
_aggFunctions[i].getIntermediateResultColumnType();
+    }
+    return new DataSchema(columnNames, columnDataTypes);
+  }
+
+  @Nullable
+  private MseBlock.Data restoreSpillPartition(int partitionId) {
+    assert _spillManager != null;
+    if (!_spillManager.hasPartition(partitionId)) {
+      return null;
+    }
+    MultistageGroupByExecutor executor = newSpillMergeGroupByExecutor();
+    _spillManager.consumePartition(partitionId, block -> {
+      executor.processSpillBlock(block);
+      if (executor.getNumGroups() > _spillThreshold) {
+        throw QueryErrorCode.SERVER_RESOURCE_LIMIT_EXCEEDED.asException(

Review Comment:
   **CORRECTNESS/UX: enabling spill can turn a query that used to succeed into 
a hard SERVER_RESOURCE_LIMIT_EXCEEDED**
   
   Setting mseAggregationSpillThreshold has two coupled effects: (a) the input 
executor's numGroupsLimit becomes Integer.MAX_VALUE, and (b) any restored 
partition above the threshold throws.
   
   So for a skewed key distribution — which is exactly the shape that motivates 
spilling — the outcome flips from 'degrade to numGroupsLimit / trim' to 'query 
fails'. A user who turns the option on to make a big GROUP BY work can get a 
new failure mode on a query that previously returned (truncated) results.
   
   The PR explicitly scopes out recursive repartitioning, which is the real 
fix, and that is a reasonable v1 call. But the intermediate fallback is cheap: 
instead of throwing, fall back to the pre-existing numGroupsLimit behaviour for 
that partition (set NUM_GROUPS_LIMIT_REACHED, stop admitting groups) and only 
throw when _errorOnNumGroupsLimit is set. That keeps the failure semantics 
identical to non-spill mode. At minimum this behaviour change needs to be in 
the release notes, not only in the PR body.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/AggregationSpillManager.java:
##########
@@ -0,0 +1,428 @@
+/**
+ * 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.operator;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.io.BufferedInputStream;
+import java.io.DataInputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import org.apache.pinot.common.datablock.DataBlock;
+import org.apache.pinot.common.datablock.DataBlockUtils;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.core.query.aggregation.function.AggregationFunction;
+import org.apache.pinot.query.runtime.blocks.MseBlock;
+import org.apache.pinot.query.runtime.blocks.RowHeapDataBlock;
+import org.apache.pinot.query.runtime.blocks.SerializedDataBlock;
+import org.apache.pinot.spi.query.QueryThreadContext;
+import org.apache.pinot.spi.utils.CommonConstants.Server;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/// Manages hash-partitioned aggregation spill files for one operator. The 
caller must finish reading before calling
+/// [#close()], which recursively removes the operator-scoped directory. This 
class is not thread-safe.
+@SuppressWarnings("rawtypes")
+class AggregationSpillManager implements AutoCloseable {
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(AggregationSpillManager.class);
+  private static final String SPILL_FILE_PREFIX = "partition-";
+  private static final String SPILL_FILE_SUFFIX = ".spill";
+  private static final String SPILL_SCOPE = "AggregationSpillManager#spill";
+  private static final String RESTORE_SCOPE = 
"AggregationSpillManager#consumePartition";
+  private static final int MAX_BUFFERED_ROWS = 1024;
+  private static final int MAX_BUFFERED_PARTITIONS = 8;
+
+  private final int _numPartitions;
+  private final int _numGroupKeys;
+  private final DataSchema _spillSchema;
+  private final AggregationFunction[] _aggFunctions;
+  private final Path _spillDirectory;
+  private final Map<Integer, FileChannel> _spillWriters = new HashMap<>();
+  private final ByteBuffer _recordLengthBuffer = 
ByteBuffer.allocate(Integer.BYTES);
+
+  AggregationSpillManager(int numPartitions, int numGroupKeys, DataSchema 
spillSchema,
+      AggregationFunction[] aggFunctions) {
+    if (numPartitions <= 0 || numPartitions > 
Server.MAX_MSE_AGGREGATION_SPILL_PARTITIONS) {
+      throw new IllegalArgumentException(
+          "Number of spill partitions must be between 1 and " + 
Server.MAX_MSE_AGGREGATION_SPILL_PARTITIONS);
+    }
+    if (numGroupKeys < 0 || numGroupKeys > spillSchema.size()) {
+      throw new IllegalArgumentException("Invalid number of group keys: " + 
numGroupKeys);
+    }
+    _numPartitions = numPartitions;
+    _numGroupKeys = numGroupKeys;
+    _spillSchema = spillSchema;
+    _aggFunctions = aggFunctions;
+    try {
+      _spillDirectory = Files.createTempDirectory("pinot-aggregation-spill-");

Review Comment:
   **RISK: spill location is hard-coded to java.io.tmpdir with no byte budget 
and no orphan cleanup**
   
   Three separate operational gaps in one line.
   
   1. Location is not configurable. Every other on-disk Pinot feature (segment 
dir, minion task dir, consumer dir) has a config key. In containerized 
deployments java.io.tmpdir is very often a small tmpfs (i.e. RAM), which means 
'spill to disk' silently becomes 'spill to memory' and defeats the whole point. 
Add a `pinot.query.mse.aggregation.spill.dir` with a sensible default derived 
from the instance data dir.
   
   2. No cap on bytes written, per query or per server. Before this PR a 
runaway GROUP BY was bounded by numGroupsLimit. With a spill threshold set, the 
input executor's numGroupsLimit is raised to Integer.MAX_VALUE (see 
MultistageGroupByExecutor.forSpillInput), so the only bound left is local disk. 
N concurrent spilling queries can fill the volume and take down unrelated 
ingestion/query paths on that server. SPILLED_BYTES is tracked but never 
checked. Please add a per-query max spill bytes and ideally a server-wide 
budget.
   
   3. Orphans. Directories are only removed on close/cancel/error paths; a JVM 
crash or kill -9 leaves `pinot-aggregation-spill-*` behind forever. Worth a 
startup sweep of the spill root (easier once #1 gives you a dedicated 
directory).



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/AggregateOperator.java:
##########
@@ -268,12 +367,87 @@ private MseBlock.Eos consumeGroupBy() {
     MseBlock block = _input.nextBlock();
     while (block.isData()) {
       _groupByExecutor.processBlock((MseBlock.Data) block);
+      if (_spillThreshold > 0 && _groupByExecutor.getNumGroups() >= 
_spillThreshold) {
+        spillCurrentGroups();
+      }
       checkTerminationAndSampleUsage();
       block = _input.nextBlock();
     }
     return (MseBlock.Eos) block;
   }
 
+  private void spillCurrentGroups() {
+    assert _groupByExecutor != null;
+    if (_groupByExecutor.getNumGroups() == 0) {
+      return;
+    }
+    if (_spillManager == null) {
+      _spillManager =
+          new AggregationSpillManager(_spillPartitions, _groupKeyIds.length, 
getSpillSchema(), _aggFunctions);
+      _spillDirectory = _spillManager.getSpillDirectory();
+    }
+    AggregationSpillManager.SpillResult spillResult =
+        _spillManager.spill(_groupByExecutor.getIntermediateResultIterator());
+    _statMap.merge(StatKey.SPILL_COUNT, 1L);
+    _statMap.merge(StatKey.SPILLED_ROWS, spillResult.getRows());
+    _statMap.merge(StatKey.SPILLED_BYTES, spillResult.getBytes());
+    _groupByExecutor = newInputGroupByExecutor();
+  }
+
+  private DataSchema getSpillSchema() {
+    String[] columnNames = _resultSchema.getColumnNames().clone();
+    ColumnDataType[] columnDataTypes = 
_resultSchema.getColumnDataTypes().clone();
+    int numKeys = _groupKeyIds.length;
+    for (int i = 0; i < _aggFunctions.length; i++) {
+      columnDataTypes[numKeys + i] = _aggFunctions[i].getType() == 
AggregationFunctionType.ANYVALUE

Review Comment:
   **The ANYVALUE special case is a workaround for a bug in 
AnyValueAggregationFunction, and it does not generalize**
   
   This works around AnyValueAggregationFunction._resultType being resolved 
lazily in ensureResultType(BlockValSet): on a merge/intermediate executor that 
never sees raw data, getIntermediateResultColumnType() falls back to STRING, 
which would then mis-describe the spilled column.
   
   Two problems with fixing it here:
   
   - It is a denylist of exactly one function, matched by 
AggregationFunctionType. Any other function whose intermediate column type is 
lazily resolved or otherwise not a faithful description of what 
extractGroupByResult() returns will silently produce a wrong spill schema — and 
the failure will surface as a deserialization error or a wrong result, far from 
here. Nothing prevents the next such function from being added.
   - The actual contract being violated belongs to AggregationFunction, not to 
this operator.
   
   Preferred: fix AnyValue so getIntermediateResultColumnType() is honest (e.g. 
return OBJECT when _resultType is unknown), and drop the special case. If that 
is too invasive for this PR, add a TODO with an issue link explaining the 
invariant that any new function must satisfy.



##########
pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AnyValueAggregationFunction.java:
##########
@@ -309,7 +312,7 @@ private Object deserializeValue(ByteBuffer buffer) {
       case BIG_DECIMAL:
         return new BigDecimal(new String(deserializeVariableBytes(buffer), 
StandardCharsets.UTF_8));
       case BYTES:
-        return deserializeVariableBytes(buffer);
+        return new ByteArray(deserializeVariableBytes(buffer));

Review Comment:
   **SCOPE + TESTS: this is a real pre-existing bug fix for ANY_VALUE(BYTES) 
bundled into a spill PR, with no test of its own**
   
   Before this change, deserializeValue() returned a raw byte[] for the BYTES 
case, and extractFinalResult() does `(Comparable<?>) intermediateResult` — 
byte[] is not Comparable, so ANY_VALUE over a BYTES column would 
ClassCastException on any path that deserializes an intermediate result (i.e. 
every leaf -> intermediate stage hop, not just the new spill path). Switching 
to ByteArray fixes that and is the right call.
   
   But:
   - It is an independent, user-visible bug fix living inside a 1.9k-line 
feature PR. It deserves its own PR and its own changelog line so it is 
backportable without the spill feature.
   - AnyValueAggregationFunctionTest is untouched. The only coverage is 
testSpillRoundTripsBinaryAnyValue in AggregateOperatorTest, which exercises it 
through the spill path. Add a direct serialize/deserialize round-trip test for 
the BYTES case, and one that asserts extractFinalResult() no longer throws — 
that is the regression that actually shipped.
   - Please double-check callers that consume the deserialized value and may 
have been coded against byte[] (the in-memory type changes; the wire format 
does not).



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/AggregateOperator.java:
##########
@@ -131,23 +157,58 @@ public AggregateOperator(OpChainExecutionContext context, 
MultiStageOperator inp
     _comparator = comparator;
 
     _errorOnNumGroupsLimit = getErrorOnNumGroupsLimit(node.getNodeHint(), 
context.getOpChainMetadata());
+    _numGroupsLimit = 
MultistageGroupByExecutor.getNumGroupsLimit(_opChainMetadata, _nodeHint);
+    int spillThreshold = 0;
+    int spillPartitions = Server.DEFAULT_MSE_AGGREGATION_SPILL_PARTITIONS;
+    boolean spillEligible = !groupKeys.isEmpty() && !_leafReturnFinalResult

Review Comment:
   **Spill is silently disabled whenever group trim is active — which includes 
any GROUP BY with a pushed-down LIMIT**
   
   groupTrimSize is set to something other than Integer.MAX_VALUE whenever 
`node.getLimit() > 0 && minGroupTrimSize > 0`, and minGroupTrimSize defaults to 
5000. So for a plan where the planner pushes a LIMIT into the aggregate, 
spillEligible is false and the user's mseAggregationSpillThreshold is ignored 
without a word.
   
   That is a large fraction of real high-cardinality GROUP BY queries. 
Excluding trim mode from v1 is a fine scoping decision — the problem is that 
the exclusion is invisible. Combine with the QueryRunner gate comment: there 
should be exactly one place that reports 'spill was requested but not applied, 
because X'.
   
   Also: this reads a bit awkwardly as a nested if. If configuredSpillThreshold 
is null the partition option is never even validated, so `SET 
mseAggregationSpillPartitions = 999` with no threshold set is accepted silently 
instead of throwing. Consider validating options independently of eligibility.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/SendStatsPredicate.java:
##########
@@ -153,6 +165,11 @@ public boolean isSendStats() {
       return _sendStats;
     }
 
+    @Override
+    public boolean isClusterVersionCompatible() {

Review Comment:
   **Graceful degradation for undecodable stats has shipped since 1.4.0 on the 
mailbox leg. Only the (unreleased) stream leg is unprotected — close that gap 
instead of adding this flag**
   
   Before adding _clusterVersionCompatible, check what already ships. Two 
independent mechanisms already degrade rather than fail when stats cannot be 
handled, and both are in stable releases:
   
     Make stats system more resilient in MSE (#15312, 2025-03-25)
       -> BlockingMultiStreamConsumer.mergeStats: catch (Exception) -> 
LOGGER.warn + _stats = null
     Substitute TransferableBlock with MseBlock (#15245, 2025-04-14)
       -> MailboxSendOperator.sendEos: catch (Exception) around 
stats.serialize() -> List.of()
   
   Both are ancestors of release-1.4.0, release-1.5.0 and release-1.5.1 
(verified against the shipped sources, not just master).
   
   Why that matters here: an unknown StatKey ordinal on an older node raises 
ArrayIndexOutOfBoundsException at StatMap.java:267 (K key = keys[ordinal], 
unchecked). On the mailbox leg that AIOOBE is caught by mergeStats, the stage's 
stats are dropped, and the query completes. That is precisely the guarantee 
this PR's gate is trying to add, and it has been true of every release since 
1.4.0. This class's own javadoc agrees: the predicate was written for 1.3.0 and 
lower, and the Mode enum says ALWAYS is strictly better than SAFE once everyone 
is >= 1.4. Tying a brand-new feature to a compatibility mode whose stated 
purpose is a version two majors back needs a stronger justification than is 
given.
   
   The one leg where it genuinely does not hold is stream-mode reporting, and 
note that leg is itself unreleased — SubmitWithStream stats (#18458) and the 
plugin-type follow-up (#18736) are master-only, first stable release 1.6.0. 
There:
   
     MultiStageStatsTreeDecoder.decodeNode (line 79) wraps deserializeStatMap 
in catch (IOException) only.
     AIOOBE is unchecked, so it escapes decode(), and StreamingQuerySession 
only catches DecodeFailedException.
   
   So the accurate statement is: the spill stat keys are already safe on the 
mailbox path in every shipped release, and unsafe only on a path that has not 
shipped yet. That is a two-line fix in the stats layer, not a compatibility 
subsystem in the query layer:
   
   1. StatMap.merge(DataInput) (line 267, and line 370 for the sibling): 
bounds-check the ordinal and throw IllegalArgumentException rather than letting 
AIOOBE escape. MultiStageQueryStats.mergeUpstream ALREADY catches 
IllegalArgumentException | IllegalStateException, so this handles the mailbox 
case at the intended layer instead of via an outer catch-all.
   2. MultiStageStatsTreeDecoder.decodeNode: wrap RuntimeException into 
DecodeFailedException so the stream leg degrades the same way the mailbox leg 
already does.
   
   With those, appending StatKey constants stops being a hazard for every 
future stat, this flag and its plumbing are unnecessary, spill stops forcing 
operators onto SAFE mode, and SendStatsPredicate goes back to covering only the 
<= 1.3 case it was written for. Fixing it in the stats layer also pays forward: 
the next rolling upgrade that adds a StatKey (1.6 -> 1.7 in stream mode) hits 
exactly this gap.
   
   Caveat, for completeness: entries carry no length prefix, so a reader 
hitting an unknown ordinal cannot resume mid-buffer and must discard that whole 
stat buffer. Discarding the buffer is already what both call sites do, so it 
costs nothing. Per-entry type tags (see the note on the StatKey enum) would 
additionally allow skipping one unknown key, but that is an optimisation on 
top, not a prerequisite.



##########
pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java:
##########
@@ -808,6 +808,20 @@ public static class QueryOptionKey {
         /// Flush threshold for streaming group-by on MSE leaf stages.
         public static final String STREAMING_GROUP_BY_FLUSH_THRESHOLD = 
"streamingGroupByFlushThreshold";
 
+        /// Maximum number of groups retained by a keyed MSE aggregation 
executor before its intermediate states are
+        /// spilled to local disk. This option is honored only when the 
server-level aggregation spill gate is enabled.
+        /// An absent value disables spilling. The first spill version does 
not apply to global aggregation,
+        /// leaf-final-result, or group-trim modes.
+        public static final String MSE_AGGREGATION_SPILL_THRESHOLD = 
"mseAggregationSpillThreshold";

Review Comment:
   **EXTENSIBILITY 5/5: state in the javadoc that groups is a v1 proxy for 
memory, and link the follow-up issue**
   
   This javadoc defines the option as 'maximum number of groups retained ... 
before its intermediate states are spilled'. Accurate, but it reads as the 
intended long-term model rather than a v1 approximation, and that framing is 
what future contributors will build on.
   
   Group count is a proxy for the resource actually being protected, and a poor 
one: bytes-per-group ranges from ~8 (SUM) to a whole HashSet (DISTINCTCOUNT) to 
KBs (HLL/Theta) to unbounded (ANY_VALUE over STRING). That is why no user can 
pick a safe value from first principles — the right number depends on which 
aggregations the query uses.
   
   Ask, purely documentary: say here that the unit is groups, that it is a 
stand-in for a memory budget, and that a byte-based trigger is the intended 
direction. Link a tracking issue that records what it needs — an 
estimated-bytes-per-group method on AggregationFunction, which most functions 
can answer statically (8 for SUM, log2m-derived for HLL, nominalEntries-derived 
for Theta) at no per-row cost.
   
   This costs nothing in this PR and is what stops the next person from 
hardening the group-count model further, or from adding a second count-based 
knob, in the belief that counting groups was the design rather than the 
starting point. Together with the option rename (QueryOptionsUtils), the 
numGroupsLimit backstop, the split of the three overloaded meanings, and the 
shouldSpill() predicate, the byte-based version becomes an additive change 
instead of a breaking one.
   
   Also worth correcting here while you are in this javadoc: 'The first spill 
version does not apply to ... group-trim modes' understates it. Trim is active 
whenever the planner pushes a LIMIT into the aggregate and minGroupTrimSize > 0 
(default 5000), so spill is silently off for a large share of real GROUP BY 
queries. See the separate note on the eligibility check.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/AggregationSpillManager.java:
##########
@@ -0,0 +1,428 @@
+/**
+ * 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.operator;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.io.BufferedInputStream;
+import java.io.DataInputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import org.apache.pinot.common.datablock.DataBlock;
+import org.apache.pinot.common.datablock.DataBlockUtils;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.core.query.aggregation.function.AggregationFunction;
+import org.apache.pinot.query.runtime.blocks.MseBlock;
+import org.apache.pinot.query.runtime.blocks.RowHeapDataBlock;
+import org.apache.pinot.query.runtime.blocks.SerializedDataBlock;
+import org.apache.pinot.spi.query.QueryThreadContext;
+import org.apache.pinot.spi.utils.CommonConstants.Server;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/// Manages hash-partitioned aggregation spill files for one operator. The 
caller must finish reading before calling
+/// [#close()], which recursively removes the operator-scoped directory. This 
class is not thread-safe.
+@SuppressWarnings("rawtypes")
+class AggregationSpillManager implements AutoCloseable {

Review Comment:
   **SCOPE: #12080 is an SSE issue asking for two features; this PR delivers 
MSE spill only, so `Close: #12080` over-claims twice**
   
   Issue #12080 is 'Support parallel combine and disk spill for groupBy 
execution'. Its text is explicitly about the SSE path — the reporter was 
reading GroupByExecutor (org.apache.pinot.core.query.aggregation.groupby), 
compared it to Druid's GroupByV2Engine, and asked for two things: (1) spill to 
disk for the merging buffer (Druid SpillingGrouper) and (2) parallel combine 
when merging sorted aggregation results (Druid ParallelCombiner, with the 
combining-thread-tree diagram).
   
   This PR implements neither of those literally: it adds spill to 
MultistageGroupByExecutor / AggregateOperator (MSE only, pinot-query-runtime), 
and does no parallel combine at all. Please downgrade `Close: #12080` to 
'Partially addresses #12080' or link a narrower MSE-scoped issue, so the SSE 
request and the parallel-combine request are not silently closed by an 
unrelated change.
   
   On whether this could later serve SSE, since that is the natural follow-up 
question — three obstacles, increasing in difficulty:
   
   1. Module direction (mechanical). This class is package-private in 
org.apache.pinot.query.runtime.operator and its I/O is expressed in MseBlock / 
RowHeapDataBlock / SerializedDataBlock. pinot-core does not depend on 
pinot-query-runtime and cannot (the dependency runs the other way), so SSE 
cannot reach it. The serialization core is already portable — DataBlock lives 
in pinot-common — so sharing it means moving the class down and swapping the 
MseBlock wrappers for a neutral row sink. If SSE reuse is plausibly on the 
roadmap, factoring the file I/O and partitioning away from the MseBlock types 
now is cheap; retrofitting after it ships is not.
   
   2. Different attachment point. SSE has no single hash table per operator. 
Per-segment GroupByExecutors feed GroupByCombineOperator, which merges into a 
server-level IndexedTable — that is the structure that actually grows, so spill 
would attach there, not to the per-segment executors.
   
   3. Concurrency, which is the real blocker. ConcurrentIndexedTable is a 
ConcurrentHashMap written by many segment-processing threads at once, whereas 
this class documents 'This class is not thread-safe' and the MSE design leans 
on AggregateOperator being single-threaded per worker: it can drain the table, 
swap in a fresh one, and carry on. Under concurrent writers that needs either a 
stop-the-world barrier across all combine threads or per-thread tables that 
spill independently — which is in fact what Druid does, SpillingGrouper per 
processing thread plus ParallelCombiner to merge them. So an SSE version is a 
different design, not a port.
   
   Worth noting too that SSE already answers 'too many groups' by trimming 
(numGroupsLimit, minSegmentGroupTrimSize, minServerGroupTrimSize, TableResizer, 
and ConcurrentIndexedTable's own _trimThreshold) and its group-by results above 
the limit are documented as approximate. Spill in SSE would therefore be about 
making approximate results exact, which is a larger product decision than the 
memory-safety framing used here. None of that blocks this PR — it is an 
argument for scoping the issue reference accurately rather than for widening 
the change.



##########
pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/QueryRunnerTest.java:
##########
@@ -187,6 +190,28 @@ public void testSelfStatsAreNotNegative() {
     Assert.assertTrue(checked > 0, "expected some self stats to check, got: " 
+ statsTree);
   }
 
+  @Test
+  public void testMSEAggregationSpill() {

Review Comment:
   **TESTS: unit coverage is genuinely strong; the end-to-end gap is 
object/sketch intermediates and MV keys**
   
   Credit where due — AggregateOperatorTest covers intermediate/final agg 
modes, composite null keys, filters, hash collisions, oversized restore 
partitions, group limits, and cleanup on all four terminal paths. That is more 
than most feature PRs land with.
   
   Remaining gaps, roughly in priority order:
   
   1. No end-to-end coverage for DISTINCTCOUNT / HLL / Theta-sketch style 
aggregations. These are the aggregations whose intermediate state is large — 
i.e. the actual reason a user enables spill — and they are the ones most likely 
to expose a serialization problem in the OBJECT column path. 
testDistinctGroupBySpill is a bare DISTINCT with no agg functions, so it does 
not cover this. AVG (AvgPair) is the only object intermediate exercised here.
   2. No multi-value / array group key anywhere, despite deepHashCode going to 
some trouble to handle arrays.
   3. No test that the restored output is correct when a single spill file 
contains records written across several spill rounds for the same key 
(testGroupBySpillWritesMultipleBoundedRecords is about record count, not 
cross-round merge of one key).
   4. Nothing asserts the spill directory is gone after a normal successful 
drain in the e2e path — only the unit tests check that.
   
   Also note this test asserts spillCount/spilledRows/spilledBytes are positive 
but never asserts an upper bound, so a regression that spills on every block 
would still pass.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/AggregationSpillManager.java:
##########
@@ -0,0 +1,428 @@
+/**
+ * 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.operator;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.io.BufferedInputStream;
+import java.io.DataInputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import org.apache.pinot.common.datablock.DataBlock;
+import org.apache.pinot.common.datablock.DataBlockUtils;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.core.query.aggregation.function.AggregationFunction;
+import org.apache.pinot.query.runtime.blocks.MseBlock;
+import org.apache.pinot.query.runtime.blocks.RowHeapDataBlock;
+import org.apache.pinot.query.runtime.blocks.SerializedDataBlock;
+import org.apache.pinot.spi.query.QueryThreadContext;
+import org.apache.pinot.spi.utils.CommonConstants.Server;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/// Manages hash-partitioned aggregation spill files for one operator. The 
caller must finish reading before calling
+/// [#close()], which recursively removes the operator-scoped directory. This 
class is not thread-safe.
+@SuppressWarnings("rawtypes")
+class AggregationSpillManager implements AutoCloseable {
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(AggregationSpillManager.class);
+  private static final String SPILL_FILE_PREFIX = "partition-";
+  private static final String SPILL_FILE_SUFFIX = ".spill";
+  private static final String SPILL_SCOPE = "AggregationSpillManager#spill";
+  private static final String RESTORE_SCOPE = 
"AggregationSpillManager#consumePartition";
+  private static final int MAX_BUFFERED_ROWS = 1024;
+  private static final int MAX_BUFFERED_PARTITIONS = 8;
+
+  private final int _numPartitions;
+  private final int _numGroupKeys;
+  private final DataSchema _spillSchema;
+  private final AggregationFunction[] _aggFunctions;
+  private final Path _spillDirectory;
+  private final Map<Integer, FileChannel> _spillWriters = new HashMap<>();
+  private final ByteBuffer _recordLengthBuffer = 
ByteBuffer.allocate(Integer.BYTES);
+
+  AggregationSpillManager(int numPartitions, int numGroupKeys, DataSchema 
spillSchema,
+      AggregationFunction[] aggFunctions) {
+    if (numPartitions <= 0 || numPartitions > 
Server.MAX_MSE_AGGREGATION_SPILL_PARTITIONS) {
+      throw new IllegalArgumentException(
+          "Number of spill partitions must be between 1 and " + 
Server.MAX_MSE_AGGREGATION_SPILL_PARTITIONS);
+    }
+    if (numGroupKeys < 0 || numGroupKeys > spillSchema.size()) {
+      throw new IllegalArgumentException("Invalid number of group keys: " + 
numGroupKeys);
+    }
+    _numPartitions = numPartitions;
+    _numGroupKeys = numGroupKeys;
+    _spillSchema = spillSchema;
+    _aggFunctions = aggFunctions;
+    try {
+      _spillDirectory = Files.createTempDirectory("pinot-aggregation-spill-");
+    } catch (IOException e) {
+      throw new UncheckedIOException("Failed to create aggregation spill 
directory", e);
+    }
+  }
+
+  SpillResult spill(Iterator<Object[]> rows) {
+    List<Object[]>[] partitions = createPartitions();
+    boolean[] touched = new boolean[_numPartitions];
+    int[] touchedPartitions = new int[_numPartitions];
+    int numTouchedPartitions = 0;
+    int numRows = 0;
+    int numBufferedRows = 0;
+    long serializedBytes = 0;
+    int numRowsProcessed = 0;
+    int maxBufferedRows = MAX_BUFFERED_ROWS * Math.min(_numPartitions, 
MAX_BUFFERED_PARTITIONS);
+    while (rows.hasNext()) {
+      
QueryThreadContext.checkTerminationAndSampleUsagePeriodically(numRowsProcessed++,
 SPILL_SCOPE);
+      Object[] row = rows.next();
+      int partitionId = getPartition(row);
+      List<Object[]> partition = partitions[partitionId];
+      if (partition == null) {
+        partition = new ArrayList<>();
+        partitions[partitionId] = partition;
+      }
+      if (!touched[partitionId]) {
+        touched[partitionId] = true;
+        touchedPartitions[numTouchedPartitions++] = partitionId;
+      }
+      partition.add(row);
+      numRows++;
+      numBufferedRows++;
+      if (partition.size() == MAX_BUFFERED_ROWS) {
+        serializedBytes += appendPartition(partitionId, partition);
+        partition.clear();
+        numBufferedRows -= MAX_BUFFERED_ROWS;
+      }
+      if (numBufferedRows >= maxBufferedRows) {
+        serializedBytes += flushPartitions(partitions, touched, 
touchedPartitions, numTouchedPartitions);
+        numTouchedPartitions = 0;
+        numBufferedRows = 0;
+      }
+    }
+    if (numTouchedPartitions > 0) {
+      serializedBytes += flushPartitions(partitions, touched, 
touchedPartitions, numTouchedPartitions);
+    }
+    return new SpillResult(numRows, serializedBytes);
+  }
+
+  boolean hasPartition(int partitionId) {
+    checkPartitionId(partitionId);
+    return Files.exists(getSpillFile(partitionId));
+  }
+
+  /// Consumes all records from a partition and deletes its file after the 
attempt, including when reading or
+  /// processing fails.
+  void consumePartition(int partitionId, Consumer<MseBlock.Data> consumer) {
+    checkPartitionId(partitionId);
+    Path spillFile = getSpillFile(partitionId);
+    if (!Files.exists(spillFile)) {
+      return;
+    }
+
+    RuntimeException failure = null;
+    closeSpillWriter(partitionId);
+    try (DataInputStream input =
+        new DataInputStream(new 
BufferedInputStream(Files.newInputStream(spillFile)))) {
+      long remainingBytes = Files.size(spillFile);
+      int numRecordsRead = 0;
+      while (remainingBytes > 0) {
+        
QueryThreadContext.checkTerminationAndSampleUsagePeriodically(numRecordsRead++, 
RESTORE_SCOPE);
+        if (remainingBytes < Integer.BYTES) {
+          throw new IOException("Truncated spill record length in: " + 
spillFile);
+        }
+        int recordLength = input.readInt();
+        remainingBytes -= Integer.BYTES;
+        if (recordLength < 0 || recordLength > remainingBytes) {
+          throw new IOException("Invalid spill record length " + recordLength 
+ " in: " + spillFile);
+        }
+        byte[] bytes = input.readNBytes(recordLength);
+        if (bytes.length != recordLength) {
+          throw new IOException("Truncated spill record in: " + spillFile);
+        }
+        remainingBytes -= recordLength;
+        ByteBuffer buffer = ByteBuffer.wrap(bytes);
+        DataBlock dataBlock = DataBlockUtils.readFrom(buffer);
+        if (buffer.hasRemaining()) {
+          throw new IOException("Trailing bytes in aggregation spill record: " 
+ spillFile);
+        }
+        consumer.accept(new SerializedDataBlock(dataBlock));
+      }
+    } catch (IOException e) {
+      failure = new UncheckedIOException("Failed to read aggregation spill 
partition: " + partitionId, e);
+      throw failure;
+    } catch (RuntimeException e) {
+      failure = e;
+      throw e;
+    } finally {
+      try {
+        deleteSpillFile(spillFile);
+      } catch (RuntimeException e) {
+        if (failure != null) {
+          failure.addSuppressed(e);
+        } else {
+          LOGGER.warn("Failed to delete consumed aggregation spill partition; 
close will retry: {}", spillFile, e);
+        }
+      }
+    }
+  }
+
+  int getNumPartitions() {
+    return _numPartitions;
+  }
+
+  Path getSpillDirectory() {
+    return _spillDirectory;
+  }
+
+  @VisibleForTesting
+  int getNumOpenSpillWriters() {
+    return _spillWriters.size();
+  }
+
+  @Override
+  public void close() {
+    if (!Files.exists(_spillDirectory)) {
+      return;
+    }
+    RuntimeException failure = null;
+    try {
+      closeSpillWriters();
+    } catch (RuntimeException e) {
+      failure = e;
+    }
+    try {
+      Files.walkFileTree(_spillDirectory, new SimpleFileVisitor<>() {
+        @Override
+        public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
+            throws IOException {
+          Files.delete(file);
+          return FileVisitResult.CONTINUE;
+        }
+
+        @Override
+        public FileVisitResult postVisitDirectory(Path directory, IOException 
exception)
+            throws IOException {
+          if (exception != null) {
+            throw exception;
+          }
+          Files.delete(directory);
+          return FileVisitResult.CONTINUE;
+        }
+      });
+    } catch (IOException e) {
+      RuntimeException cleanupFailure =
+          new UncheckedIOException("Failed to delete aggregation spill 
directory", e);
+      if (failure != null) {
+        failure.addSuppressed(cleanupFailure);
+      } else {
+        failure = cleanupFailure;
+      }
+    }
+    if (failure != null) {
+      throw failure;
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  private List<Object[]>[] createPartitions() {
+    return new List[_numPartitions];
+  }
+
+  private long flushPartitions(List<Object[]>[] partitions, boolean[] touched, 
int[] touchedPartitions,
+      int numTouchedPartitions) {
+    long serializedBytes = 0;
+    for (int i = 0; i < numTouchedPartitions; i++) {
+      QueryThreadContext.checkTerminationAndSampleUsagePeriodically(i, 
SPILL_SCOPE);
+      int partitionId = touchedPartitions[i];
+      List<Object[]> partition = partitions[partitionId];
+      if (!partition.isEmpty()) {
+        serializedBytes += appendPartition(partitionId, partition);
+        partition.clear();
+      }
+      touched[partitionId] = false;
+    }
+    return serializedBytes;
+  }
+
+  private int getPartition(Object[] row) {

Review Comment:
   **Document the invariant this partitioner depends on: hash agreement across 
the serialization round trip**
   
   Correctness of the whole scheme rests on one unstated invariant: two rows 
that the merge-side GroupIdGenerator will consider the same group must land in 
the same partition here. Partitioning is computed pre-serialization on the 
in-memory representation; grouping is decided post-serialization on the 
deserialized representation.
   
   I walked the cases and did not find a break (over-collision is harmless, and 
deepHashCode is content-based for the array types where the group map is also 
content-based), but this is the kind of invariant that a future change to 
DataBlock type coercion, to BOOLEAN/TIMESTAMP handling, or to the group-key 
generators can silently violate — and the symptom is duplicate group rows in 
the output, not an exception.
   
   Please state the invariant in the javadoc of getPartition/deepHashCode, and 
add a test with a multi-value (array) group key end-to-end through spill. 
AggregationSpillManagerTest.testDeepHashCode covers the hash function in 
isolation but nothing covers an array-valued group key surviving the round trip.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/AggregateOperator.java:
##########
@@ -131,23 +157,58 @@ public AggregateOperator(OpChainExecutionContext context, 
MultiStageOperator inp
     _comparator = comparator;
 
     _errorOnNumGroupsLimit = getErrorOnNumGroupsLimit(node.getNodeHint(), 
context.getOpChainMetadata());
+    _numGroupsLimit = 
MultistageGroupByExecutor.getNumGroupsLimit(_opChainMetadata, _nodeHint);
+    int spillThreshold = 0;
+    int spillPartitions = Server.DEFAULT_MSE_AGGREGATION_SPILL_PARTITIONS;
+    boolean spillEligible = !groupKeys.isEmpty() && !_leafReturnFinalResult
+        && groupTrimSize == Integer.MAX_VALUE && 
QueryOptionsUtils.isMSEAggregationSpillEnabled(_opChainMetadata);
+    if (spillEligible) {
+      Integer configuredSpillThreshold = 
QueryOptionsUtils.getMSEAggregationSpillThreshold(_opChainMetadata);
+      if (configuredSpillThreshold != null) {
+        spillThreshold = configuredSpillThreshold;
+        Integer configuredSpillPartitions = 
QueryOptionsUtils.getMSEAggregationSpillPartitions(_opChainMetadata);
+        if (configuredSpillPartitions != null) {
+          spillPartitions = configuredSpillPartitions;
+        }
+      }
+    }
+    _spillThreshold = spillThreshold;
+    _spillPartitions = spillPartitions;

Review Comment:
   **EXTENSIBILITY 2/5: keep numGroupsLimit as a hard ceiling instead of 
replacing it — it is also a real regression today**
   
   Not asking to change the trigger unit in this PR. Asking that it not become 
the ONLY memory bound, because that is both a regression now and the thing that 
makes a later byte-based trigger awkward.
   
   Today, without spill, in-memory groups are bounded by numGroupsLimit: 
MultistageGroupByExecutor hands it to the GroupIdGenerator, which returns 
INVALID_ID once reached and stops creating groups.
   
   With spill on, forSpillInput passes Integer.MAX_VALUE as numGroupsLimit, so 
the input executor is unbounded and the only bound left is _spillThreshold. 
Nothing validates the threshold against numGroupsLimit — 
getMSEAggregationSpillThreshold only checks positive. So `SET 
mseAggregationSpillThreshold = 100000000` is accepted and that operator now 
holds up to 100M groups where it previously stopped at numGroupsLimit. For SUM 
that is merely large; for DISTINCTCOUNT or an HLL/Theta sketch it is an OOM on 
a query that used to return truncated-but-safe results. The OOM-prevention 
feature can cause an OOM through a knob whose safe value the user cannot 
derive, because it depends on per-group state size.
   
   Ask: keep numGroupsLimit enforced on the input executor as a backstop rather 
than replacing it, so spill triggers at min(spillThreshold, numGroupsLimit).
   
   Why this is the key door-opener: numGroupsLimit then keeps meaning 'the most 
groups any one hash table may hold', independent of what unit the spill trigger 
uses. A byte-based trigger can be added later as an additional, earlier trigger 
without renegotiating the memory ceiling or changing what numGroupsLimit means. 
If instead this PR establishes 'spillThreshold replaces numGroupsLimit', every 
future change to the trigger is also a change to the operator's safety 
semantics.
   
   Second-best if you disagree: reject spillThreshold > numGroupsLimit at parse 
time with a clear message. That fixes the regression but still leaves the two 
concepts fused.



##########
pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java:
##########
@@ -549,6 +549,29 @@ public static Integer 
getStreamingGroupByFlushThreshold(Map<String, String> quer
     return 
checkedParseIntNonNegative(QueryOptionKey.STREAMING_GROUP_BY_FLUSH_THRESHOLD, 
value);
   }
 
+  @Nullable
+  public static Integer getMSEAggregationSpillThreshold(Map<String, String> 
queryOptions) {

Review Comment:
   **EXTENSIBILITY 1/5: name the option for its unit now — a query option is 
the one surface you cannot migrate later**
   
   Not asking to change the trigger in this PR. A group count is a defensible 
v1 and it matches numGroupsLimit. The ask is only that it not block a 
byte-based trigger later, and the option name is where that gets decided, 
because query options are the hardest surface to change once released.
   
   mseAggregationSpillThreshold reads unit-free but means groups. If the 
trigger later becomes bytes you get two bad choices: silently redefine the unit 
— every existing SET breaks, and breaks quietly, since the same number means 
something roughly 1000x different — or add mseAggregationSpillMemoryThreshold 
and live with two knobs and undefined precedence.
   
   Rename now to say what it counts: mseAggregationSpillMaxGroups (or 
mseAggregationSpillGroupThreshold), leaving mseAggregationSpillMaxBytes free. 
The two can then coexist with obvious semantics — spill when either is exceeded 
— and the migration needs no deprecation cycle because nothing has shipped yet. 
Mirror it in the CommonConstants javadoc.
   
   While here: getMSEAggregationSpillPartitions is validated (1..64) but the 
threshold only goes through checkedParseIntPositive, so it has no upper bound 
at all. That is what lets it silently exceed numGroupsLimit — see EXTENSIBILITY 
2/5 on AggregateOperator.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/AggregateOperator.java:
##########
@@ -268,12 +367,87 @@ private MseBlock.Eos consumeGroupBy() {
     MseBlock block = _input.nextBlock();
     while (block.isData()) {
       _groupByExecutor.processBlock((MseBlock.Data) block);
+      if (_spillThreshold > 0 && _groupByExecutor.getNumGroups() >= 
_spillThreshold) {

Review Comment:
   **EXTENSIBILITY 4/5: put the spill decision behind one predicate — a byte 
budget cannot be checked once per input block**
   
   The trigger is inlined here as `getNumGroups() >= _spillThreshold`, 
evaluated once per input block after processBlock returns. Two things are fused 
in that one line: the POLICY (what counts as too big) and the CHECK FREQUENCY 
(once per block).
   
   A group count tolerates block-granularity checks — overshoot is bounded by 
rows-per-block. A byte budget does not: one DISTINCTCOUNT or sketch block can 
add tens of MB of per-group state that a group count would barely register, so 
a byte-based trigger needs to be evaluated inside the block loop, not after it. 
Changing that later means touching this operator, MultistageGroupByExecutor, 
and the overshoot assumptions in restoreSpillPartition together.
   
   Ask: move the decision to a single predicate owned by the executor, 
something like
   
     if (_spillEnabled && _groupByExecutor.shouldSpill()) { 
spillCurrentGroups(); }
   
   The executor already owns the hash table, the group count, and the result 
holders, so it is the only place that could ever cheaply estimate retained 
bytes. With the predicate in place, the policy can change and the check can 
move inside processBlock without the operator noticing.
   
   This is a small refactor now and the difference between a contained change 
and a cross-cutting one later.



##########
pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/AggregateOperator.java:
##########
@@ -131,23 +157,58 @@ public AggregateOperator(OpChainExecutionContext context, 
MultiStageOperator inp
     _comparator = comparator;
 
     _errorOnNumGroupsLimit = getErrorOnNumGroupsLimit(node.getNodeHint(), 
context.getOpChainMetadata());
+    _numGroupsLimit = 
MultistageGroupByExecutor.getNumGroupsLimit(_opChainMetadata, _nodeHint);
+    int spillThreshold = 0;
+    int spillPartitions = Server.DEFAULT_MSE_AGGREGATION_SPILL_PARTITIONS;
+    boolean spillEligible = !groupKeys.isEmpty() && !_leafReturnFinalResult
+        && groupTrimSize == Integer.MAX_VALUE && 
QueryOptionsUtils.isMSEAggregationSpillEnabled(_opChainMetadata);
+    if (spillEligible) {
+      Integer configuredSpillThreshold = 
QueryOptionsUtils.getMSEAggregationSpillThreshold(_opChainMetadata);
+      if (configuredSpillThreshold != null) {
+        spillThreshold = configuredSpillThreshold;
+        Integer configuredSpillPartitions = 
QueryOptionsUtils.getMSEAggregationSpillPartitions(_opChainMetadata);
+        if (configuredSpillPartitions != null) {
+          spillPartitions = configuredSpillPartitions;
+        }
+      }
+    }
+    _spillThreshold = spillThreshold;
+    _spillPartitions = spillPartitions;
 
     // Initialize the appropriate executor.
-    AggregateNode.AggType aggType = node.getAggType();
     // TODO: Allow leaf return final result for non-group-by queries
-    boolean leafReturnFinalResult = node.isLeafReturnFinalResult();
     if (groupKeys.isEmpty()) {
       _aggregationExecutor =
-          new MultistageAggregationExecutor(_aggFunctions, filterArgIds, 
maxFilterArgId, aggType, _resultSchema);
+          new MultistageAggregationExecutor(_aggFunctions, _filterArgIds, 
_maxFilterArgId, _aggType, _resultSchema);
       _groupByExecutor = null;
     } else {
-      _groupByExecutor =
-          new MultistageGroupByExecutor(getGroupKeyIds(groupKeys), 
_aggFunctions, filterArgIds, maxFilterArgId, aggType,
-              leafReturnFinalResult, _resultSchema, 
context.getOpChainMetadata(), node.getNodeHint());
+      _groupByExecutor = newInputGroupByExecutor();
       _aggregationExecutor = null;
     }
   }
 
+  private MultistageGroupByExecutor newInputGroupByExecutor() {
+    if (_spillThreshold > 0) {
+      return MultistageGroupByExecutor.forSpillInput(_groupKeyIds, 
_aggFunctions, _filterArgIds, _maxFilterArgId,
+          _aggType, _resultSchema, _opChainMetadata, _nodeHint, 
_spillThreshold);
+    }
+    return new MultistageGroupByExecutor(_groupKeyIds, _aggFunctions, 
_filterArgIds, _maxFilterArgId, _aggType,
+        _leafReturnFinalResult, _resultSchema, _opChainMetadata, _nodeHint);
+  }
+
+  private MultistageGroupByExecutor newSpillMergeGroupByExecutor() {
+    AggregateNode.AggType mergeAggType =
+        _aggType.isOutputIntermediateFormat() ? 
AggregateNode.AggType.INTERMEDIATE : AggregateNode.AggType.FINAL;
+    int[] spillGroupKeyIds = new int[_groupKeyIds.length];
+    for (int i = 0; i < spillGroupKeyIds.length; i++) {
+      spillGroupKeyIds[i] = i;
+    }
+    int expectedPartitionGroups =

Review Comment:
   **EXTENSIBILITY 3/5: _spillThreshold means three different things — split 
them before one of them changes unit**
   
   _spillThreshold is currently load-bearing for three unrelated decisions:
   
     1. spill trigger            line 370   getNumGroups() >= _spillThreshold
     2. restore failure limit    line 418   executor.getNumGroups() > 
_spillThreshold -> SERVER_RESOURCE_LIMIT_EXCEEDED
     3. merge-table sizing hint  line 206   expectedPartitionGroups = 
ceil(threshold / partitions)
   
   Only (1) is a natural fit for a byte budget. (2) is a correctness-visible 
cap on a restored partition and (3) is an initial-capacity hint for a hash 
table — both want group counts, and (3) specifically wants a count because that 
is what maxInitialResultHolderCapacity takes.
   
   So the day the trigger becomes bytes, all three have to be untangled at 
once, in code that by then has shipped and has semantics users depend on. Doing 
it now is nearly free: keep one user-facing knob, derive three named internal 
fields from it, and let them diverge later.
   
     _spillTriggerMaxGroups      // (1) the knob; a byte budget can be added 
alongside it
     _restorePartitionMaxGroups  // (2) correctness cap, stays a count
     _mergeTableInitialCapacity  // (3) sizing hint, stays a count
   
   Separately on (3): expectedPartitionGroups assumes a uniform hash 
distribution. That is fine for an initial-capacity hint, but the comment should 
say it is a hint and not a bound, so nobody later mistakes it for one.



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

To unsubscribe, e-mail: [email protected]

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


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

Reply via email to