xiangfu0 commented on code in PR #19879:
URL: https://github.com/apache/pinot/pull/19879#discussion_r4289848583


##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -234,7 +312,105 @@ private void concatList(StringBuilder sb, List<?> list) {
 
   @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);
+  }
+
+  /// 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();

Review Comment:
   The alignment of `rowsByDocId` with the ascending bitmap iteration quietly 
relies on every surviving row having a distinct doc id. That holds today, but 
if a future change ever let two rows share a doc id the bitmap would dedupe and 
the trailing rows would be left unfilled. A one-line `assert 
docIds.getCardinality() == numRows;` here would make that assumption fail 
loudly instead of silently misaligning.



##########
pinot-core/src/main/java/org/apache/pinot/core/plan/SelectionPlanNode.java:
##########
@@ -88,17 +88,38 @@ public Operator<SelectionResultsBlock> run() {
         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

Review Comment:
   The gate has no guard on limit size. When `limit + offset` reaches the 
match-set size, late materialization reads the same number of entries as the 
eager path but adds a whole extra pass: a bitmap build, a sort, an array copy, 
and a second decode/transform over every surviving row. With the option 
defaulting to on, a large-`LIMIT` export/pagination query could regress. Worth 
either a limit/selectivity guard here or shipping default-off until benchmarked.



##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -234,7 +312,105 @@ private void concatList(StringBuilder sb, List<?> list) {
 
   @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);
+  }
+
+  /// 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]);
+    }
+
+    Set<String> columns = new HashSet<>();
+    for (ExpressionContext expression : nonOrderByExpressions) {
+      expression.getColumns(columns);
+    }
+    Map<String, DataSource> dataSourceMap = new HashMap<>();
+    for (String column : columns) {
+      dataSourceMap.put(column, _indexSegment.getDataSource(column, 
_queryContext.getSchema()));

Review Comment:
   This uses the `getDataSource(column, schema)` overload, which is a new 
dependency on `_queryContext.getSchema()` being populated on the scan path (the 
eager path resolves columns through the project operator instead). Could you 
confirm the schema is always non-null here, e.g. for virtual/derived columns on 
the server side, so this can't NPE?



##########
pinot-core/src/main/java/org/apache/pinot/core/operator/query/LinearSelectionOrderByOperator.java:
##########
@@ -124,39 +159,76 @@ public IndexSegment getIndexSegment() {
   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 BlockValSet[] newBlockValSets() {
+    return new BlockValSet[getNumProjectedExpressions()];
+  }
+
   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 (docId) -> {
+        Object[] row = blockValueFetcher.getRow(docId);
+        applyNulls(row, nullBitmaps, docId);
+        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 (rowId) -> {
+      Object[] row = new Object[numExpressions];
+      blockValueFetcher.getRow(rowId, row, 0);
+      if (nullBitmaps != null) {
+        applyNulls(row, nullBitmaps, rowId);
       }
+      row[_numOrderByExpressions] = docIds[rowId];
       return row;
     };
   }
 
+  private static void applyNulls(Object[] row, RoaringBitmap[] nullBitmaps, 
int rowId) {

Review Comment:
   Small readability nit: the parameter is named `rowId` (and the sibling 
lambda at line 213 calls it `rowId` too) but it actually holds a block-local 
position, not a doc id. Since an off-by-one here is exactly the kind of bug 
this code has to avoid, a name like `rowIdInBlock` would make the intent 
clearer.



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

To unsubscribe, e-mail: [email protected]

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


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

Reply via email to