This is an automated email from the ASF dual-hosted git repository.

yashmayya 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 f8352be252f Preserve aggregate hints through ProjectAggregateMergeRule 
(#19324)
f8352be252f is described below

commit f8352be252fc1a6cf099480a7812d1555d264e0e
Author: Yash Mayya <[email protected]>
AuthorDate: Thu Aug 20 17:59:59 2026 -0400

    Preserve aggregate hints through ProjectAggregateMergeRule (#19324)
---
 .../rel/rules/PinotProjectAggregateMergeRule.java  | 135 +++++++++++++++++++++
 .../calcite/rel/rules/PinotQueryRuleSets.java      |   7 +-
 .../apache/pinot/query/QueryCompilationTest.java   |  49 ++++++++
 3 files changed, 188 insertions(+), 3 deletions(-)

diff --git 
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotProjectAggregateMergeRule.java
 
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotProjectAggregateMergeRule.java
new file mode 100644
index 00000000000..f76bc899b38
--- /dev/null
+++ 
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotProjectAggregateMergeRule.java
@@ -0,0 +1,135 @@
+/**
+ * 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.calcite.rel.rules;
+
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.calcite.plan.RelHintsPropagator;
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.RelShuttleImpl;
+import org.apache.calcite.rel.core.Aggregate;
+import org.apache.calcite.rel.hint.RelHint;
+import org.apache.calcite.rel.logical.LogicalAggregate;
+import org.apache.calcite.rel.rules.ProjectAggregateMergeRule;
+
+
+/// Pinot customized version of [ProjectAggregateMergeRule] that preserves the 
matched [Aggregate]'s hints.
+///
+/// [ProjectAggregateMergeRule] rebuilds the aggregate from scratch with a 
`RelBuilder`, so the rebuilt node
+/// starts out with no hints. Calcite normally repairs this automatically: 
[RelOptRuleCall#transformTo(RelNode)]
+/// defaults to propagating the hints of `rels[0]` (the node the rule matched 
on) into the new sub-tree. That
+/// repair does not apply here, because `rels[0]` is the `Project`, and 
`aggOptions` hints are attached to the
+/// `Aggregate` (see `HintPredicates.AGGREGATE` in
+/// [org.apache.pinot.calcite.rel.hint.PinotHintStrategyTable]). The 
aggregate's hints are therefore dropped
+/// silently, and every `aggOptions` option on it is lost — including 
`is_partitioned_by_group_by_keys`,
+/// `is_skip_leaf_stage_group_by`, `is_leaf_return_final_result` and the 
group-trim options.
+///
+/// The user-visible symptom is a colocation hint that stops working as soon 
as a `Project` lands directly above
+/// the aggregate. The most common way that happens is a `SUM` over a 
*nullable* argument:
+/// [PinotAggregateReduceFunctionsRule] rewrites it to `$SUM0(x) + COUNT(x)` 
plus a
+/// `CASE(COUNT(x) = 0, NULL, $SUM0(x))` project, which is exactly the pattern 
this rule matches. With a
+/// non-nullable argument the reduction collapses to a bare `$SUM0` with no 
project, no match, and the hint
+/// survives — which is why the problem only shows up on some queries.
+///
+/// This rule reuses Calcite's transformation verbatim (no forked rule body to 
drift out of sync) and only
+/// re-attaches the hints afterwards. The rule never changes the aggregate's 
group set, so its hints remain
+/// valid for the rebuilt node.
+public class PinotProjectAggregateMergeRule extends ProjectAggregateMergeRule {
+
+  public static PinotProjectAggregateMergeRule instanceWithDescription(String 
description) {
+    return new PinotProjectAggregateMergeRule((Config) 
Config.DEFAULT.withDescription(description));
+  }
+
+  private PinotProjectAggregateMergeRule(Config config) {
+    super(config);
+  }
+
+  @Override
+  public void onMatch(RelOptRuleCall call) {
+    Aggregate aggregate = call.rel(1);
+    if (aggregate.getHints().isEmpty()) {
+      super.onMatch(call);
+    } else {
+      super.onMatch(new HintPreservingRuleCall(call, aggregate));
+    }
+  }
+
+  /// Delegating [RelOptRuleCall] that re-attaches the matched aggregate's 
hints to the rebuilt aggregate before
+  /// handing the rule's output to the real call.
+  ///
+  /// The hints are copied verbatim rather than through
+  /// [org.apache.calcite.plan.RelOptUtil#propagateRelHints(RelNode, 
RelNode)]: that helper appends the child
+  /// index to each hint's [RelHint#inheritPath] as it descends, so 
re-propagating on every rule application grows
+  /// the inherit path without bound. The rebuilt node then never compares 
equal to the previous one, the rule
+  /// keeps re-firing on its own output, and planning dies with a 
`StackOverflowError`. Copying the hint list
+  /// unchanged makes the rewrite a fixpoint, so the planner terminates after 
it has been applied once.
+  private static class HintPreservingRuleCall extends RelOptRuleCall {
+    private final RelOptRuleCall _delegate;
+    private final Aggregate _aggregate;
+
+    HintPreservingRuleCall(RelOptRuleCall delegate, Aggregate aggregate) {
+      super(delegate.getPlanner(), delegate.getOperand0(), delegate.getRels(), 
Map.of(), delegate.getParents());
+      _delegate = delegate;
+      _aggregate = aggregate;
+    }
+
+    @Override
+    public void transformTo(RelNode rel, Map<RelNode, RelNode> equiv, 
RelHintsPropagator handler) {
+      _delegate.transformTo(rel.accept(new 
RestoreAggregateHintsShuttle(_aggregate.getHints())), equiv, handler);
+    }
+
+    @Nullable
+    @Override
+    public List<RelNode> getChildRels(RelNode rel) {
+      return _delegate.getChildRels(rel);
+    }
+  }
+
+  /// Attaches the given hints to the topmost [Aggregate] of the tree it 
visits, which is the aggregate
+  /// [ProjectAggregateMergeRule] rebuilt. Nothing below it is touched: the 
rule reuses the original aggregate's
+  /// input unchanged, so the only hintable node it recreates is the aggregate 
itself.
+  private static class RestoreAggregateHintsShuttle extends RelShuttleImpl {
+    private final List<RelHint> _hints;
+    private boolean _restored;
+
+    RestoreAggregateHintsShuttle(List<RelHint> hints) {
+      _hints = hints;
+    }
+
+    @Override
+    public RelNode visit(LogicalAggregate aggregate) {
+      return restoreHints(aggregate);
+    }
+
+    @Override
+    public RelNode visit(RelNode other) {
+      return other instanceof Aggregate ? restoreHints((Aggregate) other) : 
super.visit(other);
+    }
+
+    private RelNode restoreHints(Aggregate aggregate) {
+      if (_restored) {
+        return aggregate;
+      }
+      _restored = true;
+      return aggregate.withHints(_hints);
+    }
+  }
+}
diff --git 
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotQueryRuleSets.java
 
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotQueryRuleSets.java
index e3f54b0bd58..38822d4dfad 100644
--- 
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotQueryRuleSets.java
+++ 
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotQueryRuleSets.java
@@ -32,7 +32,6 @@ import org.apache.calcite.rel.rules.FilterMergeRule;
 import org.apache.calcite.rel.rules.FilterProjectTransposeRule;
 import org.apache.calcite.rel.rules.FilterSetOpTransposeRule;
 import org.apache.calcite.rel.rules.JoinPushExpressionsRule;
-import org.apache.calcite.rel.rules.ProjectAggregateMergeRule;
 import org.apache.calcite.rel.rules.ProjectFilterTransposeRule;
 import org.apache.calcite.rel.rules.ProjectMergeRule;
 import org.apache.calcite.rel.rules.ProjectRemoveRule;
@@ -238,8 +237,10 @@ public class PinotQueryRuleSets {
       UnionMergeRule.Config.DEFAULT
           .withDescription(PlannerRuleNames.UNION_MERGE).toRule(),
       // Drop unused aggregate calls when a Project on top of the Aggregate 
doesn't reference them. Default-on.
-      ProjectAggregateMergeRule.Config.DEFAULT
-          .withDescription(PlannerRuleNames.PROJECT_AGGREGATE_MERGE).toRule(),
+      // Pinot fork of Calcite's ProjectAggregateMergeRule: the stock rule 
rebuilds the aggregate and silently
+      // drops its aggOptions hints, because Calcite only restores the hints 
of the node the rule matched on
+      // (the Project). See PinotProjectAggregateMergeRule.
+      
PinotProjectAggregateMergeRule.instanceWithDescription(PlannerRuleNames.PROJECT_AGGREGATE_MERGE),
       PruneEmptyRules.CorrelateLeftEmptyRuleConfig.DEFAULT
           
.withDescription(PlannerRuleNames.PRUNE_EMPTY_CORRELATE_LEFT).toRule(),
       PruneEmptyRules.CorrelateRightEmptyRuleConfig.DEFAULT
diff --git 
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
index 54e916982c5..66391119edd 100644
--- 
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
+++ 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
@@ -772,6 +772,55 @@ public class QueryCompilationTest extends 
QueryEnvironmentTestBase {
     }
   }
 
+  /// `ProjectAggregateMergeRule` rebuilds the aggregate with a `RelBuilder`, 
and Calcite's automatic hint
+  /// propagation only restores the hints of the node the rule matched on -- 
the `Project`, which never carries
+  /// `aggOptions`. Without 
[org.apache.pinot.calcite.rel.rules.PinotProjectAggregateMergeRule] the 
aggregate's
+  /// hints are dropped and the aggregate is split into LEAF + exchange + 
FINAL despite the colocation hint.
+  ///
+  /// A `Project` lands directly above the aggregate here because the `SUM` 
argument is nullable:
+  /// `PinotAggregateReduceFunctionsRule` rewrites `SUM(x)` into `$SUM0(x) + 
COUNT(x)` plus a
+  /// `CASE(COUNT(x) = 0, NULL, $SUM0(x))` project. With a non-nullable 
argument the reduction collapses to a
+  /// bare `$SUM0` with no project, the rule does not match, and the hint 
survives either way.
+  @Test
+  public void testAggregateHintSurvivesProjectAggregateMerge() {
+    String query = "EXPLAIN PLAN FOR SELECT /*+ 
aggOptions(is_partitioned_by_group_by_keys='true') */ "
+        + "col1, SUM(CASE WHEN col3 > 5 THEN col3 ELSE NULL END) FROM b GROUP 
BY col1";
+    String explain = _queryEnvironment.explainQuery(query, 
RANDOM_REQUEST_ID_GEN.nextLong());
+    assertTrue(explain.contains("aggType=[DIRECT]"),
+        "is_partitioned_by_group_by_keys should produce a DIRECT aggregate, 
but got:\n" + explain);
+    assertFalse(explain.contains("PinotLogicalExchange"),
+        "A colocated aggregate must not have an exchange below it, but got:\n" 
+ explain);
+  }
+
+  /// Same defect reached through a window function, which is how it shows up 
in practice: `LAG` is nullable, so
+  /// any expression derived from it is nullable, so the `SUM` over it takes 
the reduction path above. The window
+  /// itself is colocated by `windowOptions`; only the aggregate above it used 
to lose its hint.
+  @Test
+  public void testAggregateHintSurvivesProjectAggregateMergeAboveWindow() {
+    String query = "EXPLAIN PLAN FOR WITH w AS ("
+        + "SELECT /*+ windowOptions(is_partitioned_by_window_keys='true') */ 
col1, col3, ts, "
+        + "LAG(col3, 1) OVER (PARTITION BY col1 ORDER BY ts) AS prev FROM b) "
+        + "SELECT /*+ aggOptions(is_partitioned_by_group_by_keys='true') */ "
+        + "col1, SUM(CASE WHEN prev IS NULL THEN 0 ELSE col3 - prev END) FROM 
w GROUP BY col1";
+    String explain = _queryEnvironment.explainQuery(query, 
RANDOM_REQUEST_ID_GEN.nextLong());
+    assertTrue(explain.contains("aggType=[DIRECT]"),
+        "is_partitioned_by_group_by_keys should produce a DIRECT aggregate, 
but got:\n" + explain);
+  }
+
+  /// The other `aggOptions` options travel on the same hint and were lost the 
same way.
+  /// `is_skip_leaf_stage_group_by` must still push the aggregate above the 
exchange.
+  @Test
+  public void testSkipLeafStageGroupByHintSurvivesProjectAggregateMerge() {
+    String query = "EXPLAIN PLAN FOR SELECT /*+ 
aggOptions(is_skip_leaf_stage_group_by='true') */ "
+        + "col1, SUM(CASE WHEN col3 > 5 THEN col3 ELSE NULL END) FROM b GROUP 
BY col1";
+    String explain = _queryEnvironment.explainQuery(query, 
RANDOM_REQUEST_ID_GEN.nextLong());
+    assertTrue(explain.contains("aggType=[DIRECT]"),
+        "is_skip_leaf_stage_group_by should produce a single DIRECT aggregate 
above the exchange, but got:\n"
+            + explain);
+    assertFalse(explain.contains("aggType=[LEAF]"),
+        "is_skip_leaf_stage_group_by must not leave a LEAF aggregate, but 
got:\n" + explain);
+  }
+
   @Test
   public void testQueryWithHint() {
     // Hinting the query to use final stage aggregation makes server directly 
return final result


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

Reply via email to