This is an automated email from the ASF dual-hosted git repository.
gortiz 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 e83b59f0d8e Release MSE operator buffers on error and cancel, not only
on end of stream (#19462)
e83b59f0d8e is described below
commit e83b59f0d8e8c39be3138c5629c531471b63f73f
Author: Gonzalo Ortiz Jaureguizar <[email protected]>
AuthorDate: Mon Sep 7 12:45:31 2026 +0200
Release MSE operator buffers on error and cancel, not only on end of stream
(#19462)
---
.../query/runtime/operator/AggregateOperator.java | 33 +-
.../query/runtime/operator/AsofJoinOperator.java | 7 +-
.../query/runtime/operator/BaseJoinOperator.java | 6 +-
.../query/runtime/operator/HashJoinOperator.java | 7 +-
.../pinot/query/runtime/operator/LeafOperator.java | 9 +-
.../query/runtime/operator/MultiStageOperator.java | 60 ++
.../runtime/operator/NonEquiJoinOperator.java | 13 +-
.../query/runtime/operator/RepeatOperator.java | 26 +
.../pinot/query/runtime/operator/SortOperator.java | 37 +-
.../operator/SortedMailboxReceiveOperator.java | 27 +-
.../runtime/operator/set/BinarySetOperator.java | 19 +-
.../query/runtime/operator/set/UnionOperator.java | 18 +-
.../plan/pipeline/PipelineBreakerOperator.java | 10 +
.../operator/OperatorBufferReleaseTest.java | 640 +++++++++++++++++++++
.../operator/SortedMailboxReceiveOperatorTest.java | 34 ++
.../operator/set/SetOperatorBufferReleaseTest.java | 228 ++++++++
16 files changed, 1141 insertions(+), 33 deletions(-)
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/AggregateOperator.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/AggregateOperator.java
index 0e7501c635a..4717d5a2b96 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/AggregateOperator.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/AggregateOperator.java
@@ -50,6 +50,7 @@ import org.apache.pinot.query.planner.plannode.AggregateNode;
import org.apache.pinot.query.planner.plannode.PlanNode;
import org.apache.pinot.query.runtime.blocks.MseBlock;
import org.apache.pinot.query.runtime.blocks.RowHeapDataBlock;
+import org.apache.pinot.query.runtime.blocks.SuccessMseBlock;
import org.apache.pinot.query.runtime.operator.utils.SortUtils;
import org.apache.pinot.query.runtime.plan.OpChainExecutionContext;
import org.apache.pinot.spi.exception.QueryErrorCode;
@@ -71,6 +72,9 @@ public class AggregateOperator extends MultiStageOperator {
private final MultiStageOperator _input;
private final DataSchema _resultSchema;
private final AggregationFunction<?, ?>[] _aggFunctions;
+ /// Whether this operator groups. Decides which of the two executors below
is in use; kept separate from them so
+ /// that releasing an executor cannot change the mode.
+ private final boolean _isGroupBy;
@Nullable
private MultistageAggregationExecutor _aggregationExecutor;
@Nullable
@@ -136,7 +140,8 @@ public class AggregateOperator extends MultiStageOperator {
AggregateNode.AggType aggType = node.getAggType();
// TODO: Allow leaf return final result for non-group-by queries
boolean leafReturnFinalResult = node.isLeafReturnFinalResult();
- if (groupKeys.isEmpty()) {
+ _isGroupBy = !groupKeys.isEmpty();
+ if (!_isGroupBy) {
_aggregationExecutor =
new MultistageAggregationExecutor(_aggFunctions, filterArgIds,
maxFilterArgId, aggType, _resultSchema);
_groupByExecutor = null;
@@ -201,20 +206,40 @@ public class AggregateOperator extends MultiStageOperator
{
if (_eosBlock != null) {
return _eosBlock;
}
- MseBlock.Eos finalBlock = _aggregationExecutor != null ?
consumeAggregation() : consumeGroupBy();
+ MseBlock.Eos finalBlock = _isGroupBy ? consumeGroupBy() :
consumeAggregation();
_eosBlock = finalBlock;
if (finalBlock.isError()) {
+ // The upstream failed, so no result will ever be produced from what we
accumulated: drop it right away instead
+ // of waiting for close()/cancel().
+ releaseBuffers();
return finalBlock;
}
MseBlock mseBlock = produceAggregatedBlock();
+ releaseBuffers();
+ return mseBlock;
+ }
+
+ /// Drops the executors, and with them the group-by hash maps and the
aggregate result holders they own. Marks the
+ /// operator finished at the same time, so that a later [#getNextBlock()]
returns the cached end of stream instead
+ /// of trying to consume the input again with a dropped executor.
+ @Override
+ protected void releaseBuffers() {
_aggregationExecutor = null;
_groupByExecutor = null;
- return mseBlock;
+ if (_eosBlock == null) {
+ _eosBlock = SuccessMseBlock.INSTANCE;
+ }
+ }
+
+ @Override
+ protected boolean hasBufferedState() {
+ return _aggregationExecutor != null || _groupByExecutor != null;
}
private MseBlock produceAggregatedBlock() {
- if (_aggregationExecutor != null) {
+ if (!_isGroupBy) {
+ assert _aggregationExecutor != null;
return new RowHeapDataBlock(_aggregationExecutor.getResult(),
_resultSchema, _aggFunctions);
} else {
assert _groupByExecutor != null;
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/AsofJoinOperator.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/AsofJoinOperator.java
index b072f272c1c..f577782607e 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/AsofJoinOperator.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/AsofJoinOperator.java
@@ -91,10 +91,15 @@ public class AsofJoinOperator extends BaseJoinOperator {
}
@Override
- protected void onEosProduced() {
+ protected void releaseBuffers() {
_rightTable = null; // Release memory in case we keep the operator around
for a while
}
+ @Override
+ protected boolean hasBufferedState() {
+ return _rightTable != null;
+ }
+
@Override
protected List<Object[]> buildJoinedRows(MseBlock.Data leftBlock) {
assert _rightTable != null : "Right table should not be null when building
joined rows";
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/BaseJoinOperator.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/BaseJoinOperator.java
index cf481837b12..c6fcdbd390e 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/BaseJoinOperator.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/BaseJoinOperator.java
@@ -191,13 +191,13 @@ public abstract class BaseJoinOperator extends
MultiStageOperator {
LOGGER.trace("Returning {} for join operator", mseBlock);
if (mseBlock.isEos()) {
_eos = (MseBlock.Eos) mseBlock;
- onEosProduced();
+ // The right table is dead weight from here on, whether this is a
successful end of stream or an error block
+ // propagated from the left input. Release it now rather than waiting
for close()/cancel() to do it.
+ releaseBuffers();
}
return mseBlock;
}
- protected abstract void onEosProduced();
-
protected void buildRightTable() {
LOGGER.trace("Building right table for join operator");
long startTime = System.currentTimeMillis();
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/HashJoinOperator.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/HashJoinOperator.java
index 1a6ea42c2f7..9a2dc958a78 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/HashJoinOperator.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/HashJoinOperator.java
@@ -157,12 +157,17 @@ public class HashJoinOperator extends BaseJoinOperator {
}
@Override
- protected void onEosProduced() {
+ protected void releaseBuffers() {
_rightTable = null;
_matchedRightRows = null;
_nullKeyRightRows = null;
}
+ @Override
+ protected boolean hasBufferedState() {
+ return _rightTable != null || _matchedRightRows != null ||
_nullKeyRightRows != null;
+ }
+
@Override
protected List<Object[]> buildJoinedRows(MseBlock.Data leftBlock) {
assert _rightTable != null : "Right table should not be null when building
joined rows";
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/LeafOperator.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/LeafOperator.java
index d80adc418c8..efa846b409e 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/LeafOperator.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/LeafOperator.java
@@ -320,14 +320,17 @@ public class LeafOperator extends MultiStageOperator {
cancelSseTasks();
}
+ /// Stops the single-stage tasks feeding this operator and drains the blocks
they have already queued. Routed
+ /// through the release hook rather than through separate
`close()`/`cancel()` overrides so that it runs on every
+ /// termination path by construction, and so a failure here cannot abort the
rest of the teardown.
@Override
- public void cancel(Throwable e) {
+ protected void releaseBuffers() {
cancelSseTasks();
}
@Override
- public void close() {
- cancelSseTasks();
+ protected boolean hasBufferedState() {
+ return !_blockingQueue.isEmpty();
}
@VisibleForTesting
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/MultiStageOperator.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/MultiStageOperator.java
index 0bead828f0f..88dafd2e012 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/MultiStageOperator.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/MultiStageOperator.java
@@ -18,6 +18,7 @@
*/
package org.apache.pinot.query.runtime.operator;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Joiner;
import com.google.common.base.Preconditions;
import com.google.common.base.Stopwatch;
@@ -238,6 +239,52 @@ public abstract class MultiStageOperator implements
Operator<MseBlock>, AutoClos
/// the stat map the operator keeps.
public abstract StatMap<?> copyStatMaps();
+ /// Drops the per-query row and hash state this operator is holding —
whatever it accumulated while running, plus
+ /// any input it is still pointing at — so that it becomes collectable even
while the operator itself stays
+ /// reachable.
+ ///
+ /// Both [#close()] and [#cancel(Throwable)] call this, so the state is
released on *every* termination path: end
+ /// of stream, error and cancellation alike. Operators that can release
earlier (when they produce their
+ /// end-of-stream block, say) should keep doing that as well — this is the
backstop, not the prompt path.
+ ///
+ /// **Threading.** Like the rest of teardown, this runs on the thread that
executes the op chain (see [OpChain]) —
+ /// either the worker thread itself, via the scheduler's direct-executor
callback, or a thread holding a chain that
+ /// never started. It is therefore safe to touch operator state without
synchronization, and callers must not
+ /// invoke [#close()] or [#cancel(Throwable)] from anywhere else: nulling a
field that a concurrently running
+ /// `getNextBlock()` is dereferencing would be a use-after-free, not a
missed release.
+ ///
+ /// Rules for implementations:
+ ///
+ /// 1. **Be idempotent.** This can run more than once, and it runs after
[#cancel(Throwable)] on the error path.
+ /// 2. **Leave the stats alone.** [#calculateStats()] and [#copyStatMaps()]
are called after termination.
+ /// 3. **Never mutate something whose identity left the operator.** A block
sitting in a local mailbox still
+ /// points at the list it was built from, and emptying that list
silently drops rows rather than failing.
+ /// Drop or replace the reference; do not `clear()` in place. The same
goes for state an external consumer
+ /// reads after termination — see
[org.apache.pinot.query.runtime.plan.pipeline.PipelineBreakerOperator], whose
+ /// buffer is its output and which therefore releases nothing.
+ /// 4. **Prefer replacing to clearing.** `clear()` drops the elements but
keeps the backing table at whatever
+ /// capacity it grew to — `HashMap`, `ObjectOpenHashSet`, `ArrayList`
and `PriorityQueue` all behave this way,
+ /// which for a large buffer leaves tens of megabytes of empty slots
reachable. Assign a fresh, empty instance
+ /// (or `null`) instead.
+ /// 5. **Do not let a released field double as control flow.** After
release the operator is done, so a field that
+ /// also serves as a mode discriminator or a "have I read the input yet"
marker must not be the one being
+ /// dropped. Keep the discriminator in a separate final field.
+ ///
+ /// An operator that overrides [#close()] or [#cancel(Throwable)] must chain
to `super`, or its state is never
+ /// released — that is not a compile error, so it is on the implementer.
+ protected void releaseBuffers() {
+ }
+
+ /// Whether this operator is currently holding any of the state that
[#releaseBuffers()] drops.
+ ///
+ /// The two are a pair: an operator that overrides one must override the
other, and
+ /// `releaseBuffers(); assert !hasBufferedState();` must hold. It exists so
the release invariant can be asserted
+ /// without reflecting into private fields; nothing in production reads it.
+ @VisibleForTesting
+ protected boolean hasBufferedState() {
+ return false;
+ }
+
// TODO: Ideally close() call should finish within request deadline.
// TODO: Consider passing deadline as part of the API.
@Override
@@ -250,6 +297,7 @@ public abstract class MultiStageOperator implements
Operator<MseBlock>, AutoClos
// Continue processing because even one operator failed to be close,
we should still close the rest.
}
}
+ releaseBuffersSafely();
}
public void cancel(Throwable e) {
@@ -261,6 +309,18 @@ public abstract class MultiStageOperator implements
Operator<MseBlock>, AutoClos
// Continue processing because even one operator failed to be
cancelled, we should still cancel the rest.
}
}
+ releaseBuffersSafely();
+ }
+
+ private void releaseBuffersSafely() {
+ try {
+ releaseBuffers();
+ } catch (Throwable t) {
+ // Releasing buffers is best-effort cleanup; never let it break the rest
of the teardown. Throwable rather than
+ // Exception so that an AssertionError from an implementation (tests run
with -ea) cannot abort a parent's
+ // close loop and leave its siblings unclosed.
+ logger().error("Failed to release the buffers of operator: {}", this, t);
+ }
}
@Override
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/NonEquiJoinOperator.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/NonEquiJoinOperator.java
index bb1840dfa97..315c32d2272 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/NonEquiJoinOperator.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/NonEquiJoinOperator.java
@@ -37,7 +37,7 @@ public class NonEquiJoinOperator extends BaseJoinOperator {
private static final String BUILD_JOINED_ROWS_SCOPE =
"NonEquiJoinOperator#buildJoinedRows";
private static final String BUILD_NON_MATCH_RIGHT_ROWS_SCOPE =
"NonEquiJoinOperator#buildNonMatchRightRows";
- private final List<Object[]> _rightTable;
+ private List<Object[]> _rightTable;
// Track matched right rows for right join and full join to output
non-matched right rows.
// TODO: Revisit whether we should use IntList or RoaringBitmap for smaller
memory footprint.
@Nullable
@@ -69,11 +69,20 @@ public class NonEquiJoinOperator extends BaseJoinOperator {
}
}
+ /// Replaces `_rightTable` with a fresh, empty list rather than clearing it:
`clear()` would keep the element array
+ /// it grew to. A new `ArrayList` rather than `List.of()` because the field
is read — and, if the operator were ever
+ /// re-entered, written — unconditionally, so it must stay both non-null and
mutable.
@Override
- protected void onEosProduced() {
+ protected void releaseBuffers() {
+ _rightTable = new ArrayList<>();
_matchedRightRows = null;
}
+ @Override
+ protected boolean hasBufferedState() {
+ return !_rightTable.isEmpty() || _matchedRightRows != null;
+ }
+
@Override
protected List<Object[]> buildJoinedRows(MseBlock.Data leftBlock) {
ArrayList<Object[]> rows = new ArrayList<>();
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/RepeatOperator.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/RepeatOperator.java
index 5045b3b8d07..53013c0ee7a 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/RepeatOperator.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/RepeatOperator.java
@@ -25,6 +25,7 @@ import org.apache.pinot.common.datatable.StatMap;
import org.apache.pinot.common.utils.DataSchema;
import org.apache.pinot.query.runtime.blocks.MseBlock;
import org.apache.pinot.query.runtime.blocks.RowHeapDataBlock;
+import org.apache.pinot.query.runtime.blocks.SuccessMseBlock;
import org.apache.pinot.query.runtime.plan.OpChainExecutionContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -70,6 +71,10 @@ public class RepeatOperator extends MultiStageOperator {
/// Rows of the input block currently being expanded, or null when the next
input block must be pulled.
@Nullable
private List<Object[]> _currentRows;
+ /// Set once the input is exhausted (or the operator is terminated), and
returned from every later call. Without
+ /// it, a null [#_currentRows] would send us back to a spent — after
[#releaseBuffers()], closed — input.
+ @Nullable
+ private MseBlock.Eos _eosBlock;
/// The grouping set (ordinal) the next getNextBlock() call will expand the
current input block for.
private int _currentSet;
@@ -124,9 +129,13 @@ public class RepeatOperator extends MultiStageOperator {
@Override
protected MseBlock getNextBlock() {
+ if (_eosBlock != null) {
+ return _eosBlock;
+ }
if (_currentRows == null) {
MseBlock block = _input.nextBlock();
if (block.isEos()) {
+ _eosBlock = (MseBlock.Eos) block;
return block;
}
_currentRows = ((MseBlock.Data) block).asRowHeap().getRows();
@@ -166,6 +175,23 @@ public class RepeatOperator extends MultiStageOperator {
return new StatMap<>(_statMap);
}
+ /// Drops the input block being expanded. The rows belong to that block, not
to this operator, and the emitted rows
+ /// are freshly allocated, so dropping the reference is enough — clearing
would corrupt a block another operator may
+ /// still be holding. Marks the operator finished at the same time, because
a null [#_currentRows] otherwise means
+ /// "pull the next input block".
+ @Override
+ protected void releaseBuffers() {
+ _currentRows = null;
+ if (_eosBlock == null) {
+ _eosBlock = SuccessMseBlock.INSTANCE;
+ }
+ }
+
+ @Override
+ protected boolean hasBufferedState() {
+ return _currentRows != null;
+ }
+
public enum StatKey implements StatMap.Key {
EXECUTION_TIME_MS(StatMap.Type.LONG) {
@Override
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/SortOperator.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/SortOperator.java
index 82db0410b46..a06e0a483be 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/SortOperator.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/SortOperator.java
@@ -24,6 +24,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.PriorityQueue;
+import javax.annotation.Nullable;
import org.apache.calcite.rel.RelFieldCollation;
import org.apache.pinot.common.datatable.StatMap;
import org.apache.pinot.common.utils.DataSchema;
@@ -46,8 +47,18 @@ public class SortOperator extends MultiStageOperator {
private final DataSchema _dataSchema;
private final int _offset;
private final int _numRowsToKeep;
- private final PriorityQueue<Object[]> _priorityQueue;
- private final ArrayList<Object[]> _rows;
+ /// Whether this operator has to sort, or is a plain limit/offset over an
already-sorted input. Decides which of
+ /// the two buffers below is in use. Kept separate from them so that
releasing a buffer cannot change the mode.
+ private final boolean _requiresSort;
+ /// The rows-to-keep heap, used when [#_requiresSort]. Never handed
downstream — [#produceSortedBlock()] drains it
+ /// into a fresh array — so releasing replaces it outright rather than
clearing it, which would keep the backing
+ /// array alive.
+ @Nullable
+ private PriorityQueue<Object[]> _priorityQueue;
+ /// The buffered rows, used when not [#_requiresSort]. Handed downstream as
a sublist view of itself, so releasing
+ /// must drop the reference rather than empty the list.
+ @Nullable
+ private ArrayList<Object[]> _rows;
private final StatMap<StatKey> _statMap = new StatMap<>(StatKey.class);
private boolean _hasConstructedSortedBlock;
@@ -73,7 +84,8 @@ public class SortOperator extends MultiStageOperator {
// - There is no collation
// - Input is already sorted
List<RelFieldCollation> collations = node.getCollations();
- if (collations.isEmpty() || input instanceof SortedMailboxReceiveOperator)
{
+ _requiresSort = !(collations.isEmpty() || input instanceof
SortedMailboxReceiveOperator);
+ if (!_requiresSort) {
_priorityQueue = null;
_rows = new ArrayList<>(Math.min(defaultHolderCapacity, _numRowsToKeep));
} else {
@@ -109,7 +121,14 @@ public class SortOperator extends MultiStageOperator {
}
@Override
- public void cancel(Throwable e) {
+ protected void releaseBuffers() {
+ _priorityQueue = null;
+ _rows = null;
+ }
+
+ @Override
+ protected boolean hasBufferedState() {
+ return _priorityQueue != null || _rows != null;
}
@Override
@@ -125,7 +144,7 @@ public class SortOperator extends MultiStageOperator {
}
_eosBlock = consumeInputBlocks();
// returning upstream error block if finalBlock contains error.
- _statMap.merge(StatKey.REQUIRE_SORT, _priorityQueue != null);
+ _statMap.merge(StatKey.REQUIRE_SORT, _requiresSort);
if (_eosBlock.isError()) {
return _eosBlock;
}
@@ -139,7 +158,8 @@ public class SortOperator extends MultiStageOperator {
private MseBlock produceSortedBlock() {
_hasConstructedSortedBlock = true;
- if (_priorityQueue == null) {
+ if (!_requiresSort) {
+ assert _rows != null : "Rows should not be null when producing the
sorted block";
if (_rows.size() > _offset) {
List<Object[]> row = _rows.subList(_offset, _rows.size());
return new RowHeapDataBlock(row, _dataSchema);
@@ -147,6 +167,7 @@ public class SortOperator extends MultiStageOperator {
return _eosBlock;
}
} else {
+ assert _priorityQueue != null : "Priority queue should not be null when
producing the sorted block";
int resultSize = _priorityQueue.size() - _offset;
if (resultSize <= 0) {
return _eosBlock;
@@ -164,7 +185,8 @@ public class SortOperator extends MultiStageOperator {
MseBlock block = _input.nextBlock();
while (block.isData()) {
List<Object[]> container = ((MseBlock.Data) block).asRowHeap().getRows();
- if (_priorityQueue == null) {
+ if (!_requiresSort) {
+ assert _rows != null : "Rows should not be null when consuming input
blocks";
// TODO: when push-down properly, we shouldn't get more than
_numRowsToKeep
int numRows = _rows.size();
if (numRows < _numRowsToKeep) {
@@ -184,6 +206,7 @@ public class SortOperator extends MultiStageOperator {
}
}
} else {
+ assert _priorityQueue != null : "Priority queue should not be null
when consuming input blocks";
for (Object[] row : container) {
SelectionOperatorUtils.addToPriorityQueue(row, _priorityQueue,
_numRowsToKeep);
}
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/SortedMailboxReceiveOperator.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/SortedMailboxReceiveOperator.java
index bede521ad63..42ad6d05bdf 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/SortedMailboxReceiveOperator.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/SortedMailboxReceiveOperator.java
@@ -21,6 +21,7 @@ package org.apache.pinot.query.runtime.operator;
import com.google.common.base.Preconditions;
import java.util.ArrayList;
import java.util.List;
+import javax.annotation.Nullable;
import org.apache.calcite.rel.RelFieldCollation;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.pinot.common.utils.DataSchema;
@@ -46,7 +47,11 @@ public class SortedMailboxReceiveOperator extends
BaseMailboxReceiveOperator {
private final DataSchema _dataSchema;
private final List<RelFieldCollation> _collations;
- private final List<Object[]> _rows = new ArrayList<>();
+ /// The rows collected from the mailboxes. Sorted in place and handed
downstream as-is, so [#releaseBuffers()]
+ /// drops the reference instead of clearing it: a local mailbox may still be
holding the block that wraps this very
+ /// list, and emptying it would silently drop those rows.
+ @Nullable
+ private List<Object[]> _rows = new ArrayList<>();
private MseBlock _eosBlock;
@@ -77,20 +82,24 @@ public class SortedMailboxReceiveOperator extends
BaseMailboxReceiveOperator {
while (true) {
MseBlock block = _multiConsumer.readMseBlockBlocking();
if (block.isData()) {
+ assert _rows != null : "Rows should not be null while collecting
mailbox blocks";
_rows.addAll(((MseBlock.Data) block).asRowHeap().getRows());
continue;
}
MseBlock.Eos eosBlock = (MseBlock.Eos) block;
onEos();
_eosBlock = eosBlock;
+ List<Object[]> rows = _rows;
+ assert rows != null : "Rows should not be null when the end of stream is
reached";
+ releaseBuffers();
if (eosBlock.isError()) {
return eosBlock;
} else {
- if (!_rows.isEmpty()) {
+ if (!rows.isEmpty()) {
// TODO: This might not be efficient because we are sorting all the
received rows. We should use a k-way merge
// when sender side is sorted.
- _rows.sort(new SortUtils.SortComparator(_collations, false));
- return new RowHeapDataBlock(_rows, _dataSchema);
+ rows.sort(new SortUtils.SortComparator(_collations, false));
+ return new RowHeapDataBlock(rows, _dataSchema);
} else {
return block;
}
@@ -99,14 +108,12 @@ public class SortedMailboxReceiveOperator extends
BaseMailboxReceiveOperator {
}
@Override
- public void close() {
- super.close();
- _rows.clear();
+ protected void releaseBuffers() {
+ _rows = null;
}
@Override
- public void cancel(Throwable t) {
- super.cancel(t);
- _rows.clear();
+ protected boolean hasBufferedState() {
+ return _rows != null;
}
}
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/set/BinarySetOperator.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/set/BinarySetOperator.java
index 76f94b12688..3ef64f8e339 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/set/BinarySetOperator.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/set/BinarySetOperator.java
@@ -36,7 +36,7 @@ public abstract class BinarySetOperator extends SetOperator {
protected final MultiStageOperator _leftChildOperator;
protected final MultiStageOperator _rightChildOperator;
- protected final Multiset<Record> _rightRowSet;
+ protected Multiset<Record> _rightRowSet;
private MseBlock.Eos _eos;
private boolean _isRightChildOperatorProcessed;
@@ -105,6 +105,7 @@ public abstract class BinarySetOperator extends SetOperator
{
return mseBlock;
} else if (mseBlock.isError()) {
_eos = (MseBlock.Eos) mseBlock;
+ releaseBuffers();
return _eos;
} else if (mseBlock.isSuccess()) {
// If it's a regular EOS block, we continue to process the left child
operator.
@@ -115,12 +116,28 @@ public abstract class BinarySetOperator extends
SetOperator {
MseBlock mseBlock = processLeftOperator();
if (mseBlock.isEos()) {
_eos = (MseBlock.Eos) mseBlock;
+ releaseBuffers();
return _eos;
} else {
return mseBlock;
}
}
+ /// Replaces `_rightRowSet` with a fresh, empty multiset rather than
clearing it: `HashMultiset.clear()` walks every
+ /// entry and still leaves the backing table at the capacity it grew to,
which for a wide INTERSECT / EXCEPT is the
+ /// bulk of what we are trying to release. Safe to swap because
[#handleRowMatched(Object[])] is only reached from
+ /// [#processLeftOperator()], which [#getNextBlock()] stops calling once
`_eos` is set — and `_eos` is set before
+ /// every release.
+ @Override
+ protected void releaseBuffers() {
+ _rightRowSet = HashMultiset.create();
+ }
+
+ @Override
+ protected boolean hasBufferedState() {
+ return !_rightRowSet.isEmpty();
+ }
+
/// Returns true if the row matches the criteria defined by the set
operation.
///
/// Also updates the right row set based on the operator.
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/set/UnionOperator.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/set/UnionOperator.java
index 3d258db671a..515f754eb48 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/set/UnionOperator.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/set/UnionOperator.java
@@ -41,7 +41,7 @@ public class UnionOperator extends SetOperator {
private MseBlock _eosBlock = null;
private int _currentOperatorIndex = 0;
- private final Set<Record> _seenRecords = new ObjectOpenHashSet<>();
+ private Set<Record> _seenRecords = new ObjectOpenHashSet<>();
public UnionOperator(OpChainExecutionContext opChainExecutionContext,
List<MultiStageOperator> inputOperators, DataSchema dataSchema) {
@@ -60,11 +60,13 @@ public class UnionOperator extends SetOperator {
MseBlock block = currentOperator.nextBlock();
if (block.isError()) {
_eosBlock = block;
+ releaseBuffers();
return block;
} else if (block.isSuccess()) {
_currentOperatorIndex++;
if (_currentOperatorIndex == _inputOperators.size()) {
_eosBlock = block;
+ releaseBuffers();
return block;
}
} else if (block.isData()) {
@@ -103,4 +105,18 @@ public class UnionOperator extends SetOperator {
public String toExplainString() {
return EXPLAIN_NAME;
}
+
+ /// Replaces `_seenRecords` with a fresh, empty set rather than clearing it:
`ObjectOpenHashSet.clear()` nulls the
+ /// entries but deliberately keeps the key table at the capacity it grew to,
and for a de-duplicating UNION that
+ /// table is sized by the full distinct cardinality of every input. Safe to
swap because [#getNextBlock()] returns
+ /// the cached `_eosBlock` without touching the set once it is set, and it
is set before every release.
+ @Override
+ protected void releaseBuffers() {
+ _seenRecords = new ObjectOpenHashSet<>();
+ }
+
+ @Override
+ protected boolean hasBufferedState() {
+ return !_seenRecords.isEmpty();
+ }
}
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/plan/pipeline/PipelineBreakerOperator.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/plan/pipeline/PipelineBreakerOperator.java
index c2a0aab6056..a9f10d7a0d9 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/plan/pipeline/PipelineBreakerOperator.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/plan/pipeline/PipelineBreakerOperator.java
@@ -71,6 +71,16 @@ public class PipelineBreakerOperator extends
MultiStageOperator {
_statMap.merge(StatKey.EMITTED_ROWS, numRows);
}
+ /// Deliberately releases nothing, unlike every other operator.
+ ///
+ /// `_resultMap` is this operator's *output*, not scratch space:
[PipelineBreakerExecutor] reads it through
+ /// [#getResultMap()] after the op chain has finished, and `OpChain#close()`
fires the callback that unblocks that
+ /// read only after `close()` has already run. Dropping the map here would
hand the main op chain empty
+ /// pipeline-breaker results, which `nextBlock()` would then surface as an
unexplained error block.
+ @Override
+ protected void releaseBuffers() {
+ }
+
@Override
public List<MultiStageOperator> getChildOperators() {
return _childOperators;
diff --git
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/OperatorBufferReleaseTest.java
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/OperatorBufferReleaseTest.java
new file mode 100644
index 00000000000..88851a436c3
--- /dev/null
+++
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/OperatorBufferReleaseTest.java
@@ -0,0 +1,640 @@
+/**
+ * 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 java.util.List;
+import java.util.function.Supplier;
+import org.apache.calcite.rel.RelFieldCollation;
+import org.apache.calcite.rel.RelFieldCollation.Direction;
+import org.apache.calcite.rel.RelFieldCollation.NullDirection;
+import org.apache.calcite.rel.core.JoinRelType;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.pinot.common.datatable.StatMap;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
+import org.apache.pinot.query.planner.logical.RexExpression;
+import org.apache.pinot.query.planner.plannode.AggregateNode;
+import org.apache.pinot.query.planner.plannode.AggregateNode.AggType;
+import org.apache.pinot.query.planner.plannode.JoinNode;
+import org.apache.pinot.query.planner.plannode.PlanNode;
+import org.apache.pinot.query.planner.plannode.SortNode;
+import org.apache.pinot.query.runtime.blocks.ErrorMseBlock;
+import org.apache.pinot.query.runtime.blocks.MseBlock;
+import org.apache.pinot.query.runtime.blocks.SuccessMseBlock;
+import org.apache.pinot.query.runtime.plan.OpChainExecutionContext;
+import org.mockito.Mock;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import static org.apache.pinot.common.utils.DataSchema.ColumnDataType.BOOLEAN;
+import static org.apache.pinot.common.utils.DataSchema.ColumnDataType.INT;
+import static org.apache.pinot.common.utils.DataSchema.ColumnDataType.LONG;
+import static org.apache.pinot.common.utils.DataSchema.ColumnDataType.STRING;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.MockitoAnnotations.openMocks;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+
+/// Asserts the invariant that a [MultiStageOperator] releases the row and
hash state it is holding on *every*
+/// termination path — not only when it produces its end-of-stream block, but
also when the query errors out and when
+/// it is cancelled.
+///
+/// While an operator stays reachable its un-released buffers are live
references, so no GC can reclaim them. That is
+/// why the release has to happen in the operator itself rather than depend on
nothing else holding the operator tree.
+///
+/// This is deliberately one cross-operator suite rather than a case bolted
onto each operator's own test class: the
+/// invariant is a property of [MultiStageOperator], the interesting failure
is one operator quietly not having it,
+/// and [#shouldReleaseByEitherPathForEveryOperator()] can only be written in
one place. Each case drives an operator
+/// to the point where it holds state, asserts
[MultiStageOperator#hasBufferedState] so the scenario cannot go
+/// vacuous, terminates it, and asserts the state is gone.
+///
+/// Set operators live in their own package and are covered by
+/// [org.apache.pinot.query.runtime.operator.set.SetOperatorBufferReleaseTest].
+public class OperatorBufferReleaseTest {
+ private static final DataSchema SCHEMA =
+ new DataSchema(new String[]{"int_col", "string_col"}, new
ColumnDataType[]{INT, STRING});
+ private static final DataSchema JOIN_RESULT_SCHEMA =
+ new DataSchema(new String[]{"int_col1", "string_col1", "int_col2",
"string_col2"},
+ new ColumnDataType[]{INT, STRING, INT, STRING});
+ private static final RuntimeException ERROR = new RuntimeException("boom");
+
+ private AutoCloseable _mocks;
+ @Mock
+ private MultiStageOperator _input;
+ @Mock
+ private MultiStageOperator _leftInput;
+ @Mock
+ private MultiStageOperator _rightInput;
+
+ @BeforeMethod
+ public void setUp() {
+ _mocks = openMocks(this);
+ }
+
+ @AfterMethod
+ public void tearDown()
+ throws Exception {
+ _mocks.close();
+ }
+
+ //
---------------------------------------------------------------------------------------------------------------
+ // The base-class contract
+ //
---------------------------------------------------------------------------------------------------------------
+
+ @Test
+ public void shouldReleaseFromBothCloseAndCancel() {
+ RecordingOperator closed = new RecordingOperator();
+ closed.close();
+ assertEquals(closed._releaseCount, 1, "close() must release");
+
+ RecordingOperator cancelled = new RecordingOperator();
+ cancelled.cancel(ERROR);
+ assertEquals(cancelled._releaseCount, 1, "cancel() must release");
+ }
+
+ /// close() may run more than once, and it runs after cancel() on the error
path, so releasing has to be idempotent.
+ @Test
+ public void shouldTolerateRepeatedTermination() {
+ RecordingOperator operator = new RecordingOperator();
+ operator.cancel(ERROR);
+ operator.close();
+ operator.close();
+ assertEquals(operator._releaseCount, 3);
+ }
+
+ /// Children are torn down first, so an operator may still reach into its
inputs while releasing.
+ @Test
+ public void shouldReleaseAfterClosingChildren() {
+ RecordingOperator operator = new RecordingOperator(_input);
+ operator.close();
+ verify(_input, times(1)).close();
+ assertTrue(operator._childrenWereClosedFirst);
+ }
+
+ /// Releasing is best-effort cleanup: a throwing implementation must not
abort the rest of the teardown. An
+ /// `AssertionError` is the interesting case, since tests run with
assertions enabled.
+ @Test
+ public void shouldSurviveAThrowingRelease() {
+ MultiStageOperator throwsException = new RecordingOperator(_input) {
+ @Override
+ protected void releaseBuffers() {
+ throw new RuntimeException("release failed");
+ }
+ };
+ throwsException.close();
+ throwsException.cancel(ERROR);
+
+ MultiStageOperator throwsAssertionError = new
RecordingOperator(_leftInput) {
+ @Override
+ protected void releaseBuffers() {
+ throw new AssertionError("release failed");
+ }
+ };
+ throwsAssertionError.close();
+ throwsAssertionError.cancel(ERROR);
+
+ verify(_input, times(1)).close();
+ verify(_input, times(1)).cancel(any());
+ verify(_leftInput, times(1)).close();
+ verify(_leftInput, times(1)).cancel(any());
+ }
+
+ //
---------------------------------------------------------------------------------------------------------------
+ // SortOperator
+ //
---------------------------------------------------------------------------------------------------------------
+
+ @Test
+ public void shouldReleaseSortPriorityQueueOnError() {
+ givenInputProducesThenFails();
+ SortOperator operator = sortOperator(_input);
+
+ assertTrue(operator.nextBlock().isError());
+ assertTrue(operator.hasBufferedState(), "the sort is still holding its
heap when the error propagates");
+
+ operator.cancel(ERROR);
+
+ assertFalse(operator.hasBufferedState());
+ }
+
+ @Test
+ public void shouldReleaseSortRowsOnError() {
+ // No collations => the operator buffers into _rows instead of the
priority queue.
+ givenInputProducesThenFails();
+ SortOperator operator = sortOperator(_input, List.of());
+
+ assertTrue(operator.nextBlock().isError());
+ assertTrue(operator.hasBufferedState(), "the sort is still holding its
rows when the error propagates");
+
+ operator.close();
+
+ assertFalse(operator.hasBufferedState());
+ }
+
+ /// [SortOperator] used to override `cancel()` with an empty body, so a
cancel never reached the operators feeding
+ /// it. Releasing state must not come at the cost of that recursion.
+ @Test
+ public void shouldPropagateSortCancelToChildren() {
+ when(_input.nextBlock()).thenReturn(SuccessMseBlock.INSTANCE);
+ SortOperator operator = sortOperator(_input);
+
+ operator.cancel(ERROR);
+
+ verify(_input, times(1)).cancel(ERROR);
+ }
+
+ /// The block [SortOperator] emits is a sublist view of `_rows`, and a block
handed to a local mailbox can still be
+ /// read after this op chain has been closed. Releasing must therefore drop
the reference, never empty the list.
+ @Test
+ public void shouldNotEmptyTheBlockEmittedBySort() {
+ when(_input.nextBlock()).thenReturn(OperatorTestUtil.block(SCHEMA, new
Object[]{2, "b"}, new Object[]{1, "a"}))
+ .thenReturn(SuccessMseBlock.INSTANCE);
+ SortOperator operator = sortOperator(_input, List.of());
+ List<Object[]> rows = ((MseBlock.Data)
operator.nextBlock()).asRowHeap().getRows();
+ assertEquals(rows.size(), 2);
+
+ operator.close();
+
+ assertEquals(rows.size(), 2, "the emitted block must survive the operator
being closed");
+ assertEquals(rows.get(0), new Object[]{2, "b"});
+ }
+
+ //
---------------------------------------------------------------------------------------------------------------
+ // Joins
+ //
---------------------------------------------------------------------------------------------------------------
+
+ /// The right side is materialized, the join starts emitting, and then the
op chain is cancelled mid-flight — the
+ /// case where nothing on the success path ever gets to run.
+ @Test
+ public void shouldReleaseHashJoinRightTableOnCancel() {
+ givenRightBuiltThenLeftKeepsFlowing();
+ HashJoinOperator operator = hashJoinOperator();
+
+ assertTrue(operator.nextBlock().isData());
+ assertTrue(operator.hasBufferedState(), "the right table is still held
mid-join");
+
+ operator.cancel(ERROR);
+
+ assertFalse(operator.hasBufferedState());
+ }
+
+ /// When the right side itself fails, the partially built right table never
reaches the success path.
+ @Test
+ public void shouldReleaseHashJoinPartialRightTableOnError() {
+ givenRightSideFails();
+ HashJoinOperator operator = hashJoinOperator();
+
+ assertTrue(operator.nextBlock().isError());
+ assertTrue(operator.hasBufferedState(), "the partial right table is still
held when the error propagates");
+
+ operator.close();
+
+ assertFalse(operator.hasBufferedState());
+ }
+
+ @Test
+ public void shouldReleaseNonEquiJoinRightTableOnCancel() {
+ givenRightBuiltThenLeftKeepsFlowing();
+ NonEquiJoinOperator operator = nonEquiJoinOperator();
+
+ assertTrue(operator.nextBlock().isData());
+ assertTrue(operator.hasBufferedState(), "the right table is still held
mid-join");
+
+ operator.cancel(ERROR);
+
+ assertFalse(operator.hasBufferedState());
+ }
+
+ @Test
+ public void shouldReleaseNonEquiJoinPartialRightTableOnError() {
+ givenRightSideFails();
+ NonEquiJoinOperator operator = nonEquiJoinOperator();
+
+ assertTrue(operator.nextBlock().isError());
+ assertTrue(operator.hasBufferedState(), "the partial right table is still
held when the error propagates");
+
+ operator.close();
+
+ assertFalse(operator.hasBufferedState());
+ }
+
+ /// The emitted rows are copies, so releasing the right table must not
disturb a block already sent downstream.
+ @Test
+ public void shouldNotEmptyTheBlockEmittedByNonEquiJoin() {
+ when(_rightInput.nextBlock()).thenReturn(OperatorTestUtil.block(SCHEMA,
new Object[]{5, "r"}))
+ .thenReturn(SuccessMseBlock.INSTANCE);
+ when(_leftInput.nextBlock()).thenReturn(OperatorTestUtil.block(SCHEMA, new
Object[]{1, "l"}))
+ .thenReturn(SuccessMseBlock.INSTANCE);
+ NonEquiJoinOperator operator = nonEquiJoinOperator();
+ List<Object[]> rows = ((MseBlock.Data)
operator.nextBlock()).asRowHeap().getRows();
+ assertEquals(rows.size(), 1);
+
+ operator.close();
+
+ assertEquals(rows.size(), 1, "the emitted block must survive the operator
being closed");
+ assertEquals(rows.get(0), new Object[]{1, "l", 5, "r"});
+ }
+
+ @Test
+ public void shouldReleaseAsofJoinRightTableOnCancel() {
+ givenRightBuiltThenLeftKeepsFlowing();
+ AsofJoinOperator operator = asofJoinOperator();
+
+ assertTrue(operator.nextBlock().isData());
+ assertTrue(operator.hasBufferedState(), "the right table is still held
mid-join");
+
+ operator.cancel(ERROR);
+
+ assertFalse(operator.hasBufferedState());
+ }
+
+ @Test
+ public void shouldReleaseAsofJoinPartialRightTableOnError() {
+ givenRightSideFails();
+ AsofJoinOperator operator = asofJoinOperator();
+
+ assertTrue(operator.nextBlock().isError());
+ assertTrue(operator.hasBufferedState(), "the partial right table is still
held when the error propagates");
+
+ operator.close();
+
+ assertFalse(operator.hasBufferedState());
+ }
+
+ //
---------------------------------------------------------------------------------------------------------------
+ // AggregateOperator and RepeatOperator
+ //
---------------------------------------------------------------------------------------------------------------
+
+ @Test
+ public void shouldReleaseGroupByExecutorOnError() {
+ givenInputProducesThenFails();
+ AggregateOperator operator = aggregateOperator(List.of(0));
+ assertTrue(operator.hasBufferedState());
+
+ assertTrue(operator.nextBlock().isError());
+
+ // The upstream failed, so the group-by hash maps are dead weight from the
moment the error block is produced.
+ assertFalse(operator.hasBufferedState(), "the group-by executor should be
released on the error path");
+ operator.close();
+ assertFalse(operator.hasBufferedState());
+ }
+
+ @Test
+ public void shouldReleaseAggregationExecutorOnError() {
+ givenInputProducesThenFails();
+ AggregateOperator operator = aggregateOperator(List.of());
+ assertTrue(operator.hasBufferedState());
+
+ assertTrue(operator.nextBlock().isError());
+
+ assertFalse(operator.hasBufferedState(), "the aggregation executor should
be released on the error path");
+ operator.cancel(ERROR);
+ assertFalse(operator.hasBufferedState());
+ }
+
+ /// Releasing the executors must not change which of the two the operator
thinks it is: a group-by that has been
+ /// closed still has to behave like a group-by, not silently turn into a
plain aggregation.
+ @Test
+ public void shouldKeepAggregateModeAfterRelease() {
+ givenInputProducesThenFails();
+ AggregateOperator groupBy = aggregateOperator(List.of(0));
+ groupBy.close();
+ assertTrue(groupBy.nextBlock().isEos(), "a released group-by must not be
re-dispatched as an aggregation");
+
+ givenInputProducesThenFails();
+ AggregateOperator aggregation = aggregateOperator(List.of());
+ aggregation.close();
+ assertTrue(aggregation.nextBlock().isEos());
+ }
+
+ @Test
+ public void shouldReleaseRepeatCurrentRowsOnCancel() {
+ when(_input.nextBlock()).thenReturn(OperatorTestUtil.block(SCHEMA, new
Object[]{1, "a"}, new Object[]{2, "b"}));
+ RepeatOperator operator = repeatOperator();
+
+ assertTrue(operator.nextBlock().isData());
+ assertTrue(operator.hasBufferedState(), "the input block is still held
between grouping sets");
+
+ operator.cancel(ERROR);
+
+ assertFalse(operator.hasBufferedState());
+ }
+
+ /// `_currentRows == null` means "pull the next input block", so releasing
it must also mark the operator finished —
+ /// otherwise a released operator would go back to an input that has already
been closed.
+ @Test
+ public void shouldNotPullFromTheInputAfterRepeatIsReleased() {
+ when(_input.nextBlock()).thenReturn(OperatorTestUtil.block(SCHEMA, new
Object[]{1, "a"}));
+ RepeatOperator operator = repeatOperator();
+ assertTrue(operator.nextBlock().isData());
+ reset(_input);
+
+ operator.close();
+
+ assertTrue(operator.nextBlock().isEos(), "a released operator must report
end of stream");
+ verify(_input, times(0)).nextBlock();
+ assertFalse(operator.hasBufferedState());
+ }
+
+ //
---------------------------------------------------------------------------------------------------------------
+ // Cross-operator sweep
+ //
---------------------------------------------------------------------------------------------------------------
+
+ /// close() and cancel() must be interchangeable and repeatable for every
operator that buffers: whichever one runs,
+ /// and however often, the operator ends up holding nothing. Guards against
an operator that releases on only one
+ /// of the two paths, which is the exact defect this change set fixes.
+ @Test
+ public void shouldReleaseByEitherPathForEveryOperator() {
+ for (boolean cancelFirst : new boolean[]{true, false}) {
+ assertReleasedByEitherPath(cancelFirst, this::sortHoldingRows);
+ assertReleasedByEitherPath(cancelFirst, this::sortHoldingPriorityQueue);
+ assertReleasedByEitherPath(cancelFirst, this::hashJoinHoldingRightTable);
+ assertReleasedByEitherPath(cancelFirst,
this::nonEquiJoinHoldingRightTable);
+ assertReleasedByEitherPath(cancelFirst, this::asofJoinHoldingRightTable);
+ assertReleasedByEitherPath(cancelFirst,
this::aggregateHoldingGroupByExecutor);
+ assertReleasedByEitherPath(cancelFirst, this::repeatHoldingCurrentRows);
+ }
+ }
+
+ private void assertReleasedByEitherPath(boolean cancelFirst,
Supplier<MultiStageOperator> factory) {
+ MultiStageOperator operator = factory.get();
+ String name = operator.getClass().getSimpleName();
+ assertTrue(operator.hasBufferedState(), name + " should be holding state
before termination");
+
+ terminate(operator, cancelFirst);
+ assertFalse(operator.hasBufferedState(),
+ name + " should have released after " + (cancelFirst ? "cancel" :
"close"));
+
+ // Terminating again, by the other path, must be safe and must leave it
released.
+ terminate(operator, !cancelFirst);
+ assertFalse(operator.hasBufferedState(), name + " should stay released
after a second termination");
+ }
+
+ private static void terminate(MultiStageOperator operator, boolean cancel) {
+ if (cancel) {
+ operator.cancel(ERROR);
+ } else {
+ operator.close();
+ }
+ }
+
+ private MultiStageOperator sortHoldingRows() {
+ resetMocks();
+ givenInputProducesThenFails();
+ SortOperator operator = sortOperator(_input, List.of());
+ operator.nextBlock();
+ return operator;
+ }
+
+ private MultiStageOperator sortHoldingPriorityQueue() {
+ resetMocks();
+ givenInputProducesThenFails();
+ SortOperator operator = sortOperator(_input);
+ operator.nextBlock();
+ return operator;
+ }
+
+ private MultiStageOperator hashJoinHoldingRightTable() {
+ resetMocks();
+ givenRightBuiltThenLeftKeepsFlowing();
+ HashJoinOperator operator = hashJoinOperator();
+ operator.nextBlock();
+ return operator;
+ }
+
+ private MultiStageOperator nonEquiJoinHoldingRightTable() {
+ resetMocks();
+ givenRightBuiltThenLeftKeepsFlowing();
+ NonEquiJoinOperator operator = nonEquiJoinOperator();
+ operator.nextBlock();
+ return operator;
+ }
+
+ private MultiStageOperator asofJoinHoldingRightTable() {
+ resetMocks();
+ givenRightBuiltThenLeftKeepsFlowing();
+ AsofJoinOperator operator = asofJoinOperator();
+ operator.nextBlock();
+ return operator;
+ }
+
+ private MultiStageOperator aggregateHoldingGroupByExecutor() {
+ resetMocks();
+ when(_input.nextBlock()).thenReturn(OperatorTestUtil.block(SCHEMA, new
Object[]{1, "a"}));
+ return aggregateOperator(List.of(0));
+ }
+
+ private MultiStageOperator repeatHoldingCurrentRows() {
+ resetMocks();
+ when(_input.nextBlock()).thenReturn(OperatorTestUtil.block(SCHEMA, new
Object[]{1, "a"}));
+ RepeatOperator operator = repeatOperator();
+ operator.nextBlock();
+ return operator;
+ }
+
+ private void resetMocks() {
+ reset(_input, _leftInput, _rightInput);
+ }
+
+ //
---------------------------------------------------------------------------------------------------------------
+ // Fixtures
+ //
---------------------------------------------------------------------------------------------------------------
+
+ private void givenInputProducesThenFails() {
+ when(_input.nextBlock()).thenReturn(OperatorTestUtil.block(SCHEMA, new
Object[]{2, "b"}, new Object[]{1, "a"}))
+ .thenReturn(ErrorMseBlock.fromException(ERROR));
+ }
+
+ /// Right side completes, left side keeps producing, so the join is
mid-flight when we terminate it.
+ private void givenRightBuiltThenLeftKeepsFlowing() {
+ when(_rightInput.nextBlock()).thenReturn(OperatorTestUtil.block(SCHEMA,
new Object[]{1, "a"}))
+ .thenReturn(SuccessMseBlock.INSTANCE);
+ when(_leftInput.nextBlock()).thenReturn(OperatorTestUtil.block(SCHEMA, new
Object[]{0, "z"}));
+ }
+
+ /// Right side produces one block and then fails, so the right table is only
partially built.
+ private void givenRightSideFails() {
+ when(_rightInput.nextBlock()).thenReturn(OperatorTestUtil.block(SCHEMA,
new Object[]{1, "a"}))
+ .thenReturn(ErrorMseBlock.fromException(ERROR));
+ }
+
+ private HashJoinOperator hashJoinOperator() {
+ return new HashJoinOperator(OperatorTestUtil.getTracingContext(),
_leftInput, SCHEMA, _rightInput,
+ new JoinNode(-1, JOIN_RESULT_SCHEMA, PlanNode.NodeHint.EMPTY,
List.of(), JoinRelType.FULL, List.of(0),
+ List.of(0), List.of(), JoinNode.JoinStrategy.HASH));
+ }
+
+ private NonEquiJoinOperator nonEquiJoinOperator() {
+ // Condition: left.int_col < right.int_col
+ List<RexExpression> nonEquiConditions = List.of(
+ new RexExpression.FunctionCall(BOOLEAN, SqlKind.LESS_THAN.name(),
+ List.of(new RexExpression.InputRef(0), new
RexExpression.InputRef(2))));
+ return new NonEquiJoinOperator(OperatorTestUtil.getTracingContext(),
_leftInput, SCHEMA, _rightInput,
+ new JoinNode(-1, JOIN_RESULT_SCHEMA, PlanNode.NodeHint.EMPTY,
List.of(), JoinRelType.FULL, List.of(),
+ List.of(), nonEquiConditions, JoinNode.JoinStrategy.HASH));
+ }
+
+ private AsofJoinOperator asofJoinOperator() {
+ // Joined on int_col, MATCH_CONDITION on string_col.
+ RexExpression matchCondition = new RexExpression.FunctionCall(BOOLEAN,
"LESS_THAN_OR_EQUAL",
+ List.of(new RexExpression.InputRef(1), new RexExpression.InputRef(3)));
+ return new AsofJoinOperator(OperatorTestUtil.getTracingContext(),
_leftInput, SCHEMA, _rightInput,
+ new JoinNode(-1, JOIN_RESULT_SCHEMA, PlanNode.NodeHint.EMPTY,
List.of(), JoinRelType.LEFT_ASOF, List.of(0),
+ List.of(0), List.of(), JoinNode.JoinStrategy.ASOF,
matchCondition));
+ }
+
+ private SortOperator sortOperator(MultiStageOperator input) {
+ return sortOperator(input, List.of(new RelFieldCollation(0,
Direction.ASCENDING, NullDirection.LAST)));
+ }
+
+ private SortOperator sortOperator(MultiStageOperator input,
List<RelFieldCollation> collations) {
+ return new SortOperator(OperatorTestUtil.getTracingContext(), input,
+ new SortNode(-1, SCHEMA, PlanNode.NodeHint.EMPTY, List.of(),
collations, 10, 0));
+ }
+
+ private AggregateOperator aggregateOperator(List<Integer> groupKeys) {
+ RexExpression.FunctionCall countStar =
+ new RexExpression.FunctionCall(LONG, SqlKind.COUNT.name(), List.of());
+ DataSchema resultSchema = groupKeys.isEmpty()
+ ? new DataSchema(new String[]{"count"}, new ColumnDataType[]{LONG})
+ : new DataSchema(new String[]{"int_col", "count"}, new
ColumnDataType[]{INT, LONG});
+ return new AggregateOperator(OperatorTestUtil.getTracingContext(), _input,
+ new AggregateNode(-1, resultSchema, PlanNode.NodeHint.EMPTY,
List.of(), List.of(countStar), List.of(-1),
+ groupKeys, AggType.DIRECT, false, null, 0));
+ }
+
+ /// Two grouping sets, so the operator still holds the input block after
emitting the first expansion.
+ private RepeatOperator repeatOperator() {
+ DataSchema resultSchema = new DataSchema(new String[]{"int_col",
"string_col", "int_col_key", "$groupingId"},
+ new ColumnDataType[]{INT, STRING, INT, INT});
+ return new RepeatOperator(OperatorTestUtil.getTracingContext(), _input,
new int[]{0},
+ List.of(List.of(0), List.of()), resultSchema);
+ }
+
+ /// Minimal operator that records how the base class drove it.
+ private static class RecordingOperator extends MultiStageOperator {
+ private static final Logger LOGGER =
LoggerFactory.getLogger(RecordingOperator.class);
+
+ private final List<MultiStageOperator> _children;
+ private final StatMap<SortOperator.StatKey> _statMap = new
StatMap<>(SortOperator.StatKey.class);
+ private int _releaseCount;
+ private boolean _childrenWereClosedFirst;
+
+ RecordingOperator(MultiStageOperator... children) {
+ this(OperatorTestUtil.getTracingContext(), children);
+ }
+
+ private RecordingOperator(OpChainExecutionContext context,
MultiStageOperator... children) {
+ super(context);
+ _children = List.of(children);
+ }
+
+ @Override
+ protected void releaseBuffers() {
+ _releaseCount++;
+ for (MultiStageOperator child : _children) {
+ verify(child, atLeastOnce()).close();
+ }
+ _childrenWereClosedFirst = true;
+ }
+
+ @Override
+ public List<MultiStageOperator> getChildOperators() {
+ return _children;
+ }
+
+ @Override
+ protected MseBlock getNextBlock() {
+ return SuccessMseBlock.INSTANCE;
+ }
+
+ @Override
+ public void registerExecution(long time, int numRows, long
memoryUsedBytes, long gcTimeMs) {
+ }
+
+ @Override
+ public Type getOperatorType() {
+ return Type.SORT_OR_LIMIT;
+ }
+
+ @Override
+ public StatMap<SortOperator.StatKey> copyStatMaps() {
+ return new StatMap<>(_statMap);
+ }
+
+ @Override
+ protected Logger logger() {
+ return LOGGER;
+ }
+
+ @Override
+ public String toExplainString() {
+ return "RECORDING";
+ }
+ }
+}
diff --git
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/SortedMailboxReceiveOperatorTest.java
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/SortedMailboxReceiveOperatorTest.java
index 59aeccd6d5d..d5ae3a2abc5 100644
---
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/SortedMailboxReceiveOperatorTest.java
+++
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/SortedMailboxReceiveOperatorTest.java
@@ -54,6 +54,7 @@ import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertTrue;
@@ -252,6 +253,39 @@ public class SortedMailboxReceiveOperatorTest {
}
}
+ /// The block this operator emits wraps the very list it buffered the rows
into, and a block handed to a local
+ /// mailbox can still be read after this op chain has been closed. Releasing
the buffer must therefore drop the
+ /// reference, never empty the list.
+ @Test
+ public void shouldNotEmptyTheEmittedBlockOnClose() {
+
when(_mailboxService.getReceivingMailbox(eq(MAILBOX_ID_1))).thenReturn(_mailbox1);
+ Object[] row = new Object[]{1, 1};
+
when(_mailbox1.poll()).thenReturn(OperatorTestUtil.blockWithStats(DATA_SCHEMA,
row),
+ OperatorTestUtil.eosWithEmptyStats());
+ SortedMailboxReceiveOperator operator = getOperator(_stageMetadata1,
RelDistribution.Type.SINGLETON);
+ List<Object[]> resultRows = ((MseBlock.Data)
operator.nextBlock()).asRowHeap().getRows();
+ assertEquals(resultRows.size(), 1);
+
+ operator.close();
+
+ assertEquals(resultRows.size(), 1, "the emitted block must survive the
operator being closed");
+ assertEquals(resultRows.get(0), row);
+ }
+
+ /// The buffered rows must be gone on the error path too, not only once the
sorted block has been produced.
+ @Test
+ public void shouldReleaseBufferedRowsOnError() {
+
when(_mailboxService.getReceivingMailbox(eq(MAILBOX_ID_1))).thenReturn(_mailbox1);
+
when(_mailbox1.poll()).thenReturn(OperatorTestUtil.blockWithStats(DATA_SCHEMA,
new Object[]{1, 1}),
+ OperatorTestUtil.errorWithEmptyStats(new RuntimeException("TEST
ERROR")));
+ SortedMailboxReceiveOperator operator = getOperator(_stageMetadata1,
RelDistribution.Type.SINGLETON);
+ assertTrue(operator.nextBlock().isError());
+
+ operator.cancel(new RuntimeException("TEST ERROR"));
+
+ assertFalse(operator.hasBufferedState());
+ }
+
private SortedMailboxReceiveOperator getOperator(StageMetadata
stageMetadata, RelDistribution.Type distributionType,
DataSchema resultSchema, List<RelFieldCollation> collations, long
deadlineMs) {
OpChainExecutionContext context =
OperatorTestUtil.getOpChainContext(_mailboxService, deadlineMs, stageMetadata);
diff --git
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/set/SetOperatorBufferReleaseTest.java
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/set/SetOperatorBufferReleaseTest.java
new file mode 100644
index 00000000000..01ece5ae152
--- /dev/null
+++
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/set/SetOperatorBufferReleaseTest.java
@@ -0,0 +1,228 @@
+/**
+ * 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.set;
+
+import java.util.List;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
+import org.apache.pinot.query.runtime.blocks.ErrorMseBlock;
+import org.apache.pinot.query.runtime.blocks.MseBlock;
+import org.apache.pinot.query.runtime.operator.BlockListMultiStageOperator;
+import org.apache.pinot.query.runtime.operator.MultiStageOperator;
+import org.apache.pinot.query.runtime.operator.OperatorTestUtil;
+import org.testng.annotations.Test;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertTrue;
+
+
+/// The set-operator half of the release invariant asserted by
+/// [org.apache.pinot.query.runtime.operator.OperatorBufferReleaseTest]: every
operator must drop the row state it is
+/// holding on end of stream, on error and on cancellation alike.
+///
+/// These live in their own class because `hasBufferedState()` is `protected`
and the overrides that matter here are
+/// declared in this package.
+///
+/// The [BinarySetOperator] subclasses all buffer the whole right child in
`_rightRowSet`, and [UnionOperator] buffers
+/// every distinct row it has seen — neither was released on any path before.
+public class SetOperatorBufferReleaseTest {
+ private static final DataSchema SCHEMA =
+ new DataSchema(new String[]{"int_col", "string_col"}, new
ColumnDataType[]{
+ ColumnDataType.INT, ColumnDataType.STRING
+ });
+ private static final RuntimeException ERROR = new RuntimeException("boom");
+
+ @Test
+ public void shouldReleaseRightRowSetOnCancelForEveryBinarySetOperator() {
+ for (BinarySetOperatorFactory factory : binarySetOperators()) {
+ // The right child completes and the left child produces a block, so the
operator is mid-flight.
+ MultiStageOperator right = twoRowOperator();
+ BinarySetOperator operator = factory.create(mixedLeftOperator(), right);
+ String name = operator.getClass().getSimpleName();
+
+ assertTrue(operator.nextBlock().isData(), name + " should emit a data
block");
+ assertTrue(operator.hasBufferedState(), name + " should still hold the
right row set mid-flight");
+
+ operator.cancel(ERROR);
+
+ assertFalse(operator.hasBufferedState(), name + " should release the
right row set on cancel");
+ }
+ }
+
+ @Test
+ public void shouldReleaseRightRowSetOnCloseForEveryBinarySetOperator() {
+ for (BinarySetOperatorFactory factory : binarySetOperators()) {
+ MultiStageOperator right = twoRowOperator();
+ BinarySetOperator operator = factory.create(mixedLeftOperator(), right);
+ String name = operator.getClass().getSimpleName();
+
+ assertTrue(operator.nextBlock().isData());
+ assertTrue(operator.hasBufferedState());
+
+ operator.close();
+
+ assertFalse(operator.hasBufferedState(), name + " should release the
right row set on close");
+ // Terminating twice, by the other path, must be safe.
+ operator.cancel(ERROR);
+ assertFalse(operator.hasBufferedState(), name + " should stay released
after a second termination");
+ }
+ }
+
+ /// When the left child fails, the right row set is released in band with
the error block rather than waiting for
+ /// teardown.
+ @Test
+ public void shouldReleaseRightRowSetWhenTheLeftChildFails() {
+ for (BinarySetOperatorFactory factory : binarySetOperators()) {
+ MultiStageOperator right = twoRowOperator();
+ MultiStageOperator left = mock(MultiStageOperator.class);
+ when(left.nextBlock()).thenReturn(ErrorMseBlock.fromException(ERROR));
+ BinarySetOperator operator = factory.create(left, right);
+ String name = operator.getClass().getSimpleName();
+
+ assertTrue(operator.nextBlock().isError(), name + " should propagate the
error");
+
+ assertFalse(operator.hasBufferedState(), name + " should release the
right row set on the error path");
+ operator.close();
+ assertFalse(operator.hasBufferedState());
+ }
+ }
+
+ /// The right child failing leaves the right row set partially built and no
result will ever be produced from it.
+ @Test
+ public void shouldReleasePartialRightRowSetWhenTheRightChildFails() {
+ for (BinarySetOperatorFactory factory : binarySetOperators()) {
+ MultiStageOperator right = mock(MultiStageOperator.class);
+ when(right.nextBlock()).thenReturn(OperatorTestUtil.block(SCHEMA, new
Object[]{1, "AA"}))
+ .thenReturn(ErrorMseBlock.fromException(ERROR));
+ BinarySetOperator operator = factory.create(mixedLeftOperator(), right);
+ String name = operator.getClass().getSimpleName();
+
+ assertTrue(operator.nextBlock().isError(), name + " should propagate the
error");
+
+ assertFalse(operator.hasBufferedState(), name + " should release the
partial right row set");
+ }
+ }
+
+ /// The emitted rows come from the left child, so releasing the right row
set must not disturb a block already
+ /// handed downstream.
+ @Test
+ public void shouldNotEmptyTheEmittedBlockOnClose() {
+ MultiStageOperator right = twoRowOperator();
+ MultiStageOperator left = new
BlockListMultiStageOperator.Builder(SCHEMA).addRow(1, "AA").buildWithEos();
+ IntersectOperator operator =
+ new IntersectOperator(OperatorTestUtil.getTracingContext(),
List.of(left, right), SCHEMA);
+ List<Object[]> rows = ((MseBlock.Data)
operator.nextBlock()).asRowHeap().getRows();
+ assertEquals(rows.size(), 1);
+
+ operator.close();
+
+ assertEquals(rows.size(), 1, "the emitted block must survive the operator
being closed");
+ assertEquals(rows.get(0), new Object[]{1, "AA"});
+ }
+
+ @Test
+ public void shouldReleaseUnionSeenRecordsOnCancel() {
+ MultiStageOperator first = mock(MultiStageOperator.class);
+ when(first.nextBlock()).thenReturn(OperatorTestUtil.block(SCHEMA, new
Object[]{1, "AA"}));
+ MultiStageOperator second = mock(MultiStageOperator.class);
+ UnionOperator operator = new
UnionOperator(OperatorTestUtil.getTracingContext(), List.of(first, second),
SCHEMA);
+
+ assertTrue(operator.nextBlock().isData());
+ assertTrue(operator.hasBufferedState(), "the seen records should be
tracked before termination");
+
+ operator.cancel(ERROR);
+
+ assertFalse(operator.hasBufferedState());
+ }
+
+ @Test
+ public void shouldReleaseUnionSeenRecordsOnError() {
+ MultiStageOperator first = mock(MultiStageOperator.class);
+ when(first.nextBlock()).thenReturn(OperatorTestUtil.block(SCHEMA, new
Object[]{1, "AA"}))
+ .thenReturn(ErrorMseBlock.fromException(ERROR));
+ MultiStageOperator second = mock(MultiStageOperator.class);
+ UnionOperator operator = new
UnionOperator(OperatorTestUtil.getTracingContext(), List.of(first, second),
SCHEMA);
+
+ assertTrue(operator.nextBlock().isData());
+ assertTrue(operator.hasBufferedState());
+ assertTrue(operator.nextBlock().isError());
+
+ assertFalse(operator.hasBufferedState(), "the seen records should be
released on the error path");
+ operator.close();
+ assertFalse(operator.hasBufferedState());
+ }
+
+ /// Draining every input successfully must release too — the success path is
the common one.
+ @Test
+ public void shouldReleaseUnionSeenRecordsOnEndOfStream() {
+ MultiStageOperator first = new
BlockListMultiStageOperator.Builder(SCHEMA).addRow(1, "AA").buildWithEos();
+ MultiStageOperator second = new
BlockListMultiStageOperator.Builder(SCHEMA).addRow(2, "BB").buildWithEos();
+ UnionOperator operator = new
UnionOperator(OperatorTestUtil.getTracingContext(), List.of(first, second),
SCHEMA);
+
+ MseBlock block = operator.nextBlock();
+ while (block.isData()) {
+ block = operator.nextBlock();
+ }
+
+ assertTrue(block.isSuccess());
+ assertFalse(operator.hasBufferedState(), "the seen records should be
released once every input is drained");
+ }
+
+ /// The emitted blocks reference the left rows directly, so releasing must
not touch them.
+ @Test
+ public void shouldNotEmptyTheBlockEmittedByUnion() {
+ MultiStageOperator first = new
BlockListMultiStageOperator.Builder(SCHEMA).addRow(1, "AA").buildWithEos();
+ MultiStageOperator second = new
BlockListMultiStageOperator.Builder(SCHEMA).addRow(2, "BB").buildWithEos();
+ UnionOperator operator = new
UnionOperator(OperatorTestUtil.getTracingContext(), List.of(first, second),
SCHEMA);
+ List<Object[]> rows = ((MseBlock.Data)
operator.nextBlock()).asRowHeap().getRows();
+ assertEquals(rows.size(), 1);
+
+ operator.close();
+
+ assertEquals(rows.size(), 1, "the emitted block must survive the operator
being closed");
+ assertEquals(rows.get(0), new Object[]{1, "AA"});
+ }
+
+ private static MultiStageOperator twoRowOperator() {
+ // Two rows so that the set is still non-empty after the left child has
matched one of them.
+ return new BlockListMultiStageOperator.Builder(SCHEMA).addRow(1,
"AA").addRow(2, "BB").buildWithEos();
+ }
+
+ /// One row the right child also has and one it does not, so that every
flavour emits a data block: INTERSECT and
+ /// INTERSECT ALL keep the matching row, MINUS and MINUS ALL keep the other
one.
+ private static MultiStageOperator mixedLeftOperator() {
+ return new BlockListMultiStageOperator.Builder(SCHEMA).addRow(1,
"AA").addRow(3, "CC").buildWithEos();
+ }
+
+ private static List<BinarySetOperatorFactory> binarySetOperators() {
+ return List.of(
+ (left, right) -> new
IntersectOperator(OperatorTestUtil.getTracingContext(), List.of(left, right),
SCHEMA),
+ (left, right) -> new
IntersectAllOperator(OperatorTestUtil.getTracingContext(), List.of(left,
right), SCHEMA),
+ (left, right) -> new
MinusOperator(OperatorTestUtil.getTracingContext(), List.of(left, right),
SCHEMA),
+ (left, right) -> new
MinusAllOperator(OperatorTestUtil.getTracingContext(), List.of(left, right),
SCHEMA));
+ }
+
+ @FunctionalInterface
+ private interface BinarySetOperatorFactory {
+ BinarySetOperator create(MultiStageOperator left, MultiStageOperator
right);
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]