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 fcd05cca42d Fix compiled aggregation inputs after timestamp index 
rewrites (#20035)
fcd05cca42d is described below

commit fcd05cca42d1ce4350a3246ba7e5cac203b534a9
Author: Xiang Fu <[email protected]>
AuthorDate: Wed Oct 28 16:58:24 2026 -0700

    Fix compiled aggregation inputs after timestamp index rewrites (#20035)
    
    * Fix compiled aggregation inputs after timestamp index rewrites
    
    * Cover hinted timestamp aggregation with GROUP BY
    
    * Clarify timestamp rewrite planning contract
    
    * Prepare rewritten query execution state once per instance
    
    * Preserve null semantics during timestamp index rewrites
    
    * Reduce planning work for expression override hints
    
    * Avoid redundant common query planning allocations
---
 .../core/plan/maker/InstancePlanMakerImplV2.java   | 199 +++++++++---
 .../core/query/request/context/QueryContext.java   | 128 ++++----
 .../context/utils/QueryContextConverterUtils.java  |  11 +-
 .../plan/maker/QueryOverrideWithHintsTest.java     | 349 ++++++++++++++++++++-
 .../apache/pinot/queries/TimestampQueriesTest.java |  70 +++++
 5 files changed, 639 insertions(+), 118 deletions(-)

diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/plan/maker/InstancePlanMakerImplV2.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/plan/maker/InstancePlanMakerImplV2.java
index 68a141afb15..305ad2f2b32 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/plan/maker/InstancePlanMakerImplV2.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/plan/maker/InstancePlanMakerImplV2.java
@@ -21,7 +21,10 @@ package org.apache.pinot.core.plan.maker;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
+import java.util.IdentityHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -33,6 +36,7 @@ import org.apache.commons.collections4.MapUtils;
 import org.apache.commons.lang3.tuple.Pair;
 import org.apache.pinot.common.request.context.ExpressionContext;
 import org.apache.pinot.common.request.context.FilterContext;
+import org.apache.pinot.common.request.context.FunctionContext;
 import org.apache.pinot.common.request.context.OrderByExpressionContext;
 import org.apache.pinot.common.request.context.predicate.Predicate;
 import org.apache.pinot.common.utils.config.QueryOptionsUtils;
@@ -221,6 +225,7 @@ public class InstancePlanMakerImplV2 implements PlanMaker {
   @Override
   public Plan makeInstancePlan(List<SegmentContext> segmentContexts, 
QueryContext queryContext,
       ExecutorService executorService) {
+    rewriteQueryContextWithHints(queryContext, segmentContexts);
     applyQueryOptions(queryContext);
     boundGroupingSetsBaseAggregation(segmentContexts, queryContext);
 
@@ -406,7 +411,6 @@ public class InstancePlanMakerImplV2 implements PlanMaker {
 
   @Override
   public PlanNode makeSegmentPlanNode(SegmentContext segmentContext, 
QueryContext queryContext) {
-    rewriteQueryContextWithHints(queryContext, 
segmentContext.getIndexSegment());
     if (QueryContextUtils.isAggregationQuery(queryContext)) {
       List<ExpressionContext> groupByExpressions = 
queryContext.getGroupByExpressions();
       if (groupByExpressions != null) {
@@ -427,6 +431,7 @@ public class InstancePlanMakerImplV2 implements PlanMaker {
   @Override
   public Plan makeStreamingInstancePlan(List<SegmentContext> segmentContexts, 
QueryContext queryContext,
       ExecutorService executorService, ResultsBlockStreamer streamer) {
+    rewriteQueryContextWithHints(queryContext, segmentContexts);
     applyQueryOptions(queryContext);
     boundGroupingSetsBaseAggregation(segmentContexts, queryContext);
 
@@ -459,7 +464,6 @@ public class InstancePlanMakerImplV2 implements PlanMaker {
   public PlanNode makeStreamingSegmentPlanNode(SegmentContext segmentContext, 
QueryContext queryContext) {
     if (QueryContextUtils.isSelectionOnlyQuery(queryContext) && 
queryContext.getLimit() != 0) {
       // Use streaming operator only for non-empty selection-only query
-      rewriteQueryContextWithHints(queryContext, 
segmentContext.getIndexSegment());
       return new StreamingSelectionPlanNode(segmentContext, queryContext);
     } else {
       return makeSegmentPlanNode(segmentContext, queryContext);
@@ -657,86 +661,191 @@ public class InstancePlanMakerImplV2 implements 
PlanMaker {
     return true;
   }
 
-  /// In-place rewrite QueryContext based on the information from local 
IndexSegment.
-  ///
-  /// @param queryContext
-  /// @param indexSegment
+  /// Rewrites the shared context before fetch planning, using only columns 
available on every selected segment.
+  /// A partially reloaded timestamp index must leave the original expression 
executable on all segments.
+  /// Rewriting per segment would mutate the expressions shared by already 
constructed segment plans.
   @VisibleForTesting
-  public static void rewriteQueryContextWithHints(QueryContext queryContext, 
IndexSegment indexSegment) {
-    Map<ExpressionContext, ExpressionContext> expressionOverrideHints = 
queryContext.getExpressionOverrideHints();
-    if (MapUtils.isEmpty(expressionOverrideHints)) {
+  static void rewriteQueryContextWithHints(QueryContext queryContext, 
List<SegmentContext> segmentContexts) {
+    Map<ExpressionContext, ExpressionContext> hints = 
queryContext.getExpressionOverrideHints();
+    if (MapUtils.isEmpty(hints) || segmentContexts.isEmpty()) {
       return;
     }
+    Map<ExpressionContext, ExpressionContext> applicableHints = 
getApplicableHints(hints, segmentContexts);
+    if (!applicableHints.isEmpty()) {
+      rewriteQueryContextWithHints(queryContext, applicableHints);
+    }
+  }
 
-    List<ExpressionContext> selectExpressions = 
queryContext.getSelectExpressions();
-    selectExpressions.replaceAll(
-        expression -> overrideWithExpressionHints(expression, indexSegment, 
expressionOverrideHints));
-
-    List<Pair<AggregationFunction, FilterContext>> filtAggrFuns = 
queryContext.getFilteredAggregationFunctions();
-    if (filtAggrFuns != null) {
-      for (Pair<AggregationFunction, FilterContext> 
filteredAggregationFunction : filtAggrFuns) {
-        FilterContext right = filteredAggregationFunction.getRight();
-        if (right != null) {
-          overrideWithExpressionHints(right, indexSegment, 
expressionOverrideHints);
+  /// Keeps availability checks local to this segment set without changing the 
broker-provided hints.
+  private static Map<ExpressionContext, ExpressionContext> getApplicableHints(
+      Map<ExpressionContext, ExpressionContext> hints, List<SegmentContext> 
segmentContexts) {
+    if (hints.isEmpty()) {
+      return Map.of();
+    }
+    if (hints.size() == 1) {
+      Map.Entry<ExpressionContext, ExpressionContext> hint = 
hints.entrySet().iterator().next();
+      String column = getOverrideColumn(hint.getValue());
+      for (SegmentContext segmentContext : segmentContexts) {
+        if 
(!segmentContext.getIndexSegment().getColumnNames().contains(column)) {
+          return Map.of();
         }
       }
+      // The single-entry map uses equality directly, avoiding recursive 
hashing of unrelated expression trees.
+      return Map.of(hint.getKey(), hint.getValue());
+    }
+    Set<String> availableColumns = new HashSet<>();
+    for (ExpressionContext target : hints.values()) {
+      availableColumns.add(getOverrideColumn(target));
+    }
+    int numTargets = availableColumns.size();
+    for (SegmentContext segmentContext : segmentContexts) {
+      Set<String> columns = segmentContext.getIndexSegment().getColumnNames();
+      availableColumns.removeIf(column -> !columns.contains(column));
+      if (availableColumns.isEmpty()) {
+        return Map.of();
+      }
+    }
+    if (availableColumns.size() == numTargets) {
+      return hints;
+    }
+    Map<ExpressionContext, ExpressionContext> applicableHints = new 
HashMap<>();
+    for (Map.Entry<ExpressionContext, ExpressionContext> hint : 
hints.entrySet()) {
+      if (availableColumns.contains(hint.getValue().getIdentifier())) {
+        applicableHints.put(hint.getKey(), hint.getValue());
+      }
+    }
+    if (applicableHints.size() == 1) {
+      Map.Entry<ExpressionContext, ExpressionContext> hint = 
applicableHints.entrySet().iterator().next();
+      return Map.of(hint.getKey(), hint.getValue());
+    }
+    return applicableHints;
+  }
+
+  private static String getOverrideColumn(ExpressionContext target) {
+    Preconditions.checkArgument(target.getType() == 
ExpressionContext.Type.IDENTIFIER,
+        "Expression override target must be an identifier");
+    return target.getIdentifier();
+  }
+
+  /// Gates structural hashing by function name and arity; expression hashes 
cannot be cached across mutations.
+  @Nullable
+  private static Map<String, Set<Integer>> 
getHintFunctionArities(Map<ExpressionContext, ExpressionContext> hints) {
+    if (hints.size() <= 1) {
+      return null;
+    }
+    Map<String, Set<Integer>> arities = new HashMap<>();
+    for (ExpressionContext expression : hints.keySet()) {
+      FunctionContext function = expression.getFunction();
+      if (function != null) {
+        arities.computeIfAbsent(function.getFunctionName(), key -> new 
HashSet<>()).add(function.getArguments().size());
+      }
     }
+    return arities;
+  }
 
+  private static void rewriteQueryContextWithHints(QueryContext queryContext,
+      Map<ExpressionContext, ExpressionContext> hints) {
+    Set<FunctionContext> changedFunctions = Collections.newSetFromMap(new 
IdentityHashMap<>());
+    Map<String, Set<Integer>> hintFunctionArities = 
getHintFunctionArities(hints);
+    // FILTER predicates have separate contexts; aggregation inputs are 
visited through SELECT/HAVING/ORDER-BY.
+    if (queryContext.hasFilteredAggregations()) {
+      List<Pair<AggregationFunction, FilterContext>> filteredAggregations =
+          queryContext.getFilteredAggregationFunctions();
+      for (Pair<AggregationFunction, FilterContext> aggregation : 
filteredAggregations) {
+        if (aggregation.getRight() != null) {
+          overrideWithExpressionHints(aggregation.getRight(), hints, 
hintFunctionArities, changedFunctions);
+        }
+      }
+    }
+    queryContext.getSelectExpressions().replaceAll(
+        expression -> overrideWithExpressionHints(expression, hints, 
hintFunctionArities, changedFunctions));
     List<ExpressionContext> groupByExpressions = 
queryContext.getGroupByExpressions();
     if (CollectionUtils.isNotEmpty(groupByExpressions)) {
       groupByExpressions.replaceAll(
-          expression -> overrideWithExpressionHints(expression, indexSegment, 
expressionOverrideHints));
+          expression -> overrideWithExpressionHints(expression, hints, 
hintFunctionArities, changedFunctions));
     }
-
     List<OrderByExpressionContext> orderByExpressions = 
queryContext.getOrderByExpressions();
     if (CollectionUtils.isNotEmpty(orderByExpressions)) {
-      orderByExpressions.replaceAll(expression -> new OrderByExpressionContext(
-          overrideWithExpressionHints(expression.getExpression(), 
indexSegment, expressionOverrideHints),
-          expression.isAsc()));
+      orderByExpressions.replaceAll(orderBy -> {
+        ExpressionContext expression = orderBy.getExpression();
+        ExpressionContext rewritten =
+            overrideWithExpressionHints(expression, hints, 
hintFunctionArities, changedFunctions);
+        return rewritten == expression ? orderBy
+            : new OrderByExpressionContext(rewritten, orderBy.isAsc(), 
orderBy.isNullsLast());
+      });
     }
-
-    // In-place override
-    FilterContext filter = queryContext.getFilter();
-    if (filter != null) {
-      overrideWithExpressionHints(filter, indexSegment, 
expressionOverrideHints);
+    if (queryContext.getFilter() != null) {
+      overrideWithExpressionHints(queryContext.getFilter(), hints, 
hintFunctionArities, changedFunctions);
     }
-
-    // In-place override
-    FilterContext havingFilter = queryContext.getHavingFilter();
-    if (havingFilter != null) {
-      overrideWithExpressionHints(havingFilter, indexSegment, 
expressionOverrideHints);
+    if (queryContext.getHavingFilter() != null) {
+      overrideWithExpressionHints(queryContext.getHavingFilter(), hints, 
hintFunctionArities, changedFunctions);
+    }
+    if (!changedFunctions.isEmpty()) {
+      queryContext.prepareForExecution(changedFunctions);
     }
   }
 
   @VisibleForTesting
   public static void overrideWithExpressionHints(FilterContext filter, 
IndexSegment indexSegment,
       Map<ExpressionContext, ExpressionContext> expressionOverrideHints) {
+    Map<ExpressionContext, ExpressionContext> hints =
+        getApplicableHints(expressionOverrideHints, List.of(new 
SegmentContext(indexSegment)));
+    overrideWithExpressionHints(filter, hints, getHintFunctionArities(hints),
+        Collections.newSetFromMap(new IdentityHashMap<>()));
+  }
+
+  private static void overrideWithExpressionHints(FilterContext filter,
+      Map<ExpressionContext, ExpressionContext> expressionOverrideHints,
+      @Nullable Map<String, Set<Integer>> hintFunctionArities, 
Set<FunctionContext> changedFunctions) {
+    if (filter.isConstant()) {
+      return;
+    }
     if (filter.getChildren() != null) {
-      // AND, OR, NOT
       for (FilterContext child : filter.getChildren()) {
-        overrideWithExpressionHints(child, indexSegment, 
expressionOverrideHints);
+        overrideWithExpressionHints(child, expressionOverrideHints, 
hintFunctionArities, changedFunctions);
       }
     } else {
-      // PREDICATE
       Predicate predicate = filter.getPredicate();
-      predicate.setLhs(overrideWithExpressionHints(predicate.getLhs(), 
indexSegment, expressionOverrideHints));
+      ExpressionContext rewritten = 
overrideWithExpressionHints(predicate.getLhs(), expressionOverrideHints,
+          hintFunctionArities, changedFunctions);
+      predicate.setLhs(rewritten);
     }
   }
 
   @VisibleForTesting
   public static ExpressionContext 
overrideWithExpressionHints(ExpressionContext expression, IndexSegment 
indexSegment,
       Map<ExpressionContext, ExpressionContext> expressionOverrideHints) {
+    Map<ExpressionContext, ExpressionContext> hints =
+        getApplicableHints(expressionOverrideHints, List.of(new 
SegmentContext(indexSegment)));
+    return overrideWithExpressionHints(expression, hints, 
getHintFunctionArities(hints),
+        Collections.newSetFromMap(new IdentityHashMap<>()));
+  }
+
+  private static ExpressionContext 
overrideWithExpressionHints(ExpressionContext expression,
+      Map<ExpressionContext, ExpressionContext> expressionOverrideHints,
+      @Nullable Map<String, Set<Integer>> hintFunctionArities, 
Set<FunctionContext> changedFunctions) {
     if (expression.getType() != ExpressionContext.Type.FUNCTION) {
       return expression;
     }
-    ExpressionContext overrideExpression = 
expressionOverrideHints.get(expression);
-    if (overrideExpression != null && overrideExpression.getIdentifier() != 
null && indexSegment.getColumnNames()
-        .contains(overrideExpression.getIdentifier())) {
-      return overrideExpression;
+    FunctionContext function = expression.getFunction();
+    List<ExpressionContext> arguments = function.getArguments();
+    Set<Integer> arities = hintFunctionArities != null ? 
hintFunctionArities.get(function.getFunctionName()) : null;
+    if (hintFunctionArities == null || arities != null && 
arities.contains(arguments.size())) {
+      ExpressionContext overrideExpression = 
expressionOverrideHints.get(expression);
+      if (overrideExpression != null) {
+        changedFunctions.add(function);
+        return overrideExpression;
+      }
+    }
+    for (int i = 0; i < arguments.size(); i++) {
+      ExpressionContext argument = arguments.get(i);
+      ExpressionContext rewritten =
+          overrideWithExpressionHints(argument, expressionOverrideHints, 
hintFunctionArities, changedFunctions);
+      if (rewritten != argument || 
changedFunctions.contains(argument.getFunction())) {
+        changedFunctions.add(function);
+      }
+      arguments.set(i, rewritten);
     }
-    expression.getFunction()
-        .getArguments()
-        .replaceAll(argument -> overrideWithExpressionHints(argument, 
indexSegment, expressionOverrideHints));
     return expression;
   }
 }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
index 24548c551dd..857f11e5b2f 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/QueryContext.java
@@ -89,6 +89,8 @@ public class QueryContext implements QueryScopedCache {
   private AggregationFunction[] _aggregationFunctions;
   private List<Pair<AggregationFunction, FilterContext>> 
_filteredAggregationFunctions;
   private Map<Pair<FunctionContext, FilterContext>, Integer> 
_filteredAggregationsIndexMap;
+  // Logical aggregation slots are fixed before expression rewrites to 
preserve the broker/server result layout.
+  private List<Pair<FunctionContext, FilterContext>> 
_aggregationFunctionContexts;
   private boolean _hasFilteredAggregations;
   // Set by the instance planner after bounding all local BASE and derived 
grouping-set keys.
   private boolean _groupingSetsBaseAggregationAllowed = true;
@@ -190,6 +192,9 @@ public class QueryContext implements QueryScopedCache {
       return false;
     }
 
+    if (groupByKeys.size() == 1 && orderByKeys.size() == 1) {
+      return groupByKeys.get(0).equals(orderByKeys.get(0).getExpression());
+    }
     Set<ExpressionContext> groupByKeyIdentSet = new HashSet<>(groupByKeys);
     Set<ExpressionContext> orderByKeyIdentSet = new HashSet<>();
     orderByKeys.forEach(key -> orderByKeyIdentSet.add(key.getExpression()));
@@ -353,6 +358,44 @@ public class QueryContext implements QueryScopedCache {
     return _expressionOverrideHints;
   }
 
+  /// Prepares derived execution state after logical expressions change, 
before fetch or segment planning.
+  /// Reuses compiled aggregations whose input trees did not change. The 
supplied set uses function identity because
+  /// expression rewrites can mutate function arguments, including their hash 
codes, in place.
+  public void prepareForExecution(Set<FunctionContext> changedFunctions) {
+    int numAggregations = _aggregationFunctionContexts.size();
+    if (numAggregations > 0) {
+      boolean initialPreparation = _aggregationFunctions == null;
+      if (initialPreparation) {
+        _aggregationFunctions = new AggregationFunction[numAggregations];
+        _filteredAggregationFunctions = new ArrayList<>(numAggregations);
+        _filteredAggregationsIndexMap = new HashMap<>();
+      } else {
+        // FILTER-only changes can mutate keys even when no aggregation 
function is recompiled.
+        _filteredAggregationsIndexMap.clear();
+      }
+      for (int i = 0; i < numAggregations; i++) {
+        Pair<FunctionContext, FilterContext> context = 
_aggregationFunctionContexts.get(i);
+        if (initialPreparation || 
changedFunctions.contains(context.getLeft())) {
+          AggregationFunction function =
+              
AggregationFunctionFactory.getAggregationFunction(context.getLeft(), 
_nullHandlingEnabled);
+          if (initialPreparation) {
+            _filteredAggregationFunctions.add(Pair.of(function, 
context.getRight()));
+            _hasFilteredAggregations |= context.getRight() != null;
+          } else if (function != _aggregationFunctions[i]) {
+            _filteredAggregationFunctions.set(i, Pair.of(function, 
context.getRight()));
+          }
+          _aggregationFunctions[i] = function;
+        }
+        _filteredAggregationsIndexMap.put(context, i);
+      }
+    }
+    Builder.extractColumns(this);
+    if (_groupByExpressions != null) {
+      _isUnsafeTrim = isGroupingSets() || _havingFilter != null || 
!isSameOrderAndGroupByColumns(this);
+      _effectiveSegmentGroupTrimSize = 
calculateEffectiveSegmentGroupTrimSize();
+    }
+  }
+
   /// Returns `true` if the query is an EXPLAIN query, `false` otherwise.
   public boolean isExplain() {
     return _explain != ExplainMode.NONE;
@@ -850,20 +893,14 @@ public class QueryContext implements QueryScopedCache {
       }
 
       // Pre-calculate the aggregation functions and columns for the query
-      generateAggregationFunctions(queryContext);
-      extractColumns(queryContext);
+      extractAggregationFunctionContexts(queryContext);
 
       // Pre-calculate group-by configs
       if (queryContext.getGroupByExpressions() != null) {
-        /// Grouping-set queries expand each row into multiple groups; force 
the unsafe-trim path so the
-        /// sort-aggregate fast path (which assumes a single flat grouping) is 
not taken.
-        queryContext._isUnsafeTrim = queryContext.isGroupingSets()
-            || !queryContext.isSameOrderAndGroupByColumns(queryContext) || 
queryContext.getHavingFilter() != null;
         Integer sortAggregateLimitThreshold = 
QueryOptionsUtils.getSortAggregateLimitThreshold(_queryOptions);
         if (sortAggregateLimitThreshold != null) {
           
queryContext.setSortAggregateLimitThreshold(sortAggregateLimitThreshold);
         }
-        
queryContext.setEffectiveSegmentGroupTrimSize(queryContext.calculateEffectiveSegmentGroupTrimSize());
 
         // sortAggregateSequentialCombineNumSegmentsThreshold is defaulted to 
hardware concurrency
         // if not specified. this allows one more parallel thread (the main 
thread) to do only combine
@@ -876,70 +913,37 @@ public class QueryContext implements QueryScopedCache {
         }
       }
 
+      queryContext.prepareForExecution(Set.of());
       return queryContext;
     }
 
-    /// Helper method to generate the aggregation functions for the query.
-    private void generateAggregationFunctions(QueryContext queryContext) {
-      List<Pair<AggregationFunction, FilterContext>> 
filteredAggregationFunctions = new ArrayList<>();
-      Map<Pair<FunctionContext, FilterContext>, Integer> 
filteredAggregationsIndexMap = new HashMap<>();
-
-      // Add aggregation functions in the SELECT clause
-      // NOTE: DO NOT deduplicate the aggregation functions in the SELECT 
clause because that involves protocol change.
-      List<Pair<FunctionContext, FilterContext>> filteredAggregations = new 
ArrayList<>();
+    /// Captures logical aggregation slots before rewriting; execution 
preparation must not deduplicate them again.
+    private void extractAggregationFunctionContexts(QueryContext queryContext) 
{
+      List<Pair<FunctionContext, FilterContext>> contexts = new ArrayList<>();
+      // SELECT duplicates are protocol-visible and must retain independent 
result slots.
       for (ExpressionContext selectExpression : 
queryContext._selectExpressions) {
-        getAggregations(selectExpression, filteredAggregations);
-      }
-      for (Pair<FunctionContext, FilterContext> pair : filteredAggregations) {
-        FunctionContext aggregation = pair.getLeft();
-        FilterContext filter = pair.getRight();
-        int functionIndex = filteredAggregationFunctions.size();
-        AggregationFunction aggregationFunction =
-            AggregationFunctionFactory.getAggregationFunction(aggregation, 
queryContext._nullHandlingEnabled);
-        filteredAggregationFunctions.add(Pair.of(aggregationFunction, filter));
-        filteredAggregationsIndexMap.put(Pair.of(aggregation, filter), 
functionIndex);
-      }
-
-      // Add aggregation functions in the HAVING and ORDER-BY clause but not 
in the SELECT clause
-      filteredAggregations.clear();
-      if (queryContext._havingFilter != null) {
-        getAggregations(queryContext._havingFilter, filteredAggregations);
+        getAggregations(selectExpression, contexts);
       }
-      if (queryContext._orderByExpressions != null) {
-        for (OrderByExpressionContext orderByExpression : 
queryContext._orderByExpressions) {
-          getAggregations(orderByExpression.getExpression(), 
filteredAggregations);
+      if (queryContext._havingFilter != null || 
queryContext._orderByExpressions != null) {
+        List<Pair<FunctionContext, FilterContext>> otherAggregations = new 
ArrayList<>();
+        if (queryContext._havingFilter != null) {
+          getAggregations(queryContext._havingFilter, otherAggregations);
         }
-      }
-      for (Pair<FunctionContext, FilterContext> pair : filteredAggregations) {
-        if (!filteredAggregationsIndexMap.containsKey(pair)) {
-          FunctionContext aggregation = pair.getLeft();
-          FilterContext filter = pair.getRight();
-          int functionIndex = filteredAggregationFunctions.size();
-          AggregationFunction aggregationFunction =
-              AggregationFunctionFactory.getAggregationFunction(aggregation, 
queryContext._nullHandlingEnabled);
-          filteredAggregationFunctions.add(Pair.of(aggregationFunction, 
filter));
-          filteredAggregationsIndexMap.put(Pair.of(aggregation, filter), 
functionIndex);
+        if (queryContext._orderByExpressions != null) {
+          for (OrderByExpressionContext orderByExpression : 
queryContext._orderByExpressions) {
+            getAggregations(orderByExpression.getExpression(), 
otherAggregations);
+          }
         }
-      }
-
-      if (!filteredAggregationFunctions.isEmpty()) {
-        int numAggregations = filteredAggregationFunctions.size();
-        AggregationFunction[] aggregationFunctions = new 
AggregationFunction[numAggregations];
-        for (int i = 0; i < numAggregations; i++) {
-          Pair<AggregationFunction, FilterContext> pair = 
filteredAggregationFunctions.get(i);
-          aggregationFunctions[i] = pair.getLeft();
-          // NOTE: Compute the flag over all the collected aggregations 
(SELECT, HAVING and ORDER-BY) instead of only
-          //       the SELECT ones. A FILTER clause on an aggregation that is 
referenced only in HAVING or ORDER-BY
-          //       must still be honored, else the plan nodes pick the 
non-filtered operator and silently evaluate the
-          //       aggregation over the main filter alone.
-          if (pair.getRight() != null) {
-            queryContext._hasFilteredAggregations = true;
+        if (!otherAggregations.isEmpty()) {
+          Set<Pair<FunctionContext, FilterContext>> existing = new 
HashSet<>(contexts);
+          for (Pair<FunctionContext, FilterContext> context : 
otherAggregations) {
+            if (existing.add(context)) {
+              contexts.add(context);
+            }
           }
         }
-        queryContext._aggregationFunctions = aggregationFunctions;
-        queryContext._filteredAggregationFunctions = 
filteredAggregationFunctions;
-        queryContext._filteredAggregationsIndexMap = 
filteredAggregationsIndexMap;
       }
+      queryContext._aggregationFunctionContexts = contexts;
     }
 
     /// Helper method to extract AGGREGATION FunctionContexts and FILTER 
FilterContexts from the given expression.
@@ -986,7 +990,7 @@ public class QueryContext implements QueryScopedCache {
     }
 
     /// Helper method to extract the columns (IDENTIFIER expressions) for the 
query.
-    private void extractColumns(QueryContext query) {
+    private static void extractColumns(QueryContext query) {
       Set<String> columns = new HashSet<>();
 
       for (ExpressionContext expression : query._selectExpressions) {
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/utils/QueryContextConverterUtils.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/utils/QueryContextConverterUtils.java
index a5cf6b84e02..6d88a294579 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/utils/QueryContextConverterUtils.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/request/context/utils/QueryContextConverterUtils.java
@@ -186,9 +186,16 @@ public class QueryContextConverterUtils {
     }
 
     // EXPRESSION OVERRIDE HINTS
-    Map<ExpressionContext, ExpressionContext> expressionContextOverrideHints = 
new HashMap<>();
+    Map<ExpressionContext, ExpressionContext> expressionContextOverrideHints;
     Map<Expression, Expression> expressionOverrideHints = 
pinotQuery.getExpressionOverrideHints();
-    if (expressionOverrideHints != null) {
+    if (expressionOverrideHints == null || expressionOverrideHints.isEmpty()) {
+      expressionContextOverrideHints = Map.of();
+    } else if (expressionOverrideHints.size() == 1) {
+      Map.Entry<Expression, Expression> entry = 
expressionOverrideHints.entrySet().iterator().next();
+      expressionContextOverrideHints = 
Map.of(RequestContextUtils.getExpression(entry.getKey()),
+          RequestContextUtils.getExpression(entry.getValue()));
+    } else {
+      expressionContextOverrideHints = new HashMap<>();
       for (Map.Entry<Expression, Expression> entry : 
expressionOverrideHints.entrySet()) {
         
expressionContextOverrideHints.put(RequestContextUtils.getExpression(entry.getKey()),
             RequestContextUtils.getExpression(entry.getValue()));
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/plan/maker/QueryOverrideWithHintsTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/plan/maker/QueryOverrideWithHintsTest.java
index 274b891b129..f9e3ba24dcb 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/plan/maker/QueryOverrideWithHintsTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/plan/maker/QueryOverrideWithHintsTest.java
@@ -19,29 +19,50 @@
 package org.apache.pinot.core.plan.maker;
 
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import org.apache.commons.lang3.tuple.Pair;
 import org.apache.pinot.common.request.Expression;
 import org.apache.pinot.common.request.PinotQuery;
 import org.apache.pinot.common.request.context.ExpressionContext;
 import org.apache.pinot.common.request.context.FilterContext;
 import org.apache.pinot.common.request.context.FunctionContext;
+import org.apache.pinot.common.request.context.OrderByExpressionContext;
 import org.apache.pinot.common.request.context.RequestContextUtils;
 import org.apache.pinot.common.request.context.predicate.EqPredicate;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
 import org.apache.pinot.common.utils.request.RequestUtils;
+import org.apache.pinot.core.query.aggregation.function.AggregationFunction;
+import org.apache.pinot.core.query.executor.ResultsBlockStreamer;
+import org.apache.pinot.core.query.reduce.ReducerDataSchemaUtils;
 import org.apache.pinot.core.query.request.context.QueryContext;
 import 
org.apache.pinot.core.query.request.context.utils.QueryContextConverterUtils;
 import org.apache.pinot.segment.spi.IndexSegment;
+import org.apache.pinot.segment.spi.SegmentContext;
 import org.apache.pinot.spi.data.FieldSpec;
 import org.apache.pinot.sql.parsers.CalciteSqlParser;
 import org.mockito.Mockito;
 import org.testng.annotations.BeforeClass;
+import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertNotEquals;
+import static org.testng.Assert.assertNotSame;
+import static org.testng.Assert.assertSame;
 
 
 public class QueryOverrideWithHintsTest {
@@ -160,19 +181,52 @@ public class QueryOverrideWithHintsTest {
         ExpressionContext.forLiteral(FieldSpec.DataType.INT, 1000));
   }
 
-  @Test
-  public void testRewriteExpressionsWithHints() {
+  @DataProvider
+  public Object[][] multiHintCases() {
+    return new Object[][]{{false}, {true}};
+  }
+
+  @Test(dataProvider = "multiHintCases")
+  public void testRewriteExpressionsWithHints(boolean includeUnavailableHint) {
     PinotQuery pinotQuery = CalciteSqlParser.compileToPinotQuery(
-        "SELECT datetrunc('MONTH', ts), count(*), sum(abc) from myTable group 
by datetrunc('MONTH', ts) ");
+        "SELECT datetrunc('MONTH', ts), count(*), sum(datetrunc('MONTH', ts)) 
from myTable"
+            + " WHERE datetrunc('MONTH', ts) > 0 group by datetrunc('MONTH', 
ts)");
     Expression dateTruncFunctionExpr =
         RequestUtils.getFunctionExpression("datetrunc", 
RequestUtils.getLiteralExpression("MONTH"),
             RequestUtils.getIdentifierExpression("ts"));
     Expression timestampIndexColumn = 
RequestUtils.getIdentifierExpression("$ts$MONTH");
-    pinotQuery.setExpressionOverrideHints(Map.of(dateTruncFunctionExpr, 
timestampIndexColumn));
+    Map<Expression, Expression> hints = new 
HashMap<>(Map.of(dateTruncFunctionExpr, timestampIndexColumn,
+        CalciteSqlParser.compileToExpression("DATETRUNC('MONTH', ts, 
'MILLISECONDS')"), timestampIndexColumn));
+    if (includeUnavailableHint) {
+      hints.put(CalciteSqlParser.compileToExpression("DATETRUNC('DAY', ts)"),
+          RequestUtils.getIdentifierExpression("$ts$DAY"));
+    }
+    pinotQuery.setExpressionOverrideHints(hints);
     QueryContext queryContext = 
QueryContextConverterUtils.getQueryContext(pinotQuery);
-    InstancePlanMakerImplV2.rewriteQueryContextWithHints(queryContext, 
_indexSegment);
+    AggregationFunction count = queryContext.getAggregationFunctions()[0];
+    var filteredAggregations = queryContext.getFilteredAggregationFunctions();
+    var countPair = filteredAggregations.get(0);
+    var sumPair = filteredAggregations.get(1);
+    IndexSegment first = mock(IndexSegment.class);
+    Set<String> columns = spy(new HashSet<>(Set.of("$ts$MONTH")));
+    when(first.getColumnNames()).thenReturn(columns);
+    IndexSegment second = mock(IndexSegment.class);
+    when(second.getColumnNames()).thenReturn(Set.of("$ts$MONTH"));
+    InstancePlanMakerImplV2.rewriteQueryContextWithHints(queryContext,
+        List.of(new SegmentContext(first), new SegmentContext(second)));
+    verify(columns, times(1)).contains("$ts$MONTH");
+    verify(second, times(1)).getColumnNames();
+    assertSame(queryContext.getAggregationFunctions()[0], count);
+    assertSame(queryContext.getFilteredAggregationFunctions(), 
filteredAggregations);
+    assertSame(filteredAggregations.get(0), countPair);
+    assertNotSame(filteredAggregations.get(1), sumPair);
+    assertEquals(queryContext.getFilter().getPredicate().getLhs(), 
ExpressionContext.forIdentifier("$ts$MONTH"));
+    assertEquals(queryContext.getColumns(), Set.of("$ts$MONTH"));
     assertEquals(queryContext.getSelectExpressions().get(0).getIdentifier(), 
"$ts$MONTH");
     assertEquals(queryContext.getGroupByExpressions().get(0).getIdentifier(), 
"$ts$MONTH");
+    
assertEquals(queryContext.getAggregationFunctions()[1].getInputExpressions(),
+        List.of(ExpressionContext.forIdentifier("$ts$MONTH")));
+    assertEquals(queryContext.getExpressionOverrideHints().size(), 
includeUnavailableHint ? 3 : 2);
   }
 
   @Test
@@ -183,10 +237,287 @@ public class QueryOverrideWithHintsTest {
         RequestUtils.getFunctionExpression("datetrunc", 
RequestUtils.getLiteralExpression("DAY"),
             RequestUtils.getIdentifierExpression("ts"));
     Expression timestampIndexColumn = 
RequestUtils.getIdentifierExpression("$ts$DAY");
-    pinotQuery.setExpressionOverrideHints(Map.of(dateTruncFunctionExpr, 
timestampIndexColumn));
+    pinotQuery.setExpressionOverrideHints(Map.of(dateTruncFunctionExpr, 
timestampIndexColumn,
+        CalciteSqlParser.compileToExpression("DATETRUNC('DAY', ts, 
'MILLISECONDS')"), timestampIndexColumn));
     QueryContext queryContext = 
QueryContextConverterUtils.getQueryContext(pinotQuery);
-    InstancePlanMakerImplV2.rewriteQueryContextWithHints(queryContext, 
_indexSegment);
-    assertEquals(queryContext.getSelectExpressions().get(0).getFunction(),
-        
queryContext.getExpressionOverrideHints().keySet().iterator().next().getFunction());
+    IndexSegment first = mock(IndexSegment.class);
+    Set<String> columns = spy(new HashSet<>(Set.of("ts")));
+    when(first.getColumnNames()).thenReturn(columns);
+    IndexSegment later = mock(IndexSegment.class);
+    Map<ExpressionContext, ExpressionContext> originalHints = 
Map.copyOf(queryContext.getExpressionOverrideHints());
+    InstancePlanMakerImplV2.rewriteQueryContextWithHints(queryContext,
+        List.of(new SegmentContext(first), new SegmentContext(later)));
+    verify(columns, times(1)).contains("$ts$DAY");
+    verify(later, never()).getColumnNames();
+    assertEquals(queryContext.getExpressionOverrideHints(), originalHints);
+    
assertEquals(queryContext.getSelectExpressions().get(0).getFunction().getFunctionName(),
 "datetrunc");
+  }
+
+  @DataProvider
+  public Object[][] aggregationRewriteCases() {
+    List<Object[]> cases = new ArrayList<>();
+    for (boolean nullHandling : new boolean[]{false, true}) {
+      for (boolean nested : new boolean[]{false, true}) {
+        for (String predicate : List.of("", "DATETRUNC('MONTH', ts, 
'MILLISECONDS') > 0", "TRUE", "FALSE")) {
+          cases.add(new Object[]{nullHandling, predicate, nested});
+        }
+      }
+    }
+    return cases.toArray(new Object[0][]);
+  }
+
+  @Test(dataProvider = "aggregationRewriteCases")
+  public void testRewriteCompiledAggregation(boolean nullHandlingEnabled, 
String predicate, boolean nested) {
+    boolean filtered = !predicate.isEmpty();
+    String filter = filtered ? " FILTER(WHERE " + predicate + ")" : "";
+    String input = "DATETRUNC('MONTH', ts, 'MILLISECONDS')";
+    if (nested) {
+      input = "ADD(" + input + ", 1)";
+    }
+    QueryContext queryContext = aggregationQuery(
+        "SELECT DISTINCTCOUNT(" + input + ")" + filter + " AS active_days FROM 
myTable", "MONTH", nullHandlingEnabled);
+    if (predicate.equals("TRUE") || predicate.equals("FALSE")) {
+      
assertEquals(queryContext.getFilteredAggregationFunctions().get(0).getRight().isConstant(),
 true);
+    }
+    InstancePlanMakerImplV2.rewriteQueryContextWithHints(queryContext, 
List.of(new SegmentContext(_indexSegment)));
+
+    ExpressionContext derived = ExpressionContext.forIdentifier("$ts$MONTH");
+    ExpressionContext expectedInput = nested
+        ? 
RequestContextUtils.getExpression(CalciteSqlParser.compileToExpression("ADD(\"$ts$MONTH\",
 1)")) : derived;
+    
assertEquals(queryContext.getAggregationFunctions()[0].getInputExpressions(), 
List.of(expectedInput));
+    assertEquals(queryContext.getColumns(), Set.of("$ts$MONTH"));
+    assertEquals(queryContext.getAliasList(), List.of("active_days"));
+    assertEquals(queryContext.isNullHandlingEnabled(), nullHandlingEnabled);
+    assertEquals(queryContext.hasFilteredAggregations(), filtered);
+    var pair = queryContext.getFilteredAggregationFunctions().get(0);
+    if (filtered) {
+      if (predicate.equals("TRUE") || predicate.equals("FALSE")) {
+        assertEquals(pair.getRight().isConstant(), true);
+        assertEquals(pair.getRight().isConstantTrue(), 
Boolean.parseBoolean(predicate));
+        assertEquals(pair.getRight().isConstantFalse(), 
!Boolean.parseBoolean(predicate));
+      } else {
+        assertEquals(pair.getRight().getPredicate().getLhs(), derived);
+      }
+    }
+    FunctionContext function = 
queryContext.getSelectExpressions().get(0).getFunction();
+    if (filtered) {
+      function = function.getArguments().get(0).getFunction();
+    }
+    
assertEquals(queryContext.getFilteredAggregationsIndexMap().get(Pair.of(function,
 pair.getRight())),
+        Integer.valueOf(0));
+
+    AggregationFunction compiled = queryContext.getAggregationFunctions()[0];
+    InstancePlanMakerImplV2.rewriteQueryContextWithHints(queryContext, 
List.of(new SegmentContext(_indexSegment)));
+    assertSame(queryContext.getAggregationFunctions()[0], compiled);
+  }
+
+  @DataProvider
+  public Object[][] orderByRewriteCases() {
+    List<Object[]> cases = new ArrayList<>();
+    for (String expression : List.of("ts", "DATETRUNC('MONTH', ts, 
'MILLISECONDS')",
+        "ADD(DATETRUNC('MONTH', ts, 'MILLISECONDS'), 1)")) {
+      for (String ordering : List.of("ASC NULLS FIRST", "ASC NULLS LAST", 
"DESC NULLS FIRST", "DESC NULLS LAST")) {
+        cases.add(new Object[]{expression, ordering});
+      }
+    }
+    return cases.toArray(new Object[0][]);
+  }
+
+  @Test(dataProvider = "orderByRewriteCases")
+  public void testRewritePreservesNullOrdering(String expression, String 
ordering) {
+    QueryContext context = aggregationQuery(
+        "SELECT DATETRUNC('MONTH', ts, 'MILLISECONDS') FROM myTable ORDER BY " 
+ expression + " " + ordering,
+        "MONTH", true);
+    OrderByExpressionContext original = context.getOrderByExpressions().get(0);
+    InstancePlanMakerImplV2.rewriteQueryContextWithHints(context, List.of(new 
SegmentContext(_indexSegment)));
+    OrderByExpressionContext rewritten = 
context.getOrderByExpressions().get(0);
+    assertEquals(rewritten.isAsc(), original.isAsc());
+    assertEquals(rewritten.isNullsLast(), original.isNullsLast());
+    if (expression.startsWith("DATETRUNC")) {
+      assertEquals(rewritten.getExpression(), 
ExpressionContext.forIdentifier("$ts$MONTH"));
+      assertNotSame(rewritten, original);
+    } else {
+      assertSame(rewritten, original);
+      if (expression.startsWith("ADD")) {
+        
assertEquals(rewritten.getExpression().getFunction().getArguments().get(0),
+            ExpressionContext.forIdentifier("$ts$MONTH"));
+      }
+    }
+  }
+
+  @Test
+  public void testPreparationWithoutHints() {
+    QueryContext queryContext =
+        QueryContextConverterUtils.getQueryContext("SELECT COUNT(*), SUM(ts), 
SUM(ts) FROM myTable");
+    assertEquals(queryContext.getAggregationFunctions().length, 3);
+    var filteredAggregations = queryContext.getFilteredAggregationFunctions();
+    var countPair = filteredAggregations.get(0);
+    var sumPair = filteredAggregations.get(1);
+    var duplicatePair = filteredAggregations.get(2);
+    var indexMap = queryContext.getFilteredAggregationsIndexMap();
+    AggregationFunction count = queryContext.getAggregationFunctions()[0];
+    AggregationFunction sum = queryContext.getAggregationFunctions()[1];
+    FunctionContext sumContext = 
queryContext.getSelectExpressions().get(1).getFunction();
+    sumContext.getArguments().set(0, ExpressionContext.forIdentifier("other"));
+    Set<FunctionContext> changed = Collections.newSetFromMap(new 
IdentityHashMap<>());
+    changed.add(sumContext);
+    queryContext.prepareForExecution(changed);
+    assertSame(queryContext.getAggregationFunctions()[0], count);
+    assertSame(queryContext.getFilteredAggregationFunctions(), 
filteredAggregations);
+    assertSame(queryContext.getFilteredAggregationsIndexMap(), indexMap);
+    assertSame(filteredAggregations.get(0), countPair);
+    assertNotSame(filteredAggregations.get(1), sumPair);
+    assertSame(filteredAggregations.get(2), duplicatePair);
+    assertNotSame(queryContext.getAggregationFunctions()[1], sum);
+    
assertEquals(queryContext.getAggregationFunctions()[1].getInputExpressions(),
+        List.of(ExpressionContext.forIdentifier("other")));
+    assertEquals(queryContext.getColumns(), Set.of("ts", "other"));
+
+    ExpressionContext first = ExpressionContext.forIdentifier("first");
+    ExpressionContext second = ExpressionContext.forIdentifier("second");
+    for (Pair<List<ExpressionContext>, Boolean> testCase : 
List.of(Pair.of(List.of(first), false),
+        Pair.of(List.of(second), true), Pair.of(List.of(first, second), false),
+        Pair.of(List.of(first, first, second), false))) {
+      List<ExpressionContext> groupKeys = testCase.getLeft();
+      List<OrderByExpressionContext> orderKeys = groupKeys.size() == 1
+          ? List.of(new OrderByExpressionContext(first, true))
+          : List.of(new OrderByExpressionContext(second, true), new 
OrderByExpressionContext(first, true));
+      QueryContext grouped = new 
QueryContext.Builder().setSelectExpressions(List.of(first, second))
+          
.setGroupByExpressions(groupKeys).setOrderByExpressions(orderKeys).build();
+      assertEquals(grouped.isUnsafeTrim(), testCase.getRight().booleanValue());
+    }
+  }
+
+  @Test
+  public void testFilterOnlyRewriteRetainsAggregationEntries() {
+    for (boolean aggregationFilter : new boolean[]{false, true}) {
+      String predicate = "DATETRUNC('MONTH', ts, 'MILLISECONDS') > 0";
+      String sql = aggregationFilter ? "SELECT SUM(ts) FILTER(WHERE " + 
predicate + ") FROM myTable"
+          : "SELECT SUM(ts) FROM myTable WHERE " + predicate;
+      QueryContext context = aggregationQuery(sql, "MONTH", false);
+      var aggregations = context.getFilteredAggregationFunctions();
+      var pair = aggregations.get(0);
+      var indexMap = context.getFilteredAggregationsIndexMap();
+      AggregationFunction function = context.getAggregationFunctions()[0];
+      InstancePlanMakerImplV2.rewriteQueryContextWithHints(context, 
List.of(new SegmentContext(_indexSegment)));
+      assertSame(context.getFilteredAggregationFunctions(), aggregations);
+      assertSame(aggregations.get(0), pair);
+      assertSame(context.getAggregationFunctions()[0], function);
+      assertSame(context.getFilteredAggregationsIndexMap(), indexMap);
+      assertEquals(context.hasFilteredAggregations(), aggregationFilter);
+      FilterContext filter = aggregationFilter ? pair.getRight() : 
context.getFilter();
+      assertEquals(filter.getPredicate().getLhs(), 
ExpressionContext.forIdentifier("$ts$MONTH"));
+      FunctionContext aggregation = 
context.getSelectExpressions().get(0).getFunction();
+      if (aggregationFilter) {
+        aggregation = aggregation.getArguments().get(0).getFunction();
+      }
+      assertEquals(indexMap.get(Pair.of(aggregation, pair.getRight())), 
Integer.valueOf(0));
+      assertEquals(context.getColumns(), Set.of("ts", "$ts$MONTH"));
+    }
+  }
+
+  @Test(expectedExceptions = IllegalArgumentException.class,
+      expectedExceptionsMessageRegExp = "Expression override target must be an 
identifier")
+  public void testRejectNonIdentifierOverrideTarget() {
+    PinotQuery query = CalciteSqlParser.compileToPinotQuery("SELECT 
DATETRUNC('MONTH', ts) FROM myTable");
+    
query.setExpressionOverrideHints(Map.of(CalciteSqlParser.compileToExpression("DATETRUNC('MONTH',
 ts)"),
+        RequestUtils.getLiteralExpression(0)));
+    
InstancePlanMakerImplV2.rewriteQueryContextWithHints(QueryContextConverterUtils.getQueryContext(query),
+        List.of(new SegmentContext(_indexSegment)));
+  }
+
+  @Test
+  public void testRewritePreservesAggregationSlots() {
+    String sql = "SELECT DISTINCTCOUNT(DATETRUNC('MONTH', ts, 'MILLISECONDS')) 
AS days, "
+        + "DISTINCTCOUNT(DATETRUNC('MONTH', ts, 'MILLISECONDS')) AS 
duplicate_days FROM myTable "
+        + "HAVING DISTINCTCOUNT(\"$ts$MONTH\") > 0 ORDER BY 
DISTINCTCOUNT(\"$ts$MONTH\")";
+    QueryContext queryContext = aggregationQuery(sql, "MONTH", false);
+    QueryContext brokerContext = aggregationQuery(sql, "MONTH", false);
+    assertEquals(queryContext.getAggregationFunctions().length, 3);
+    InstancePlanMakerImplV2.rewriteQueryContextWithHints(queryContext, 
List.of(new SegmentContext(_indexSegment)));
+    // Converging SELECT and HAVING/ORDER-BY expressions must keep their 
original wire slots.
+    assertEquals(queryContext.getAggregationFunctions().length, 3);
+    assertEquals(queryContext.getFilteredAggregationFunctions().size(), 3);
+    for (AggregationFunction function : 
queryContext.getAggregationFunctions()) {
+      assertEquals(function.getInputExpressions(), 
List.of(ExpressionContext.forIdentifier("$ts$MONTH")));
+    }
+    FunctionContext having = 
queryContext.getHavingFilter().getPredicate().getLhs().getFunction();
+    
assertEquals(queryContext.getFilteredAggregationsIndexMap().get(Pair.of(having, 
null)), Integer.valueOf(2));
+    assertEquals(queryContext.getAliasList(), List.of("days", 
"duplicate_days"));
+    String[] physicalNames = new String[3];
+    String[] logicalNames = new String[3];
+    ColumnDataType[] types = new ColumnDataType[3];
+    for (int i = 0; i < 3; i++) {
+      physicalNames[i] = 
queryContext.getAggregationFunctions()[i].getResultColumnName();
+      logicalNames[i] = 
brokerContext.getAggregationFunctions()[i].getResultColumnName();
+      types[i] = 
queryContext.getAggregationFunctions()[i].getIntermediateResultColumnType();
+    }
+    DataSchema canonical = 
ReducerDataSchemaUtils.canonicalizeDataSchemaForAggregation(brokerContext,
+        new DataSchema(physicalNames, types));
+    assertEquals(canonical.getColumnNames(), logicalNames);
+    assertEquals(canonical.getColumnDataTypes(), types);
+  }
+
+  @Test
+  public void testMissingIndexRetainsCompiledAggregation() {
+    QueryContext queryContext = aggregationQuery(
+        "SELECT DISTINCTCOUNT(DATETRUNC('DAY', ts, 'MILLISECONDS')) AS 
active_days FROM myTable", "DAY", false);
+    assertEquals(queryContext.getExpressionOverrideHints().size(), 1);
+    
assertEquals(queryContext.getExpressionOverrideHints().get(RequestContextUtils.getExpression(
+        CalciteSqlParser.compileToExpression("DATETRUNC('DAY', ts, 
'MILLISECONDS')"))),
+        ExpressionContext.forIdentifier("$ts$DAY"));
+    AggregationFunction function = queryContext.getAggregationFunctions()[0];
+    List<ExpressionContext> inputs = function.getInputExpressions();
+    IndexSegment later = mock(IndexSegment.class);
+    InstancePlanMakerImplV2.rewriteQueryContextWithHints(queryContext,
+        List.of(new SegmentContext(_indexSegment), new SegmentContext(later)));
+    verify(later, never()).getColumnNames();
+    assertSame(queryContext.getAggregationFunctions()[0], function);
+    
assertEquals(queryContext.getAggregationFunctions()[0].getInputExpressions(), 
inputs);
+    assertEquals(queryContext.getColumns(), Set.of("ts"));
+    assertEquals(queryContext.getAliasList(), List.of("active_days"));
+  }
+
+  @DataProvider
+  public Object[][] instanceRewriteCases() {
+    return new Object[][]{{false, false}, {true, false}, {false, true}, {true, 
true}};
+  }
+
+  @Test(dataProvider = "instanceRewriteCases")
+  public void testInstanceRewriteRequiresIndexOnEverySegment(boolean 
streaming, boolean allIndexed) {
+    QueryContext queryContext = aggregationQuery(
+        "SELECT DISTINCTCOUNT(DATETRUNC('MONTH', ts, 'MILLISECONDS')) AS 
active_days FROM myTable", "MONTH", false);
+    IndexSegment second = mock(IndexSegment.class);
+    when(second.getColumnNames()).thenReturn(allIndexed ? Set.of("ts", 
"$ts$MONTH") : Set.of("ts"));
+    List<SegmentContext> segments = List.of(new SegmentContext(_indexSegment), 
new SegmentContext(second));
+    Map<ExpressionContext, ExpressionContext> hints = 
queryContext.getExpressionOverrideHints();
+    Map<ExpressionContext, ExpressionContext> originalHints = 
Map.copyOf(hints);
+    InstancePlanMakerImplV2 planMaker = new InstancePlanMakerImplV2();
+    ExecutorService executor = mock(ExecutorService.class);
+    if (streaming) {
+      planMaker.makeStreamingInstancePlan(segments, queryContext, executor, 
mock(ResultsBlockStreamer.class));
+    } else {
+      planMaker.makeInstancePlan(segments, queryContext, executor);
+    }
+    assertSame(queryContext.getExpressionOverrideHints(), hints);
+    assertEquals(hints, originalHints);
+    AggregationFunction<?, ?> aggregation = 
queryContext.getAggregationFunctions()[0];
+    ExpressionContext input = aggregation.getInputExpressions().get(0);
+    if (allIndexed) {
+      assertEquals(input, ExpressionContext.forIdentifier("$ts$MONTH"));
+      assertEquals(queryContext.getColumns(), Set.of("$ts$MONTH"));
+    } else {
+      assertEquals(input.getFunction().getFunctionName(), "datetrunc");
+      assertEquals(queryContext.getColumns(), Set.of("ts"));
+    }
+  }
+
+  private static QueryContext aggregationQuery(String sql, String granularity, 
boolean nullHandlingEnabled) {
+    PinotQuery pinotQuery = CalciteSqlParser.compileToPinotQuery(sql);
+    pinotQuery.setQueryOptions(Map.of("enableNullHandling", 
Boolean.toString(nullHandlingEnabled)));
+    pinotQuery.setExpressionOverrideHints(Map.of(
+        CalciteSqlParser.compileToExpression("DATETRUNC('" + granularity + "', 
ts, 'MILLISECONDS')"),
+        RequestUtils.getIdentifierExpression("$ts$" + granularity)));
+    return QueryContextConverterUtils.getQueryContext(pinotQuery);
   }
 }
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/queries/TimestampQueriesTest.java 
b/pinot-core/src/test/java/org/apache/pinot/queries/TimestampQueriesTest.java
index 824c762ea6e..72a1cda43ef 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/queries/TimestampQueriesTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/queries/TimestampQueriesTest.java
@@ -24,30 +24,46 @@ import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import org.apache.commons.io.FileUtils;
+import org.apache.pinot.common.request.PinotQuery;
 import org.apache.pinot.common.response.broker.BrokerResponseNative;
 import org.apache.pinot.common.response.broker.ResultTable;
 import org.apache.pinot.common.utils.DataSchema;
 import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
+import org.apache.pinot.common.utils.request.RequestUtils;
+import org.apache.pinot.core.operator.blocks.InstanceResponseBlock;
+import org.apache.pinot.core.plan.maker.InstancePlanMakerImplV2;
+import org.apache.pinot.core.query.request.context.QueryContext;
+import 
org.apache.pinot.core.query.request.context.utils.QueryContextConverterUtils;
 import 
org.apache.pinot.segment.local.indexsegment.immutable.ImmutableSegmentLoader;
 import 
org.apache.pinot.segment.local.segment.creator.impl.SegmentIndexCreationDriverImpl;
 import org.apache.pinot.segment.local.segment.readers.GenericRowRecordReader;
 import org.apache.pinot.segment.spi.ImmutableSegment;
 import org.apache.pinot.segment.spi.IndexSegment;
+import org.apache.pinot.segment.spi.SegmentContext;
 import org.apache.pinot.segment.spi.creator.SegmentGeneratorConfig;
+import org.apache.pinot.spi.config.table.FieldConfig;
 import org.apache.pinot.spi.config.table.TableConfig;
 import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.config.table.TimestampConfig;
+import org.apache.pinot.spi.config.table.TimestampIndexGranularity;
 import org.apache.pinot.spi.data.FieldSpec.DataType;
 import org.apache.pinot.spi.data.Schema;
 import org.apache.pinot.spi.data.readers.GenericRow;
 import org.apache.pinot.spi.exception.BadQueryRequestException;
+import org.apache.pinot.spi.query.QueryThreadContext;
 import org.apache.pinot.spi.utils.ReadMode;
 import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.apache.pinot.sql.parsers.CalciteSqlParser;
 import org.testng.annotations.AfterClass;
 import org.testng.annotations.BeforeClass;
+import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
 import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
 
 
 /// Queries test for TIMESTAMP data type.
@@ -66,6 +82,7 @@ public class TimestampQueriesTest extends BaseQueriesTest {
       new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
 
   private IndexSegment _indexSegment;
+  private IndexSegment _timestampIndexedSegment;
   private List<IndexSegment> _indexSegments;
 
   @Override
@@ -115,6 +132,58 @@ public class TimestampQueriesTest extends BaseQueriesTest {
     ImmutableSegment immutableSegment = ImmutableSegmentLoader.load(new 
File(INDEX_DIR, SEGMENT_NAME), ReadMode.mmap);
     _indexSegment = immutableSegment;
     _indexSegments = Arrays.asList(immutableSegment, immutableSegment);
+
+    TableConfig indexedConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
+        .setFieldConfigList(List.of(new 
FieldConfig.Builder(TIMESTAMP_COLUMN).withTimestampConfig(
+            new 
TimestampConfig(List.of(TimestampIndexGranularity.DAY))).build())).build();
+    Schema indexedSchema = new Schema.SchemaBuilder()
+        .addDateTime(TIMESTAMP_COLUMN, DataType.TIMESTAMP, 
"1:MILLISECONDS:EPOCH", "1:MILLISECONDS").build();
+    SegmentGeneratorConfig indexedGenerator = new 
SegmentGeneratorConfig(indexedConfig, indexedSchema);
+    indexedGenerator.setSegmentName(SEGMENT_NAME + "_indexed");
+    indexedGenerator.setOutDir(INDEX_DIR.getPath());
+    List<GenericRow> indexedRecords = new ArrayList<>(NUM_RECORDS);
+    for (int i = 0; i < NUM_RECORDS; i++) {
+      GenericRow indexedRecord = new GenericRow();
+      indexedRecord.putValue(TIMESTAMP_COLUMN, BASE_TIMESTAMP + i);
+      indexedRecords.add(indexedRecord);
+    }
+    SegmentIndexCreationDriverImpl indexedDriver = new 
SegmentIndexCreationDriverImpl();
+    indexedDriver.init(indexedGenerator, new 
GenericRowRecordReader(indexedRecords));
+    indexedDriver.build();
+    _timestampIndexedSegment =
+        ImmutableSegmentLoader.load(new File(INDEX_DIR, SEGMENT_NAME + 
"_indexed"), ReadMode.mmap);
+  }
+
+  @DataProvider
+  public Object[][] timestampIndexSegmentOrders() {
+    return new Object[][]{{false, false}, {true, false}, {false, true}, {true, 
true}};
+  }
+
+  @Test(dataProvider = "timestampIndexSegmentOrders")
+  public void testTimestampAggregationWithPrefetch(boolean reversed, boolean 
allIndexed)
+      throws Exception {
+    String expression = "DATETRUNC('DAY', " + TIMESTAMP_COLUMN + ", 
'MILLISECONDS')";
+    String derived = "$" + TIMESTAMP_COLUMN + "$DAY";
+    assertTrue(_timestampIndexedSegment.getColumnNames().contains(derived));
+    PinotQuery query = CalciteSqlParser.compileToPinotQuery(
+        "SELECT DISTINCTCOUNT(" + expression + "), COUNT(*) FROM " + 
RAW_TABLE_NAME);
+    
query.setExpressionOverrideHints(Map.of(CalciteSqlParser.compileToExpression(expression),
+        RequestUtils.getIdentifierExpression(derived)));
+    QueryContext context = QueryContextConverterUtils.getQueryContext(query);
+    context.setEnablePrefetch(true);
+    context.setServerReturnFinalResult(true);
+    context.setEndTimeMs(System.currentTimeMillis() + 30_000);
+    IndexSegment second = allIndexed ? _timestampIndexedSegment : 
_indexSegment;
+    List<SegmentContext> segments = reversed
+        ? List.of(new SegmentContext(second), new 
SegmentContext(_timestampIndexedSegment))
+        : List.of(new SegmentContext(_timestampIndexedSegment), new 
SegmentContext(second));
+    try (QueryThreadContext ignored = QueryThreadContext.openForSseTest()) {
+      InstanceResponseBlock result =
+          new InstancePlanMakerImplV2().makeInstancePlan(segments, context, 
EXECUTOR_SERVICE).execute();
+      assertEquals(((Number) result.getRows().get(0)[0]).longValue(), 1L);
+      assertEquals(((Number) result.getRows().get(0)[1]).longValue(), 2L * 
NUM_RECORDS);
+    }
+    assertEquals(context.getColumns(), allIndexed ? Set.of(derived) : 
Set.of(TIMESTAMP_COLUMN));
   }
 
   @Test
@@ -238,6 +307,7 @@ public class TimestampQueriesTest extends BaseQueriesTest {
   public void tearDown()
       throws IOException {
     _indexSegment.destroy();
+    _timestampIndexedSegment.destroy();
     FileUtils.deleteDirectory(INDEX_DIR);
   }
 }


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

Reply via email to