This is an automated email from the ASF dual-hosted git repository.
xiangfu0 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 310d7604fcd Materialize non-order-by columns late on the sorted
order-by path (#19879)
310d7604fcd is described below
commit 310d7604fcd89f8348eeaabb9f56de8a9b74d163
Author: Arunkumar Saravanan <[email protected]>
AuthorDate: Thu Oct 22 11:26:29 2026 +0530
Materialize non-order-by columns late on the sorted order-by path (#19879)
---
.../common/utils/config/QueryOptionsUtils.java | 8 +
.../query/LinearSelectionOrderByOperator.java | 216 ++++++++++++++++++---
.../SelectionPartiallyOrderedByDescOperation.java | 11 +-
.../SelectionPartiallyOrderedByLinearOperator.java | 11 +-
.../apache/pinot/core/plan/SelectionPlanNode.java | 27 ++-
...nnerSegmentSelectionSingleValueQueriesTest.java | 94 ++++++++-
.../queries/NullHandlingEnabledQueriesTest.java | 34 ++++
.../apache/pinot/spi/utils/CommonConstants.java | 10 +
8 files changed, 377 insertions(+), 34 deletions(-)
diff --git
a/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java
b/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java
index 936c968aaf0..491417d7315 100644
---
a/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java
+++
b/pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java
@@ -964,6 +964,14 @@ public class QueryOptionsUtils {
return queryOptions.getOrDefault(QueryOptionKey.WORKLOAD_NAME,
CommonConstants.Accounting.DEFAULT_WORKLOAD_NAME);
}
+ public static boolean isLateMaterializeSortedOrderByEnabled(Map<String,
String> queryOptions) {
+ String value =
queryOptions.get(QueryOptionKey.LATE_MATERIALIZE_SORTED_ORDER_BY);
+ if (value == null) {
+ return QueryOptionKey.DEFAULT_LATE_MATERIALIZE_SORTED_ORDER_BY;
+ }
+ return Boolean.parseBoolean(value);
+ }
+
public static boolean isReverseOrderAllowed(Map<String, String>
queryOptions) {
String value = queryOptions.get(QueryOptionKey.ALLOW_REVERSE_ORDER);
if (value == null) {
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java
b/pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java
index 1e54d9526ed..b5a16551b25 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java
@@ -22,13 +22,19 @@ import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.CaseFormat;
import com.google.common.base.Preconditions;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
+import java.util.Map;
import java.util.PriorityQueue;
+import java.util.Set;
import java.util.function.IntFunction;
import java.util.function.Supplier;
import java.util.stream.Collectors;
+import javax.annotation.Nullable;
import org.apache.pinot.common.request.context.ExpressionContext;
import org.apache.pinot.common.request.context.OrderByExpressionContext;
import org.apache.pinot.common.utils.DataSchema;
@@ -36,15 +42,21 @@ import org.apache.pinot.core.common.BlockValSet;
import org.apache.pinot.core.common.RowBasedBlockValueFetcher;
import org.apache.pinot.core.operator.BaseOperator;
import org.apache.pinot.core.operator.BaseProjectOperator;
+import org.apache.pinot.core.operator.BitmapDocIdSetOperator;
import org.apache.pinot.core.operator.ColumnContext;
import org.apache.pinot.core.operator.ExecutionStatistics;
import org.apache.pinot.core.operator.ExplainAttributeBuilder;
+import org.apache.pinot.core.operator.ProjectionOperator;
+import org.apache.pinot.core.operator.ProjectionOperatorUtils;
import org.apache.pinot.core.operator.blocks.ValueBlock;
import org.apache.pinot.core.operator.blocks.results.SelectionResultsBlock;
+import org.apache.pinot.core.operator.transform.TransformOperator;
import org.apache.pinot.core.query.request.context.QueryContext;
import org.apache.pinot.core.query.selection.SelectionOperatorUtils;
import org.apache.pinot.core.query.utils.OrderByComparatorFactory;
import org.apache.pinot.segment.spi.IndexSegment;
+import org.apache.pinot.segment.spi.datasource.DataSource;
+import org.apache.pinot.spi.query.QueryScanCostContext;
import org.roaringbitmap.RoaringBitmap;
@@ -75,11 +87,25 @@ public abstract class LinearSelectionOrderByOperator
extends BaseOperator<Select
protected final int _numRowsToKeep;
protected final Comparator<Object[]> _comparator;
protected final Supplier<ListBuilder> _listBuilderSupplier;
+ protected final int _numOrderByExpressions;
+ /// When true the project operator only produces the order-by expressions,
and the remaining output expressions are
+ /// fetched for the rows that survive the limit. Rows carry their doc id at
index [#_numOrderByExpressions] until
+ /// that fetch overwrites it.
+ protected final boolean _lateMaterialize;
+ private long _numEntriesScannedPostFilterLate;
/// @param expressions Order-by expressions must be at the head of
the list.
/// @param numSortedExpressions Number of expressions in the order-by
expressions that are sorted.
public LinearSelectionOrderByOperator(IndexSegment indexSegment,
QueryContext queryContext,
List<ExpressionContext> expressions, BaseProjectOperator<?>
projectOperator, int numSortedExpressions) {
+ this(indexSegment, queryContext, expressions, projectOperator,
numSortedExpressions, false);
+ }
+
+ /// @param lateMaterialize whether `projectOperator` produces only the
order-by expressions, leaving the remaining
+ /// output expressions to be fetched once the limit
has been applied.
+ public LinearSelectionOrderByOperator(IndexSegment indexSegment,
QueryContext queryContext,
+ List<ExpressionContext> expressions, BaseProjectOperator<?>
projectOperator, int numSortedExpressions,
+ boolean lateMaterialize) {
_indexSegment = indexSegment;
_queryContext = queryContext;
_nullHandlingEnabled = queryContext.isNullHandlingEnabled();
@@ -90,10 +116,20 @@ public abstract class LinearSelectionOrderByOperator
extends BaseOperator<Select
assert _orderByExpressions != null;
int numOrderByExpressions = _orderByExpressions.size();
+ _numOrderByExpressions = numOrderByExpressions;
+ _lateMaterialize = lateMaterialize;
+ // Rows carry the doc id at index numOrderByExpressions, so there must be
at least one expression after the
+ // order-by ones for that slot to exist (and something worth deferring).
+ Preconditions.checkArgument(!lateMaterialize || expressions.size() >
numOrderByExpressions,
+ "Late materialization requires output expressions beyond the %s
order-by ones, got %s",
+ numOrderByExpressions, expressions.size());
+
_alreadySorted = expressions.subList(0, numSortedExpressions);
_toSort = expressions.subList(numSortedExpressions, numOrderByExpressions);
- _columnContexts = new ColumnContext[_expressions.size()];
+ // Only the projected expressions can be resolved against the project
operator. When materialising late it holds
+ // the order-by expressions alone, which is also all the comparator needs.
+ _columnContexts = new ColumnContext[lateMaterialize ?
numOrderByExpressions : _expressions.size()];
for (int i = 0; i < _columnContexts.length; i++) {
ExpressionContext expression = _expressions.get(i);
_columnContexts[i] = _projectOperator.getResultColumnContext(expression);
@@ -124,39 +160,72 @@ public abstract class LinearSelectionOrderByOperator
extends BaseOperator<Select
public ExecutionStatistics getExecutionStatistics() {
long numEntriesScannedInFilter =
_projectOperator.getExecutionStatistics().getNumEntriesScannedInFilter();
int numDocsScanned = getNumDocsScanned();
- long numEntriesScannedPostFilter = (long) numDocsScanned *
_projectOperator.getNumColumnsProjected();
+ long numEntriesScannedPostFilter =
+ (long) numDocsScanned * _projectOperator.getNumColumnsProjected() +
_numEntriesScannedPostFilterLate;
int numTotalDocs = _indexSegment.getSegmentMetadata().getTotalDocs();
return new ExecutionStatistics(numDocsScanned, numEntriesScannedInFilter,
numEntriesScannedPostFilter,
numTotalDocs);
}
+ /// Number of expressions the project operator actually produces: all of
them, or just the order-by prefix when
+ /// materialising late.
+ protected int getNumProjectedExpressions() {
+ return _columnContexts.length;
+ }
+
protected IntFunction<Object[]> fetchBlock(ValueBlock valueBlock,
BlockValSet[] blockValSets) {
- int numExpressions = _expressions.size();
+ int numProjected = getNumProjectedExpressions();
- for (int i = 0; i < numExpressions; i++) {
+ for (int i = 0; i < numProjected; i++) {
ExpressionContext expression = _expressions.get(i);
blockValSets[i] = valueBlock.getBlockValueSet(expression);
}
RowBasedBlockValueFetcher blockValueFetcher = new
RowBasedBlockValueFetcher(blockValSets);
- if (!_nullHandlingEnabled) {
- return blockValueFetcher::getRow;
+ RoaringBitmap[] nullBitmaps;
+ if (_nullHandlingEnabled) {
+ nullBitmaps = new RoaringBitmap[numProjected];
+ for (int i = 0; i < numProjected; i++) {
+ nullBitmaps[i] = blockValSets[i].getNullBitmap();
+ }
+ } else {
+ nullBitmaps = null;
}
- RoaringBitmap[] nullBitmaps = new RoaringBitmap[numExpressions];
- for (int i = 0; i < numExpressions; i++) {
- nullBitmaps[i] = blockValSets[i].getNullBitmap();
+
+ if (!_lateMaterialize) {
+ if (nullBitmaps == null) {
+ return blockValueFetcher::getRow;
+ }
+ return (rowIdInBlock) -> {
+ Object[] row = blockValueFetcher.getRow(rowIdInBlock);
+ applyNulls(row, nullBitmaps, rowIdInBlock);
+ return row;
+ };
}
- return (docId) -> {
- Object[] row = blockValueFetcher.getRow(docId);
- for (int colId = 0; colId < nullBitmaps.length; colId++) {
- if (nullBitmaps[colId] != null && nullBitmaps[colId].contains(docId)) {
- row[colId] = null;
- }
+
+ // Leave room for the expressions fetched after the limit is applied, and
carry the doc id in the first of those
+ // slots so the fetch knows which documents to read. That slot is
overwritten by the first fetched value.
+ int numExpressions = _expressions.size();
+ int[] docIds = valueBlock.getDocIds();
+ return (rowIdInBlock) -> {
+ Object[] row = new Object[numExpressions];
+ blockValueFetcher.getRow(rowIdInBlock, row, 0);
+ if (nullBitmaps != null) {
+ applyNulls(row, nullBitmaps, rowIdInBlock);
}
+ row[_numOrderByExpressions] = docIds[rowIdInBlock];
return row;
};
}
+ private static void applyNulls(Object[] row, RoaringBitmap[] nullBitmaps,
int rowIdInBlock) {
+ for (int colId = 0; colId < nullBitmaps.length; colId++) {
+ if (nullBitmaps[colId] != null &&
nullBitmaps[colId].contains(rowIdInBlock)) {
+ row[colId] = null;
+ }
+ }
+ }
+
protected abstract int getNumDocsScanned();
/// Returns a list of rows sorted that:
@@ -198,6 +267,9 @@ public abstract class LinearSelectionOrderByOperator
extends BaseOperator<Select
sb.append(", unsortedList: ");
concatList(sb, _toSort);
+ if (_lateMaterialize) {
+ sb.append(", lateMaterialized: true");
+ }
sb.append(", rest: ");
concatList(sb, _expressions.subList(_alreadySorted.size() +
_toSort.size(), _expressions.size()));
@@ -215,6 +287,9 @@ public abstract class LinearSelectionOrderByOperator
extends BaseOperator<Select
List<String> toSort =
_toSort.stream().map(ExpressionContext::toString).collect(Collectors.toList());
List<String> restStr =
rest.stream().map(ExpressionContext::toString).collect(Collectors.toList());
+ if (_lateMaterialize) {
+ attributeBuilder.putString("lateMaterialized", "true");
+ }
attributeBuilder.putStringList("sortedList", sortedList)
.putStringList("unsortedList", toSort)
.putStringList("rest", restStr);
@@ -234,25 +309,122 @@ public abstract class LinearSelectionOrderByOperator
extends BaseOperator<Select
@Override
protected SelectionResultsBlock getNextBlock() {
- return new SelectionResultsBlock(createDataSchema(),
fetch(_listBuilderSupplier), _comparator, _queryContext);
+ List<Object[]> rows = fetch(_listBuilderSupplier);
+ if (!_lateMaterialize) {
+ return new SelectionResultsBlock(createDataSchema(), rows, _comparator,
_queryContext);
+ }
+ return fetchRemainingExpressions(rows);
}
- protected DataSchema createDataSchema() {
+ /// Fills the output expressions that were not projected during the scan,
for the rows that survived the limit.
+ ///
+ /// The rows are visited in doc-id order because that is the order
[BitmapDocIdSetOperator#ascending] returns, while
+ /// `rows` keeps the result order. Both views hold the same row arrays, so
filling one fills the other.
+ private SelectionResultsBlock fetchRemainingExpressions(List<Object[]> rows)
{
int numExpressions = _expressions.size();
+ List<ExpressionContext> nonOrderByExpressions =
_expressions.subList(_numOrderByExpressions, numExpressions);
+ int numNonOrderByExpressions = nonOrderByExpressions.size();
+
+ int numRows = rows.size();
+ Object[][] rowsByDocId = rows.toArray(new Object[numRows][]);
+ Arrays.sort(rowsByDocId, Comparator.comparingInt(row -> (int)
row[_numOrderByExpressions]));
+ RoaringBitmap docIds = new RoaringBitmap();
+ for (Object[] row : rowsByDocId) {
+ docIds.add((int) row[_numOrderByExpressions]);
+ }
+ // rowsByDocId is filled by walking the bitmap in ascending order, so the
two must line up one for one.
+ // They do only because surviving rows have distinct doc ids. If that ever
stopped holding, the bitmap would
+ // dedupe and the trailing rows would keep their unfilled values instead
of failing -- wrong data returned,
+ // not an error. Checked rather than asserted because this build never
runs with -ea, and the cost is one
+ // cardinality read per query against a projection pass over every
surviving row.
+ Preconditions.checkState(docIds.getCardinality() == numRows,
+ "Expected one distinct doc id per surviving row, got %s doc ids for %s
rows", docIds.getCardinality(),
+ numRows);
+
+ Set<String> columns = new HashSet<>();
+ for (ExpressionContext expression : nonOrderByExpressions) {
+ expression.getColumns(columns);
+ }
+ Map<String, DataSource> dataSourceMap = new HashMap<>();
+ for (String column : columns) {
+ // Same resolution the eager path uses: ProjectPlanNode resolves every
selected column through this exact
+ // call, including the ones fetched here. The schema is only
dereferenced for a column the segment does not
+ // physically have, and it is set for every server query in
ServerQueryExecutorV1Impl.
+ dataSourceMap.put(column, _indexSegment.getDataSource(column,
_queryContext.getSchema()));
+ }
+ // Distinct physical columns. A column that is both an order-by column and
referenced by a non-order-by
+ // expression is read again here and counted again, which slightly
over-reports entries scanned.
+ int numColumns = columns.size();
+
+ BitmapDocIdSetOperator docIdSetOperator =
BitmapDocIdSetOperator.ascending(docIds, numRows);
+ try (ProjectionOperator projectionOperator =
+ ProjectionOperatorUtils.getProjectionOperator(dataSourceMap,
docIdSetOperator, _queryContext)) {
+ TransformOperator transformOperator =
+ new TransformOperator(_queryContext, projectionOperator,
nonOrderByExpressions);
+
+ BlockValSet[] blockValSets = new BlockValSet[numNonOrderByExpressions];
+ int rowBaseId = 0;
+ ValueBlock valueBlock;
+ while ((valueBlock = transformOperator.nextBlock()) != null) {
+ for (int i = 0; i < numNonOrderByExpressions; i++) {
+ blockValSets[i] =
valueBlock.getBlockValueSet(nonOrderByExpressions.get(i));
+ }
+ RowBasedBlockValueFetcher blockValueFetcher = new
RowBasedBlockValueFetcher(blockValSets);
+ int numDocsFetched = valueBlock.getNumDocs();
+ for (int i = 0; i < numDocsFetched; i++) {
+ blockValueFetcher.getRow(i, rowsByDocId[rowBaseId + i],
_numOrderByExpressions);
+ }
+ if (_nullHandlingEnabled) {
+ RoaringBitmap[] nullBitmaps = new
RoaringBitmap[numNonOrderByExpressions];
+ for (int i = 0; i < numNonOrderByExpressions; i++) {
+ nullBitmaps[i] = blockValSets[i].getNullBitmap();
+ }
+ for (int i = 0; i < numDocsFetched; i++) {
+ Object[] row = rowsByDocId[rowBaseId + i];
+ for (int colId = 0; colId < numNonOrderByExpressions; colId++) {
+ if (nullBitmaps[colId] != null &&
nullBitmaps[colId].contains(i)) {
+ row[_numOrderByExpressions + colId] = null;
+ }
+ }
+ }
+ }
+ long entriesScanned = (long) numDocsFetched * numColumns;
+ _numEntriesScannedPostFilterLate += entriesScanned;
+ QueryScanCostContext scanCost = getScanCostContext();
+ if (scanCost != null) {
+ scanCost.addEntriesScannedPostFilter(entriesScanned);
+ }
+ rowBaseId += numDocsFetched;
+ }
+ return new SelectionResultsBlock(createDataSchema(transformOperator,
nonOrderByExpressions), rows, _comparator,
+ _queryContext);
+ }
+ }
- // Create the data schema
+ /// Data schema covering both cases. Types for the expressions the scan
projected come from the project
+ /// operator's column contexts; types for any expressions fetched late come
from the transform operator built
+ /// for that fetch. `lateExpressions` is empty when nothing was materialised
late, which leaves the first
+ /// branch covering every expression.
+ private DataSchema createDataSchema(@Nullable TransformOperator
lateTransformOperator,
+ List<ExpressionContext> lateExpressions) {
+ int numExpressions = _expressions.size();
+ int numProjected = numExpressions - lateExpressions.size();
String[] columnNames = new String[numExpressions];
DataSchema.ColumnDataType[] columnDataTypes = new
DataSchema.ColumnDataType[numExpressions];
- for (int i = 0; i < columnNames.length; i++) {
- columnNames[i] = _expressions.get(i).toString();
- }
for (int i = 0; i < numExpressions; i++) {
+ columnNames[i] = _expressions.get(i).toString();
+ ColumnContext columnContext = i < numProjected ? _columnContexts[i]
+ : lateTransformOperator.getResultColumnContext(lateExpressions.get(i
- numProjected));
columnDataTypes[i] =
-
DataSchema.ColumnDataType.fromDataType(_columnContexts[i].getDataType(),
_columnContexts[i].isSingleValue());
+ DataSchema.ColumnDataType.fromDataType(columnContext.getDataType(),
columnContext.isSingleValue());
}
return new DataSchema(columnNames, columnDataTypes);
}
+ protected DataSchema createDataSchema() {
+ return createDataSchema(null, List.of());
+ }
+
/// A private class used to build a partially sorted list by adding
partially sorted data.
///
/// Specifically, this class has been designed to receive successive calls
to [#add(Object[])] follow by a
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/operator/query/SelectionPartiallyOrderedByDescOperation.java
b/pinot-core/src/main/java/org/apache/pinot/core/operator/query/SelectionPartiallyOrderedByDescOperation.java
index 6d348885466..396bd01ab02 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/operator/query/SelectionPartiallyOrderedByDescOperation.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/operator/query/SelectionPartiallyOrderedByDescOperation.java
@@ -44,7 +44,13 @@ public class SelectionPartiallyOrderedByDescOperation
extends LinearSelectionOrd
public SelectionPartiallyOrderedByDescOperation(IndexSegment indexSegment,
QueryContext queryContext,
List<ExpressionContext> expressions, BaseProjectOperator<?>
projectOperator, int numSortedExpressions) {
- super(indexSegment, queryContext, expressions, projectOperator,
numSortedExpressions);
+ this(indexSegment, queryContext, expressions, projectOperator,
numSortedExpressions, false);
+ }
+
+ public SelectionPartiallyOrderedByDescOperation(IndexSegment indexSegment,
QueryContext queryContext,
+ List<ExpressionContext> expressions, BaseProjectOperator<?>
projectOperator, int numSortedExpressions,
+ boolean lateMaterialize) {
+ super(indexSegment, queryContext, expressions, projectOperator,
numSortedExpressions, lateMaterialize);
assert queryContext.getOrderByExpressions() != null;
Preconditions.checkArgument(!queryContext.getOrderByExpressions().stream()
.filter(expr -> expr.getExpression().getType() ==
ExpressionContext.Type.IDENTIFIER)
@@ -62,8 +68,7 @@ public class SelectionPartiallyOrderedByDescOperation extends
LinearSelectionOrd
// the same DataBlockCache, so they may be ephemeral and being overridden
by the next block.
// The only alternative we have right now is to retrieve the last LIMIT
elements from each block
- int numExpressions = _expressions.size();
- BlockValSet[] blockValSets = new BlockValSet[numExpressions];
+ BlockValSet[] blockValSets = new BlockValSet[getNumProjectedExpressions()];
List<Object[]> localBestRows = new ArrayList<>();
ValueBlock valueBlock;
while ((valueBlock = _projectOperator.nextBlock()) != null) {
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/operator/query/SelectionPartiallyOrderedByLinearOperator.java
b/pinot-core/src/main/java/org/apache/pinot/core/operator/query/SelectionPartiallyOrderedByLinearOperator.java
index 6d71289c1c7..33a43a144c8 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/operator/query/SelectionPartiallyOrderedByLinearOperator.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/operator/query/SelectionPartiallyOrderedByLinearOperator.java
@@ -46,7 +46,13 @@ public class SelectionPartiallyOrderedByLinearOperator
extends LinearSelectionOr
public SelectionPartiallyOrderedByLinearOperator(IndexSegment indexSegment,
QueryContext queryContext,
List<ExpressionContext> expressions, BaseProjectOperator<?>
projectOperator, int numSortedExpressions) {
- super(indexSegment, queryContext, expressions, projectOperator,
numSortedExpressions);
+ this(indexSegment, queryContext, expressions, projectOperator,
numSortedExpressions, false);
+ }
+
+ public SelectionPartiallyOrderedByLinearOperator(IndexSegment indexSegment,
QueryContext queryContext,
+ List<ExpressionContext> expressions, BaseProjectOperator<?>
projectOperator, int numSortedExpressions,
+ boolean lateMaterialize) {
+ super(indexSegment, queryContext, expressions, projectOperator,
numSortedExpressions, lateMaterialize);
boolean firstColIsAsc = queryContext.getOrderByExpressions().stream()
.filter(expr -> expr.getExpression().getType() ==
ExpressionContext.Type.IDENTIFIER)
.findFirst()
@@ -61,8 +67,7 @@ public class SelectionPartiallyOrderedByLinearOperator
extends LinearSelectionOr
@Override
protected List<Object[]> fetch(Supplier<ListBuilder> listBuilderSupplier) {
- int numExpressions = _expressions.size();
- BlockValSet[] blockValSets = new BlockValSet[numExpressions];
+ BlockValSet[] blockValSets = new BlockValSet[getNumProjectedExpressions()];
ListBuilder listBuilder = listBuilderSupplier.get();
ValueBlock valueBlock;
while ((valueBlock = _projectOperator.nextBlock()) != null) {
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/plan/SelectionPlanNode.java
b/pinot-core/src/main/java/org/apache/pinot/core/plan/SelectionPlanNode.java
index 029f4811c44..04b11cec49b 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/plan/SelectionPlanNode.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/plan/SelectionPlanNode.java
@@ -88,17 +88,38 @@ public class SelectionPlanNode implements PlanNode {
maxDocsPerCall = Math.min(limit + _queryContext.getOffset(),
DocIdSetPlanNode.MAX_DOC_PER_CALL);
}
- BaseProjectOperator<?> projectOperator = getSortedByProject(expressions,
maxDocsPerCall, orderByExpressions);
+ // Whether the scan can iterate in query order, and so stop once the
list builder is full. Ascending always
+ // can; descending only when reverse iteration is permitted, because
DocIdSetPlanNode only produces an
+ // ascending DocIdSetOperator. getSortedByProject below performs the
actual switch and decides for real; this
+ // is a conservative prediction, and being wrong only costs the
optimization, never correctness.
boolean asc = orderByExpressions.get(0).isAsc();
+ boolean canIterateInQueryOrder =
+ asc ||
QueryOptionsUtils.isReverseOrderAllowed(_queryContext.getQueryOptions());
+
+ // Projecting only the order-by expressions and fetching the rest for
the rows that survive the limit pays off
+ // only when the scan cannot stop at the limit. The linear operator
stops as soon as the list builder is full,
+ // which with a fully sorted order-by prefix is after limit+offset
documents, so there is nothing to save
+ // there. The descending operator has to read the whole match set
because there is no descending cursor.
+ boolean scanBoundedByLimit = canIterateInQueryOrder &&
sortedColumnsPrefixSize == orderByExpressions.size();
+ boolean lateMaterialize = expressions.size() > numOrderByExpressions &&
!scanBoundedByLimit
+ &&
QueryOptionsUtils.isLateMaterializeSortedOrderByEnabled(_queryContext.getQueryOptions());
+
+ // Build the project operator once. Constructing one is not free -- it
resolves a DataSource and a
+ // ColumnContext per selected column, and some implementations start
prefetching those columns -- so a
+ // build-and-discard probe would read exactly the columns this is trying
to avoid reading.
+ List<ExpressionContext> expressionsToProject =
+ lateMaterialize ? expressions.subList(0, numOrderByExpressions) :
expressions;
+ BaseProjectOperator<?> projectOperator =
+ getSortedByProject(expressionsToProject, maxDocsPerCall,
orderByExpressions);
// Remember that we cannot use asc == projectOperator.isAscending()
because empty operators are considered
// both ascending and descending
DocIdOrderedOperator.DocIdOrder queryOrder =
DocIdOrderedOperator.DocIdOrder.fromAsc(asc);
if (projectOperator.isCompatibleWith(queryOrder)) {
return new SelectionPartiallyOrderedByLinearOperator(_indexSegment,
_queryContext, expressions, projectOperator,
- sortedColumnsPrefixSize);
+ sortedColumnsPrefixSize, lateMaterialize);
} else {
return new SelectionPartiallyOrderedByDescOperation(_indexSegment,
_queryContext, expressions, projectOperator,
- sortedColumnsPrefixSize);
+ sortedColumnsPrefixSize, lateMaterialize);
}
}
if (numOrderByExpressions == expressions.size()) {
diff --git
a/pinot-core/src/test/java/org/apache/pinot/queries/InnerSegmentSelectionSingleValueQueriesTest.java
b/pinot-core/src/test/java/org/apache/pinot/queries/InnerSegmentSelectionSingleValueQueriesTest.java
index 333b8db5de2..1a35f440ef9 100644
---
a/pinot-core/src/test/java/org/apache/pinot/queries/InnerSegmentSelectionSingleValueQueriesTest.java
+++
b/pinot-core/src/test/java/org/apache/pinot/queries/InnerSegmentSelectionSingleValueQueriesTest.java
@@ -290,15 +290,16 @@ public class InnerSegmentSelectionSingleValueQueriesTest
extends BaseSingleValue
public void testSelectionOrderBySingleSortedColumnDescDidOrder() {
String orderBy = " ORDER BY column5 DESC";
BaseOperator<SelectionResultsBlock> selectionOrderByOperator = getOperator(
- "SET allowReverseOrder = false; " + SELECTION_QUERY + orderBy);
+ "SET allowReverseOrder = false; SET lateMaterializeSortedOrderBy =
true; " + SELECTION_QUERY + orderBy);
assertTrue(selectionOrderByOperator instanceof
SelectionPartiallyOrderedByDescOperation, "Expected: "
+ SelectionPartiallyOrderedByLinearOperator.class + ", found: " +
selectionOrderByOperator.getClass());
SelectionResultsBlock resultsBlock = selectionOrderByOperator.nextBlock();
ExecutionStatistics executionStatistics =
selectionOrderByOperator.getExecutionStatistics();
assertEquals(executionStatistics.getNumDocsScanned(), 30000L);
assertEquals(executionStatistics.getNumEntriesScannedInFilter(), 0L);
- // 30000 * (3 columns)
- assertEquals(executionStatistics.getNumEntriesScannedPostFilter(), 90000L);
+ // The scan reads only the order-by column (30000 * 1); column1 and
column11 are fetched just for the
+ // 10 rows that survive the limit (10 * 2). Before late materialisation
this was 30000 * 3.
+ assertEquals(executionStatistics.getNumEntriesScannedPostFilter(), 30000L
+ 20L);
assertEquals(executionStatistics.getNumTotalDocs(), 30000L);
DataSchema dataSchema = resultsBlock.getDataSchema();
assertEquals(dataSchema.getColumnNames(), new String[]{"column5",
"column1", "column11"});
@@ -311,6 +312,17 @@ public class InnerSegmentSelectionSingleValueQueriesTest
extends BaseSingleValue
assertEquals(lastRow[0], "gFuH");
}
+ /// The optimization is opt-in, so the same query without the option must
still read every selected column
+ /// for the whole match set. Pins the default; delete or invert this when
the default flips.
+ @Test
+ public void testSelectionOrderBySingleSortedColumnDescIsEagerByDefault() {
+ BaseOperator<SelectionResultsBlock> operator =
+ getOperator("SET allowReverseOrder = false; " + SELECTION_QUERY + "
ORDER BY column5 DESC");
+ operator.nextBlock();
+ // 30000 docs * 3 selected columns, as before late materialisation existed.
+
assertEquals(operator.getExecutionStatistics().getNumEntriesScannedPostFilter(),
90000L);
+ }
+
@Test
public void testSelectionOrderBySingleSortedColumnDescReverseOrder() {
String orderBy = " ORDER BY column5 DESC";
@@ -725,4 +737,80 @@ public class InnerSegmentSelectionSingleValueQueriesTest
extends BaseSingleValue
assertEquals(brokerResponse.getOfflineThreadMemAllocatedBytes(), 0);
assertEquals(brokerResponse.getRealtimeThreadMemAllocatedBytes(), 0);
}
+
+ /// Runs `query` with late materialisation on and off and asserts the
answers are identical. column5 is the sorted
+ /// column in this fixture.
+ ///
+ /// @param expectFewerEntries whether late materialisation should read fewer
entries post-filter. It should not when
+ /// the scan already stops at the limit, which is
the case for the linear operator with a
+ /// fully sorted order-by prefix.
+ private void assertSameAnswerWithAndWithoutLateMaterialization(String query,
boolean expectFewerEntries) {
+ BaseOperator<SelectionResultsBlock> lateOperator = getOperator("SET
lateMaterializeSortedOrderBy = true; " + query);
+ SelectionResultsBlock lateBlock = lateOperator.nextBlock();
+ ExecutionStatistics lateStats = lateOperator.getExecutionStatistics();
+
+ BaseOperator<SelectionResultsBlock> eagerOperator =
+ getOperator("SET lateMaterializeSortedOrderBy = false; " + query);
+ SelectionResultsBlock eagerBlock = eagerOperator.nextBlock();
+ ExecutionStatistics eagerStats = eagerOperator.getExecutionStatistics();
+
+ DataSchema lateSchema = lateBlock.getDataSchema();
+ DataSchema eagerSchema = eagerBlock.getDataSchema();
+ assertEquals(lateSchema.getColumnNames(), eagerSchema.getColumnNames());
+ assertEquals(lateSchema.getColumnDataTypes(),
eagerSchema.getColumnDataTypes());
+
+ List<Object[]> lateRows = lateBlock.getRows();
+ List<Object[]> eagerRows = eagerBlock.getRows();
+ assertEquals(lateRows.size(), eagerRows.size());
+ for (int i = 0; i < lateRows.size(); i++) {
+ assertEquals(lateRows.get(i), eagerRows.get(i), "row " + i + " differs
for query: " + query);
+ }
+ assertEquals(lateStats.getNumDocsScanned(),
eagerStats.getNumDocsScanned());
+
+ long late = lateStats.getNumEntriesScannedPostFilter();
+ long eager = eagerStats.getNumEntriesScannedPostFilter();
+ if (expectFewerEntries) {
+ assertTrue(late < eager, "expected fewer entries post-filter, but read "
+ late + " vs " + eager
+ + " for query: " + query);
+ } else {
+ assertEquals(late, eager, "expected the same entries post-filter for
query: " + query);
+ }
+ }
+
+ /// The linear operator stops at the limit, so there is nothing to defer and
the plan keeps projecting everything.
+ @Test
+ public void testLateMaterializationSkippedWhenScanStopsAtLimit() {
+ assertSameAnswerWithAndWithoutLateMaterialization(SELECTION_QUERY + "
ORDER BY column5", false);
+ assertSameAnswerWithAndWithoutLateMaterialization(
+ "SET allowReverseOrder = true; " + SELECTION_QUERY + " ORDER BY
column5 DESC", false);
+ }
+
+ /// Descending without a reverse cursor reads the whole match set, so
deferring the other columns is a real saving.
+ @Test
+ public void testLateMaterializationSortedColumnDesc() {
+ assertSameAnswerWithAndWithoutLateMaterialization(
+ "SET allowReverseOrder = false; " + SELECTION_QUERY + " ORDER BY
column5 DESC", true);
+ }
+
+ @Test
+ public void testLateMaterializationWithOffsetAndLimit() {
+ assertSameAnswerWithAndWithoutLateMaterialization(
+ "SET allowReverseOrder = false; " + SELECTION_QUERY + " ORDER BY
column5 DESC LIMIT 5 OFFSET 3", true);
+ }
+
+ /// An unsorted tail in the order-by removes the limit bound on the scan, so
the linear operator benefits too. The
+ /// unsorted order-by column is still projected during the scan; only the
remaining output columns are deferred.
+ @Test
+ public void testLateMaterializationWithUnsortedOrderBySuffix() {
+ assertSameAnswerWithAndWithoutLateMaterialization(SELECTION_QUERY + "
ORDER BY column5, column6", true);
+ }
+
+ /// The late fetch builds its own TransformOperator, so a non-order-by
expression that is a function must resolve,
+ /// both for its values and for its data type in the schema.
+ @Test
+ public void testLateMaterializationWithTransformProjection() {
+ assertSameAnswerWithAndWithoutLateMaterialization(
+ "SET allowReverseOrder = false; SELECT column5, ADD(column1, 1),
column11 FROM testTable "
+ + "ORDER BY column5 DESC", true);
+ }
}
diff --git
a/pinot-core/src/test/java/org/apache/pinot/queries/NullHandlingEnabledQueriesTest.java
b/pinot-core/src/test/java/org/apache/pinot/queries/NullHandlingEnabledQueriesTest.java
index b9e1cff051e..61faccf6d37 100644
---
a/pinot-core/src/test/java/org/apache/pinot/queries/NullHandlingEnabledQueriesTest.java
+++
b/pinot-core/src/test/java/org/apache/pinot/queries/NullHandlingEnabledQueriesTest.java
@@ -2102,4 +2102,38 @@ public class NullHandlingEnabledQueriesTest extends
BaseQueriesTest {
public void destroySegments() {
_indexSegment.destroy();
}
+
+ /// Late materialisation fetches the non-order-by columns in a second pass,
so their null bitmaps come from a
+ /// different block than the order-by column's. An off-by-one there would
silently drop or invent nulls, and the
+ /// answer must not depend on whether the optimization ran.
+ @Test
+ public void testLateMaterializationPreservesNullsInDeferredColumns()
+ throws Exception {
+ initializeRows();
+ insertRowWithTwoColumns(1, null);
+ insertRowWithTwoColumns(2, 20);
+ insertRowWithTwoColumns(3, null);
+ insertRowWithTwoColumns(4, 40);
+ Schema schema = schemaBuilder()
+ .addSingleValueDimension(COLUMN1, DataType.INT)
+ .addSingleValueDimension(COLUMN2, DataType.INT)
+ .build();
+ // column1 is sorted and has no nulls, so the sorted-prefix path is used;
column2 is the deferred column
+ setUpSegments(TABLE_CONFIG_WITH_SORTED_COLUMN, schema);
+
+ String query =
+ String.format("SELECT %s, %s FROM testTable ORDER BY %s DESC LIMIT 8",
COLUMN1, COLUMN2, COLUMN1);
+ List<Object[]> lateRows = getRows("SET lateMaterializeSortedOrderBy =
true; " + query);
+ List<Object[]> eagerRows = getRows("SET lateMaterializeSortedOrderBy =
false; " + query);
+
+ assertEquals(lateRows.size(), eagerRows.size());
+ for (int i = 0; i < lateRows.size(); i++) {
+ assertEquals(lateRows.get(i), eagerRows.get(i), "row " + i + " differs");
+ }
+ // The top rows are column1=4 (column2=40) across the segment copies, then
column1=3, whose column2 is null
+ assertEquals(lateRows.get(0)[0], 4);
+ assertEquals(lateRows.get(0)[1], 40);
+ assertEquals(lateRows.get(lateRows.size() - 1)[0], 3);
+ assertNull(lateRows.get(lateRows.size() - 1)[1], "the deferred column must
keep its null");
+ }
}
diff --git
a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
index 967ab1b8dac..46b2bd84ddc 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
@@ -1058,6 +1058,16 @@ public class CommonConstants {
public static final String ALLOW_REVERSE_ORDER = "allowReverseOrder";
public static final boolean DEFAULT_ALLOW_REVERSE_ORDER = false;
+ /// Selection with an order-by prefix on a sorted column: project only
the order-by expressions during the
+ /// scan and fetch the remaining output expressions for the rows that
survive the limit. Enabling it
+ /// avoids reading every selected column for the whole match set.
+ ///
+ /// Off by default while the optimization bakes, following
`allowReverseOrder` and
+ /// `usePhysicalOptimizer`. It also changes the observable
`numEntriesScannedPostFilter` on the queries it
+ /// applies to, so turning it on is a deliberate choice. Note the win
shrinks as `limit + offset`
+ /// approaches the match-set size, where the second pass reads what
the scan would have read anyway.
+ public static final String LATE_MATERIALIZE_SORTED_ORDER_BY =
"lateMaterializeSortedOrderBy";
+ public static final boolean DEFAULT_LATE_MATERIALIZE_SORTED_ORDER_BY =
false;
public static final String MULTI_STAGE_LEAF_LIMIT =
"multiStageLeafLimit";
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]