yashmayya commented on code in PR #18334:
URL: https://github.com/apache/pinot/pull/18334#discussion_r3632799718


##########
pinot-core/src/test/java/org/apache/pinot/core/plan/maker/MetadataAndDictionaryAggregationPlanMakerTest.java:
##########
@@ -170,6 +178,42 @@ public void testPlanMaker(String query, Class<? extends 
Operator<?>> operatorCla
     assertTrue(upsertOperatorClass.isInstance(upsertOperator));
   }
 
+  /**
+   * Verifies the partial metadata-based aggregation path. When a query mixes 
a metadata-eligible function (MAX) with a
+   * non-eligible one (SUM), the plan uses an {@link AggregationOperator} that 
pre-aggregates the eligible function from
+   * metadata while scanning the rest. Before this feature, a non-scan based 
operator was used only when <em>all</em>
+   * functions were metadata eligible; a mixed query would have scanned every 
function.
+   * <p>
+   * To prove the eligible function is actually served from metadata (and not 
scanned), the column dictionary is
+   * overridden to report a bogus max value that does not exist in the data. 
The query result equals the bogus value
+   * only if the metadata path is taken; a scan would return the true max.
+   */
+  @Test
+  public void testPartialMetadataBasedAggregationServesEligibleFromMetadata() {
+    int bogusMax = 999_999_999;
+    QueryContext queryContext =
+        QueryContextConverterUtils.getQueryContext("select 
max(daysSinceEpoch), sum(column1) from testTable");
+
+    // Override only daysSinceEpoch's dictionary max value; everything else 
delegates to the real segment.
+    DataSource realDataSource = _indexSegment.getDataSource("daysSinceEpoch", 
queryContext.getSchema());
+    Dictionary dictionaryWithBogusMax = mock(Dictionary.class, 
delegatesTo(realDataSource.getDictionary()));
+    doReturn(bogusMax).when(dictionaryWithBogusMax).getMaxVal();
+    DataSource dataSourceWithBogusMax = mock(DataSource.class, 
delegatesTo(realDataSource));
+    
doReturn(dictionaryWithBogusMax).when(dataSourceWithBogusMax).getDictionary();
+    IndexSegment segment = mock(IndexSegment.class, 
delegatesTo(_indexSegment));
+    
doReturn(dataSourceWithBogusMax).when(segment).getDataSource(eq("daysSinceEpoch"),
 any());
+
+    Operator<?> operator = PLAN_MAKER.makeSegmentPlanNode(new 
SegmentContext(segment), queryContext).run();
+    // A mixed query must use the (partial) AggregationOperator, not the fully 
non-scan based operator.
+    assertTrue(operator instanceof AggregationOperator);
+
+    AggregationResultsBlock resultsBlock = (AggregationResultsBlock) 
operator.nextBlock();
+    List<Object> results = resultsBlock.getResults();
+    assertNotNull(results);
+    // MAX is served from the (overridden) dictionary metadata, so the bogus 
value proves the metadata path was used.
+    assertEquals(((Number) results.get(0)).doubleValue(), (double) bogusMax);

Review Comment:
   Nice trick with the bogus max to prove the metadata path was taken. A few 
additions would make this airtight:
   - also assert `sum(column1)` (`results.get(1)`) equals the real scanned sum, 
so we know the scanned side still runs correctly alongside the resolved one.
   - add regression cases for the two things this refactor specifically fixes: 
a single non-resolvable agg with no filter (`select sum(column1)`) should not 
throw and should fall to the scan path, and an agg over an expression arg 
(`max(add(column1, column2))`) should be treated as non-resolvable.
   - the numeric-type guard (MIN/MAX on a non-numeric column falling back to 
scan) is the correctness fix in this PR but isn't covered anywhere - worth a 
case here.



##########
pinot-core/src/main/java/org/apache/pinot/core/plan/AggregationPlanNode.java:
##########
@@ -115,17 +122,34 @@ public Operator<AggregationResultsBlock> 
buildNonFilteredAggOperator() {
 
     boolean hasNullValues = _queryContext.isNullHandlingEnabled() && 
hasNullValues(aggregationFunctions);
     if (!hasNullValues) {
-      // Priority 2: Check if non-scan based aggregation is feasible
-      if (filterOperator.isResultMatchingAll() && isFitForNonScanBasedPlan()) {
+      // when the filter matches all documents, resolve as many functions as 
possible from the column
+      // dictionary/metadata without scanning the segment. Eligibility is 
evaluated once per function here
+      // and reused for both the fully non-scan path (all functions 
resolvable) and
+      // the partial path (some functions resolvable).
+      if (filterOperator.isResultMatchingAll()) {
+        boolean[] metadataResolvable = new 
boolean[aggregationFunctions.length];
         DataSource[] dataSources = new DataSource[aggregationFunctions.length];
+        int numResolved = 0;
         for (int i = 0; i < aggregationFunctions.length; i++) {
-          List<?> inputExpressions = 
aggregationFunctions[i].getInputExpressions();
-          if (!inputExpressions.isEmpty()) {
-            String column = ((ExpressionContext) 
inputExpressions.get(0)).getIdentifier();
-            dataSources[i] = _indexSegment.getDataSource(column, 
_queryContext.getSchema());
+          DataSource dataSource = 
getDataSourceForAggregationFunction(aggregationFunctions[i]);
+          if (isFitForNonScanBasedPlan(aggregationFunctions[i], dataSource)) {
+            metadataResolvable[i] = true;
+            dataSources[i] = dataSource;
+            numResolved++;
           }
         }
-        return new NonScanBasedAggregationOperator(_queryContext, dataSources, 
numTotalDocs);
+
+        if (numResolved == aggregationFunctions.length) {
+          // Priority 2: all functions can be resolved from 
dictionary/metadata -> fully non-scan based execution
+          return new NonScanBasedAggregationOperator(_queryContext, 
dataSources, numTotalDocs);
+        }
+        if (numResolved > 0) {
+          // some functions can be resolved from dictionary/metadata; the rest 
fall back to scan-based
+          // execution in the AggregationOperator.
+          aggregationInfo = 
AggregationFunctionUtils.buildAggregationInfoWithoutStarTree(_segmentContext, 
_queryContext,

Review Comment:
   Minor/optional: the partial path still projects every function's columns via 
`buildAggregationInfoWithoutStarTree`, so a column used only by a 
metadata-resolved function is still read during the scan - we save the per-row 
`aggregate()` but not the column I/O. Usually the per-row compute is the bulk 
(especially DISTINCT*), so this is fine, but if you wanted the full win you'd 
need to exclude resolved-only columns from the projected set. OK to leave as a 
follow-up.



##########
pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionUtilsTest.java:
##########
@@ -0,0 +1,86 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.core.query.aggregation.function;
+
+import org.apache.pinot.segment.spi.AggregationFunctionType;
+import org.apache.pinot.segment.spi.datasource.DataSource;
+import org.apache.pinot.segment.spi.index.reader.Dictionary;
+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.assertThrows;
+
+
+/**
+ * Unit test for {@link AggregationFunctionUtils#getAggregationResult}, the 
metadata/dictionary based aggregation
+ * result resolver used by the non-scan based and partial metadata based 
aggregation paths.
+ */
+@SuppressWarnings("rawtypes")
+public class AggregationFunctionUtilsTest {
+
+  private static AggregationFunction mockFunction(AggregationFunctionType 
type) {
+    AggregationFunction aggregationFunction = mock(AggregationFunction.class);
+    when(aggregationFunction.getType()).thenReturn(type);
+    return aggregationFunction;
+  }
+
+  @Test
+  public void testCountResolvedFromNumTotalDocs() {
+    AggregationFunction countFunction = 
mockFunction(AggregationFunctionType.COUNT);
+    // COUNT is resolved directly from numTotalDocs and must not touch the 
(possibly null) data source.
+    Object result = 
AggregationFunctionUtils.getAggregationResult(countFunction, null, 42, "TEST");
+    assertEquals(result, 42L);
+  }
+
+  @Test
+  public void testMinAndMaxResolvedFromDictionary() {
+    Dictionary dictionary = mock(Dictionary.class);
+    when(dictionary.getMinVal()).thenReturn(5);
+    when(dictionary.getMaxVal()).thenReturn(10);
+    DataSource dataSource = mock(DataSource.class);
+    when(dataSource.getDictionary()).thenReturn(dictionary);
+
+    Object minResult = 
AggregationFunctionUtils.getAggregationResult(mockFunction(AggregationFunctionType.MIN),
+        dataSource, 100, "TEST");
+    assertEquals(minResult, 5.0);
+
+    Object maxResult = 
AggregationFunctionUtils.getAggregationResult(mockFunction(AggregationFunctionType.MAX),
+        dataSource, 100, "TEST");
+    assertEquals(maxResult, 10.0);
+  }
+
+  @Test
+  public void testUnsupportedFunctionThrows() {

Review Comment:
   Good start. This only exercises COUNT/MIN/MAX/MODE/null though - the 
DISTINCT* branches are the main beneficiaries of the non-scan path and they do 
real dictionary iteration (and BYTES -> HLL merging), so a case that resolves 
e.g. DISTINCTCOUNT from a real dictionary would add meaningful coverage.



##########
pinot-core/src/main/java/org/apache/pinot/core/plan/AggregationPlanNode.java:
##########
@@ -173,40 +197,68 @@ private boolean hasNullValues(AggregationFunction[] 
aggregationFunctions) {
   }
 
   /**
-   * Returns {@code true} if the given aggregations can be solved with 
dictionary or column metadata, {@code false}
-   * otherwise.
+   * Returns {@code true} if the given aggregation function can be resolved 
from the column dictionary or metadata
+   * (without scanning the segment), {@code false} otherwise. {@code COUNT} is 
always eligible. Functions whose result
+   * is derived numerically from the column min/max (e.g. MIN, MAX, 
MINMAXRANGE) are only eligible for numeric columns,
+   * since non-numeric columns (e.g. BYTES) store min/max as raw values that 
cannot be parsed as numbers.
+   *
+   * @param aggregationFunction aggregation function to test
+   * @param dataSource the function argument's data source (see {@link 
#getDataSourceForAggregationFunction})
    */
-  private boolean isFitForNonScanBasedPlan() {
-    AggregationFunction[] aggregationFunctions = 
_queryContext.getAggregationFunctions();
-    assert aggregationFunctions != null;
-    for (AggregationFunction<?, ?> aggregationFunction : aggregationFunctions) 
{
-      if (aggregationFunction.getType() == COUNT) {
-        continue;
-      }
-      ExpressionContext argument = 
aggregationFunction.getInputExpressions().get(0);
-      if (argument.getType() != ExpressionContext.Type.IDENTIFIER) {
-        return false;
-      }
-      DataSource dataSource = 
_indexSegment.getDataSource(argument.getIdentifier(), 
_queryContext.getSchema());
-      if (DICTIONARY_BASED_FUNCTIONS.contains(aggregationFunction.getType())) {
-        if (dataSource.getDictionary() != null) {
-          continue;
-        }
-      }
-      if (METADATA_BASED_FUNCTIONS.contains(aggregationFunction.getType())) {
-        if (dataSource.getDataSourceMetadata().getMaxValue() != null
-            && dataSource.getDataSourceMetadata().getMinValue() != null) {
-          continue;
-        }
-      }
+  private boolean isFitForNonScanBasedPlan(AggregationFunction<?, ?> 
aggregationFunction,
+      @Nullable DataSource dataSource) {
+    AggregationFunctionType functionType = aggregationFunction.getType();
+    if (functionType == COUNT) {
+      return true;
+    }
+
+    if (dataSource == null) {
+      // Aggregation function does not have a single identifier argument (e.g. 
COUNT(*) or COUNT(1)),
+      // so it cannot be resolved from metadata
       return false;
     }
-    return true;
+
+    // MIN/MAX/MINMAXRANGE derive their result numerically from the column 
min/max, which is only valid for numeric
+    // columns. Non-numeric columns (e.g. BYTES) store min/max as raw values 
that cannot be parsed as numbers.
+    if (NUMERIC_METADATA_FUNCTIONS.contains(functionType)
+        && 
!dataSource.getDataSourceMetadata().getDataType().getStoredType().isNumeric()) {
+      return false;
+    }
+
+    if (dataSource.getDictionary() != null && 
DICTIONARY_BASED_FUNCTIONS.contains(functionType)) {
+      return true;
+    }
+
+    return METADATA_BASED_FUNCTIONS.contains(functionType)
+        && dataSource.getDataSourceMetadata().getMaxValue() != null
+        && dataSource.getDataSourceMetadata().getMinValue() != null;
   }
 
   private static boolean canOptimizeFilteredCount(BaseFilterOperator 
filterOperator,
       AggregationFunction[] aggregationFunctions) {
     return (aggregationFunctions.length == 1 && 
aggregationFunctions[0].getType() == COUNT)
         && filterOperator.canOptimizeCount();
   }
+
+  /**
+   * Returns the data source for the given aggregation function's argument, or 
{@code null} if the function has no
+   * argument (e.g. {@code COUNT(*)}) or its argument is not a single column 
identifier (e.g. {@code COUNT(1)} or a
+   * transform expression), in which case it cannot be resolved from 
dictionary/metadata.
+   *
+   * @param aggregationFunction aggregation function whose argument data 
source is resolved
+   * @return the argument's data source, or {@code null} if it has no single 
identifier argument
+   */
+  @Nullable
+  private DataSource 
getDataSourceForAggregationFunction(AggregationFunction<?, ?> 
aggregationFunction) {
+    List<?> inputExpressions = aggregationFunction.getInputExpressions();
+    if (!inputExpressions.isEmpty()) {
+      ExpressionContext argument = 
aggregationFunction.getInputExpressions().get(0);

Review Comment:
   nit: can reuse the `inputExpressions` local here instead of calling 
`getInputExpressions()` a second time.



-- 
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