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

gortiz 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 a6d16ef3772 Fix ClassCastException in skip-leaf direct aggregation 
with deduplicated agg calls (#19023)
a6d16ef3772 is described below

commit a6d16ef377265b060635407e71e872fe3376aef6
Author: Gonzalo Ortiz Jaureguizar <[email protected]>
AuthorDate: Tue Jul 21 12:01:46 2026 +0200

    Fix ClassCastException in skip-leaf direct aggregation with deduplicated 
agg calls (#19023)
---
 .../PinotAggregateExchangeNodeInsertRule.java      |  38 +++++--
 .../PinotAggregateExchangeNodeInsertRuleTest.java  | 121 +++++++++++++++++++++
 2 files changed, 152 insertions(+), 7 deletions(-)

diff --git 
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotAggregateExchangeNodeInsertRule.java
 
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotAggregateExchangeNodeInsertRule.java
index fb3d0f437c4..96f9c422668 100644
--- 
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotAggregateExchangeNodeInsertRule.java
+++ 
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotAggregateExchangeNodeInsertRule.java
@@ -160,7 +160,7 @@ public class PinotAggregateExchangeNodeInsertRule {
         return;
       }
 
-      PinotLogicalAggregate newAggRel = createPlan(call, aggRel, true, 
hintOptions, newCollations, limit);
+      RelNode newAggRel = createPlan(call, aggRel, true, hintOptions, 
newCollations, limit);
       RelNode newProjectRel = projectRel.copy(projectRel.getTraitSet(), 
List.of(newAggRel));
       call.transformTo(sortRel.copy(sortRel.getTraitSet(), 
List.of(newProjectRel)));
     }
@@ -202,7 +202,7 @@ public class PinotAggregateExchangeNodeInsertRule {
         return;
       }
 
-      PinotLogicalAggregate newAggRel = createPlan(call, aggRel, true, 
hintOptions, collations, limit);
+      RelNode newAggRel = createPlan(call, aggRel, true, hintOptions, 
collations, limit);
       call.transformTo(sortRel.copy(sortRel.getTraitSet(), 
List.of(newAggRel)));
     }
   }
@@ -227,7 +227,7 @@ public class PinotAggregateExchangeNodeInsertRule {
     }
   }
 
-  private static PinotLogicalAggregate createPlan(RelOptRuleCall call, 
Aggregate aggRel, boolean hasGroupBy,
+  private static RelNode createPlan(RelOptRuleCall call, Aggregate aggRel, 
boolean hasGroupBy,
       Map<String, String> hintOptions, @Nullable List<RelFieldCollation> 
collations, int limit) {
     // WITHIN GROUP collation is not supported in leaf stage aggregation.
     RelCollation withinGroupCollation = extractWithinGroupCollation(aggRel);
@@ -261,12 +261,28 @@ public class PinotAggregateExchangeNodeInsertRule {
    * Use this group by optimization to skip leaf stage aggregation when 
aggregating at leaf level is not desired. Many
    * situation could be wasted effort to do group-by on leaf, eg: when 
cardinality of group by column is very high.
    */
-  private static PinotLogicalAggregate 
createPlanWithExchangeDirectAggregation(RelOptRuleCall call, Aggregate aggRel,
+  private static RelNode 
createPlanWithExchangeDirectAggregation(RelOptRuleCall call, Aggregate aggRel,
       @Nullable RelCollation withinGroupCollation, @Nullable 
List<RelFieldCollation> collations, int limit) {
     RelNode input = aggRel.getInput();
+    // A layout-restoring Project that RelBuilder may add over the rebuilt 
aggregate (see below); when
+    // present it is re-applied over the direct aggregate so the aggregate's 
output columns are
+    // unchanged for the parent.
+    Project topProject = null;
     // Create Project when there's none below the aggregate.
     if (!(PinotRuleUtils.unboxRel(input) instanceof Project)) {
-      aggRel = (Aggregate) generateProjectUnderAggregate(call, aggRel);
+      RelNode rewritten = 
PinotRuleUtils.unboxRel(generateProjectUnderAggregate(call, aggRel));
+      if (rewritten instanceof Project) {
+        // generateProjectUnderAggregate rebuilds the aggregate with 
RelBuilder, which deduplicates
+        // identical aggregate calls and, when it does, adds a top Project to 
restore the original
+        // output layout (a column referenced more than once — e.g. a SUM 
reused by an AVG reduction —
+        // is projected twice). Keep that Project; the aggregate is its input, 
and we re-apply it over
+        // the direct aggregate below. Without this, casting the Project to 
Aggregate threw
+        // "LogicalProject cannot be cast to Aggregate".
+        topProject = (Project) rewritten;
+        aggRel = (Aggregate) PinotRuleUtils.unboxRel(topProject.getInput());
+      } else {
+        aggRel = (Aggregate) rewritten;
+      }
       input = aggRel.getInput();
     }
 
@@ -280,8 +296,16 @@ public class PinotAggregateExchangeNodeInsertRule {
       exchange = PinotLogicalExchange.create(input, distribution);
     }
 
-    return new PinotLogicalAggregate(aggRel, exchange, buildAggCalls(aggRel, 
AggType.DIRECT, false), AggType.DIRECT,
-        false, collations, limit);
+    PinotLogicalAggregate directAggregate = new PinotLogicalAggregate(aggRel, 
exchange,
+        buildAggCalls(aggRel, AggType.DIRECT, false), AggType.DIRECT, false, 
collations, limit);
+    if (topProject == null) {
+      return directAggregate;
+    }
+    // Re-apply the layout-restoring Project over the direct aggregate (same 
projects and row type,
+    // new input). The direct aggregate keeps the rebuilt aggregate's output 
columns, so the Project's
+    // references remain valid.
+    return topProject.copy(topProject.getTraitSet(), directAggregate, 
topProject.getProjects(),
+        topProject.getRowType());
   }
 
   /**
diff --git 
a/pinot-query-planner/src/test/java/org/apache/pinot/calcite/rel/rules/PinotAggregateExchangeNodeInsertRuleTest.java
 
b/pinot-query-planner/src/test/java/org/apache/pinot/calcite/rel/rules/PinotAggregateExchangeNodeInsertRuleTest.java
new file mode 100644
index 00000000000..9f3dc888fac
--- /dev/null
+++ 
b/pinot-query-planner/src/test/java/org/apache/pinot/calcite/rel/rules/PinotAggregateExchangeNodeInsertRuleTest.java
@@ -0,0 +1,121 @@
+/**
+ * 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 com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.calcite.plan.RelOptCluster;
+import org.apache.calcite.plan.RelTraitSet;
+import org.apache.calcite.plan.hep.HepPlanner;
+import org.apache.calcite.plan.hep.HepProgramBuilder;
+import org.apache.calcite.plan.hep.HepRelVertex;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.AggregateCall;
+import org.apache.calcite.rel.hint.HintPredicates;
+import org.apache.calcite.rel.hint.HintStrategyTable;
+import org.apache.calcite.rel.hint.RelHint;
+import org.apache.calcite.rel.logical.LogicalAggregate;
+import org.apache.calcite.rel.logical.LogicalProject;
+import org.apache.calcite.rel.logical.LogicalValues;
+import org.apache.calcite.rel.metadata.DefaultRelMetadataProvider;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rex.RexBuilder;
+import org.apache.calcite.sql.fun.SqlStdOperatorTable;
+import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.calcite.util.ImmutableBitSet;
+import org.apache.pinot.calcite.rel.hint.PinotHintOptions;
+import org.apache.pinot.calcite.rel.logical.PinotLogicalAggregate;
+import org.apache.pinot.query.type.TypeFactory;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+
+/// Unit tests for {@link PinotAggregateExchangeNodeInsertRule}, focused on 
the skip-leaf-stage
+/// direct-aggregation path.
+public class PinotAggregateExchangeNodeInsertRuleTest {
+  private static final TypeFactory TYPE_FACTORY = TypeFactory.INSTANCE;
+  private static final RexBuilder REX_BUILDER = RexBuilder.DEFAULT;
+
+  private static AggregateCall count(String name) {
+    return AggregateCall.create(SqlStdOperatorTable.COUNT, false, List.of(1), 
-1,
+        TYPE_FACTORY.createSqlType(SqlTypeName.BIGINT), name);
+  }
+
+  /// Builds `Aggregate[hint=is_skip_leaf_stage_group_by]( <count()>*n ) over 
a two-column Values`,
+  /// runs the WithoutSort rule, and returns the (unwrapped) transformed root.
+  private static RelNode runSkipLeaf(int numCountCalls) {
+    HepProgramBuilder program = new HepProgramBuilder();
+    
program.addRuleInstance(PinotAggregateExchangeNodeInsertRule.WithoutSort.INSTANCE);
+    HepPlanner planner = new HepPlanner(program.build());
+    RelOptCluster cluster = RelOptCluster.create(planner, REX_BUILDER);
+    cluster.setMetadataProvider(DefaultRelMetadataProvider.INSTANCE);
+    cluster.setHintStrategies(HintStrategyTable.builder()
+        .hintStrategy(PinotHintOptions.AGGREGATE_HINT_OPTIONS, 
HintPredicates.AGGREGATE)
+        .build());
+    RelDataType rowType = TYPE_FACTORY.builder()
+        .add("g", SqlTypeName.INTEGER)
+        .add("v", SqlTypeName.INTEGER)
+        .build();
+    LogicalValues input = new LogicalValues(cluster, 
RelTraitSet.createEmpty(), rowType, ImmutableList.of());
+    List<AggregateCall> aggCalls = new ArrayList<>();
+    for (int i = 0; i < numCountCalls; i++) {
+      aggCalls.add(count("c" + i));
+    }
+    LogicalAggregate aggregate = (LogicalAggregate) LogicalAggregate.create(
+            input, ImmutableBitSet.of(0), List.of(ImmutableBitSet.of(0)), 
aggCalls)
+        
.withHints(List.of(RelHint.builder(PinotHintOptions.AGGREGATE_HINT_OPTIONS)
+            
.hintOption(PinotHintOptions.AggregateOptions.IS_SKIP_LEAF_STAGE_GROUP_BY, 
"true")
+            .build()));
+    planner.setRoot(aggregate);
+    return unwrap(planner.findBestExp());
+  }
+
+  private static RelNode unwrap(RelNode node) {
+    return node instanceof HepRelVertex ? ((HepRelVertex) 
node).getCurrentRel() : node;
+  }
+
+  /// Regression: two identical `COUNT` calls make 
`generateProjectUnderAggregate` deduplicate them
+  /// and return a `Project`-over-`Aggregate` (the top `Project` re-references 
the deduplicated column
+  /// to restore the original layout). The rule used to cast that result to 
`Aggregate` and throw
+  /// `LogicalProject cannot be cast to Aggregate`. It must now preserve the 
`Project` over a direct
+  /// aggregate.
+  @Test
+  public void skipLeafWithDuplicateAggregateCallsPreservesLayoutProject() {
+    RelNode result = runSkipLeaf(2);
+    assertTrue(result instanceof LogicalProject,
+        "expected a layout-restoring Project at the root, got " + 
result.getClass().getSimpleName());
+    RelNode child = unwrap(result.getInput(0));
+    assertTrue(child instanceof PinotLogicalAggregate,
+        "expected a PinotLogicalAggregate under the Project, got " + 
child.getClass().getSimpleName());
+    // group key + the two (deduplicated then re-expanded) count columns.
+    assertEquals(result.getRowType().getFieldCount(), 3);
+  }
+
+  /// The common case (no duplicate agg calls) still produces a bare direct 
aggregate — no extra
+  /// Project.
+  @Test
+  public void skipLeafWithoutDuplicatesProducesDirectAggregate() {
+    RelNode result = runSkipLeaf(1);
+    assertTrue(result instanceof PinotLogicalAggregate,
+        "expected a PinotLogicalAggregate, got " + 
result.getClass().getSimpleName());
+  }
+}


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

Reply via email to