This is an automated email from the ASF dual-hosted git repository.
mrhhsg pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new fa6da72465f [fix](fe) Keep Java and Python UDAFs out of bucketed hash
aggregation (#68565)
fa6da72465f is described below
commit fa6da72465fb65771579517808e7bcb87ad7e79f
Author: Jerry Hu <[email protected]>
AuthorDate: Tue Sep 29 20:46:33 2026 +0800
[fix](fe) Keep Java and Python UDAFs out of bucketed hash aggregation
(#68565)
### What problem does this PR solve?
Issue Number: None
Related PR: #61495, #65024
Problem Summary: On a single-BE cluster, bucketed hash aggregation is on
by
default and the translator fuses a one-phase GLOBAL aggregate with its
distribute child into a BucketedAggregationNode. The source side of that
operator merges the live aggregate states built by different sink
instances
directly, instead of serializing them and deserializing them with the
merging
evaluator as the two-phase plan does. Java and Python UDAFs rely on the
latter:
- Java UDAF: the extra evaluator clone used by the bucketed source never
calls
create(), so its _exec_place stays null and merge()/insert_result_into()
dereference a null state. Reproduced locally with a Java UDAF
(`SELECT k, my_udaf(v) FROM t GROUP BY k`): UBSan reports "reference
binding
to null pointer of type AggregateJavaUdafData" in
AggregateJavaUdaf::merge
and the query fails / the BE goes down.
- Python UDAF: merge() builds the rhs state from serialize_data, which
is
only filled on the deserialize path, so the rhs contribution is dropped
or
the Python server RPC fails.
None of the FE gates excluded UDAFs. Add the check to the shared gate
AggregateUtils.isBucketedHashAggEnabled, which now takes the aggregate
and
returns false when any aggregate function is a Udf (JavaUdaf /
PythonUdaf).
The translator, ChildrenPropertiesRegulator, ChildOutputPropertyDeriver
and
CostModel all go through this gate, so the optimizer also stops
preferring
the one-phase plan for these aggregates and they keep the regular
aggregation path.
### Release note
Fix BE crash / wrong result when a Java or Python UDAF is used with
GROUP BY
on a single-BE cluster with bucketed hash aggregation enabled.
### Check List (For Author)
- Test:
- Unit Test: BucketedAggregateTranslatorTest (new Python UDAF case under
agg_phase=0 and agg_phase=1, fails
without the fix), BucketedAggregateTest, ChildOutputPropertyDeriverTest,
ChildrenPropertiesRegulatorTest, CostModelV1Test
- Regression test: query_p0/javaudf/test_javaudaf_bucketed_agg (default
and agg_phase=1 plans; fails on
the old FE with BUCKETED AGGREGATE in the plan and a BE null deref when
executed), plus bucketed_hash_agg and percentile_bucketed_agg_merge
- Behavior changed: Yes (aggregates containing Java/Python UDAFs no
longer
use bucketed hash aggregation)
- Does this need documentation: No
---
.../org/apache/doris/nereids/cost/CostModel.java | 3 +-
.../glue/translator/PhysicalPlanTranslator.java | 4 +-
.../properties/ChildOutputPropertyDeriver.java | 3 +-
.../properties/ChildrenPropertiesRegulator.java | 2 +-
.../apache/doris/nereids/util/AggregateUtils.java | 16 +++-
.../BucketedAggregateTranslatorTest.java | 66 ++++++++++++++--
.../javaudf/test_javaudaf_bucketed_agg.out | 16 ++++
.../javaudf/test_javaudaf_bucketed_agg.groovy | 89 ++++++++++++++++++++++
8 files changed, 182 insertions(+), 17 deletions(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/cost/CostModel.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/cost/CostModel.java
index 4c9d2a0b4ba..aa88b013c9f 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/cost/CostModel.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/cost/CostModel.java
@@ -364,8 +364,7 @@ class CostModel extends PlanVisitor<Cost, PlanContext> {
// gates are enforced by ChildrenPropertiesRegulator), apply a
discount
// to prefer this path over two-phase aggregation.
if (aggregate.getAggMode() == AggMode.INPUT_TO_RESULT
- && AggregateUtils.isBucketedHashAggEnabled(
- aggregate.getGroupByExpressions().size())) {
+ && AggregateUtils.isBucketedHashAggEnabled(aggregate)) {
rowCost *= BUCKETED_AGG_COST_DISCOUNT;
}
return Cost.of(context.getCostWeight(),
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
index 67de840bf92..04d78a69983 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
@@ -3330,8 +3330,8 @@ public class PhysicalPlanTranslator extends
DefaultPlanVisitor<PlanFragment, Pla
*/
private boolean shouldUseBucketedFusion(PhysicalHashAggregate<? extends
Plan> aggregate,
PlanTranslatorContext context) {
- // Shared eligibility: session var, single-BE, GROUP BY, smooth upgrade
- if
(!AggregateUtils.isBucketedHashAggEnabled(aggregate.getGroupByExpressions().size()))
{
+ // Shared eligibility: session var, single-BE, GROUP BY, smooth
upgrade, no UDAF
+ if (!AggregateUtils.isBucketedHashAggEnabled(aggregate)) {
return false;
}
// Must be one-phase: GLOBAL + INPUT_TO_RESULT
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildOutputPropertyDeriver.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildOutputPropertyDeriver.java
index 2df7723a7ab..a1568491a9d 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildOutputPropertyDeriver.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildOutputPropertyDeriver.java
@@ -199,8 +199,7 @@ public class ChildOutputPropertyDeriver extends
PlanVisitor<PhysicalProperties,
// from incorrectly skipping exchanges.
if (agg.getAggPhase().isGlobal()
&& agg.getAggMode() == AggMode.INPUT_TO_RESULT
- && AggregateUtils.isBucketedHashAggEnabled(
- agg.getGroupByExpressions().size())
+ && AggregateUtils.isBucketedHashAggEnabled(agg)
&&
isShuffleCompatible(childOutputProperty.getDistributionSpec())) {
return PhysicalProperties.ANY;
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java
index 9741d1de68a..483fe9cab8a 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java
@@ -164,7 +164,7 @@ public class ChildrenPropertiesRegulator extends
PlanVisitor<List<List<PhysicalP
// pattern so the translator can fuse it into
BucketedAggregationNode.
// Gate with data-volume checks using group-level statistics to
avoid
// generating this pattern when bucketed agg is unsuitable.
- if
(AggregateUtils.isBucketedHashAggEnabled(aggregate.getGroupByExpressions().size()))
{
+ if (AggregateUtils.isBucketedHashAggEnabled(aggregate)) {
return !bucketedDataVolumeGatesPass(aggregate);
}
return true;
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/util/AggregateUtils.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/util/AggregateUtils.java
index b16320beeaa..d10c1805b62 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/util/AggregateUtils.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/util/AggregateUtils.java
@@ -23,6 +23,7 @@ import org.apache.doris.nereids.trees.expressions.Cast;
import org.apache.doris.nereids.trees.expressions.Expression;
import org.apache.doris.nereids.trees.expressions.IsNull;
import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.functions.Udf;
import
org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunction;
import org.apache.doris.nereids.trees.expressions.functions.agg.AggregateParam;
import org.apache.doris.nereids.trees.expressions.functions.agg.Count;
@@ -232,9 +233,10 @@ public class AggregateUtils {
* discount), and PhysicalPlanTranslator (for fusion into
BucketedAggregationNode).
*
* @return true if the session variable is enabled, there is exactly one
alive BE,
- * no smooth upgrade is in progress, and the aggregate has GROUP
BY keys.
+ * no smooth upgrade is in progress, the aggregate has GROUP BY
keys and
+ * contains no user-defined aggregate function.
*/
- public static boolean isBucketedHashAggEnabled(int groupByExprCount) {
+ public static boolean isBucketedHashAggEnabled(Aggregate<? extends Plan>
aggregate) {
ConnectContext ctx = ConnectContext.get();
if (ctx == null) {
return false;
@@ -243,7 +245,7 @@ public class AggregateUtils {
return false;
}
// Must have GROUP BY keys (without-key aggregation not supported)
- if (groupByExprCount == 0) {
+ if (aggregate.getGroupByExpressions().isEmpty()) {
return false;
}
// Correctness gate: single-BE only (cross-BE in-memory merge is
impossible).
@@ -265,6 +267,14 @@ public class AggregateUtils {
return false;
}
}
+ // Bucketed agg merges the live states built by different sink
instances
+ // directly, without serializing them. Java / Python UDAFs can only
merge a
+ // state that was deserialized by the merging evaluator (the Java UDAF
+ // executor place and the Python UDAF serialized buffer are only set
up on
+ // that path), so they must stay on the regular aggregation path.
+ if
(aggregate.getAggregateFunctions().stream().anyMatch(Udf.class::isInstance)) {
+ return false;
+ }
return true;
}
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/BucketedAggregateTranslatorTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/BucketedAggregateTranslatorTest.java
index 479d55e380b..14457d2ba05 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/BucketedAggregateTranslatorTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/BucketedAggregateTranslatorTest.java
@@ -41,6 +41,47 @@ public class BucketedAggregateTranslatorTest extends
TestWithFeService {
+ "kint INT NOT NULL, kbint INT NOT NULL, kstr STRING NOT
NULL) "
+ "DISTRIBUTED BY HASH(kint) BUCKETS 4 "
+ "PROPERTIES('replication_num' = '1')");
+ createFunction("CREATE AGGREGATE FUNCTION
bucketed_aggregate_translator_test.py_udaf_sum(INT) "
+ + "RETURNS BIGINT PROPERTIES('type'='PYTHON_UDF',
'symbol'='SumUdaf', "
+ + "'runtime_version'='3.10.2')");
+ }
+
+ @Test
+ public void testPythonUdafIsNotFusedIntoBucketedAggregation() throws
Exception {
+ SessionVariable sessionVariable = connectContext.getSessionVariable();
+ int oldAggPhase = sessionVariable.aggPhase;
+ int oldBeNumberForTest = sessionVariable.getBeNumberForTest();
+ long oldBucketedAggMinInputRows =
sessionVariable.bucketedAggMinInputRows;
+ long oldBucketedAggMaxGroupKeys =
sessionVariable.bucketedAggMaxGroupKeys;
+ double oldBucketedAggHighCardThreshold =
sessionVariable.bucketedAggHighCardThreshold;
+ boolean oldEnableBucketedHashAgg =
sessionVariable.enableBucketedHashAgg;
+ try {
+ sessionVariable.setBeNumberForTest(1);
+ sessionVariable.bucketedAggMinInputRows = 0;
+ sessionVariable.bucketedAggMaxGroupKeys = 0;
+ sessionVariable.bucketedAggHighCardThreshold = 1.0;
+ sessionVariable.enableBucketedHashAgg = true;
+
+ // agg_phase=0 lets the optimizer choose the plan; agg_phase=1
forces the
+ // one-phase plan, so only the translator fusion gate can reject
the UDAF.
+ for (int aggPhase : new int[] {0, 1}) {
+ sessionVariable.aggPhase = aggPhase;
+ // A builtin aggregate on the same shape is fused, so the UDAF
cases below
+ // are rejected because of the UDAF rather than the plan shape.
+
Assertions.assertFalse(collectBucketedAggregationNodes("sum(kint)").isEmpty());
+ assertUsesRegularAggregation(
+
"bucketed_aggregate_translator_test.py_udaf_sum(kint)");
+ assertUsesRegularAggregation(
+ "sum(kint),
bucketed_aggregate_translator_test.py_udaf_sum(kint)");
+ }
+ } finally {
+ sessionVariable.aggPhase = oldAggPhase;
+ sessionVariable.setBeNumberForTest(oldBeNumberForTest);
+ sessionVariable.bucketedAggMinInputRows =
oldBucketedAggMinInputRows;
+ sessionVariable.bucketedAggMaxGroupKeys =
oldBucketedAggMaxGroupKeys;
+ sessionVariable.bucketedAggHighCardThreshold =
oldBucketedAggHighCardThreshold;
+ sessionVariable.enableBucketedHashAgg = oldEnableBucketedHashAgg;
+ }
}
@Test
@@ -78,18 +119,29 @@ public class BucketedAggregateTranslatorTest extends
TestWithFeService {
}
private void assertUsesRegularAggregation(String aggregateFunction) throws
Exception {
- Planner planner = getSQLPlanner("SELECT " + aggregateFunction
+ Planner planner = planAggregate(aggregateFunction);
+ Assertions.assertTrue(collectNodes(planner,
BucketedAggregationNode.class).isEmpty());
+ Assertions.assertFalse(collectNodes(planner,
AggregationNode.class).isEmpty());
+ }
+
+ private List<BucketedAggregationNode>
collectBucketedAggregationNodes(String aggregateFunction)
+ throws Exception {
+ return collectNodes(planAggregate(aggregateFunction),
BucketedAggregationNode.class);
+ }
+
+ private Planner planAggregate(String aggregateFunction) throws Exception {
+ return getSQLPlanner("SELECT " + aggregateFunction
+ " FROM
bucketed_aggregate_translator_test.agg_group_concat_table GROUP BY kbint");
- List<BucketedAggregationNode> bucketedAggregationNodes =
Lists.newArrayList();
- List<AggregationNode> aggregationNodes = Lists.newArrayList();
+ }
+
+ private <T extends PlanNode> List<T> collectNodes(Planner planner,
Class<T> nodeClass) {
+ List<T> nodes = Lists.newArrayList();
for (PlanFragment fragment : planner.getFragments()) {
PlanNode root = fragment.getPlanRoot();
if (root != null) {
- root.collect(BucketedAggregationNode.class,
bucketedAggregationNodes);
- root.collect(AggregationNode.class, aggregationNodes);
+ root.collect(nodeClass, nodes);
}
}
- Assertions.assertTrue(bucketedAggregationNodes.isEmpty());
- Assertions.assertFalse(aggregationNodes.isEmpty());
+ return nodes;
}
}
diff --git
a/regression-test/data/query_p0/javaudf/test_javaudaf_bucketed_agg.out
b/regression-test/data/query_p0/javaudf/test_javaudaf_bucketed_agg.out
new file mode 100644
index 00000000000..025b79b7d76
--- /dev/null
+++ b/regression-test/data/query_p0/javaudf/test_javaudaf_bucketed_agg.out
@@ -0,0 +1,16 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !udaf --
+0 1683
+1 1617
+2 1650
+
+-- !udaf_with_builtin --
+0 1683 1683
+1 1617 1617
+2 1650 1650
+
+-- !udaf_one_phase --
+0 1683
+1 1617
+2 1650
+
diff --git
a/regression-test/suites/query_p0/javaudf/test_javaudaf_bucketed_agg.groovy
b/regression-test/suites/query_p0/javaudf/test_javaudaf_bucketed_agg.groovy
new file mode 100644
index 00000000000..c7f829fabae
--- /dev/null
+++ b/regression-test/suites/query_p0/javaudf/test_javaudaf_bucketed_agg.groovy
@@ -0,0 +1,89 @@
+// 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.
+
+// Bucketed hash aggregation merges the live states of different sink instances
+// without serializing them, which Java UDAFs cannot handle. A Java UDAF must
+// therefore keep the regular aggregation plan even when bucketed agg applies.
+suite("test_javaudaf_bucketed_agg") {
+ def jarPath =
"""${context.file.parent}/../../javaudf_p0/jars/java-udf-case-jar-with-dependencies.jar"""
+ scp_udf_file_to_all_be(jarPath)
+
+ sql "set be_number_for_test=1"
+ sql "set enable_bucketed_hash_agg=true"
+ sql "set bucketed_agg_min_input_rows=0"
+ sql "set bucketed_agg_max_group_keys=0"
+ sql "set bucketed_agg_high_card_threshold=1.0"
+ sql "set parallel_pipeline_task_num=2"
+
+ sql "DROP TABLE IF EXISTS test_javaudaf_bucketed_agg_tbl"
+ sql """
+ CREATE TABLE test_javaudaf_bucketed_agg_tbl (
+ id INT NOT NULL,
+ k INT NOT NULL,
+ v INT NOT NULL
+ )
+ DISTRIBUTED BY HASH(id) BUCKETS 4
+ PROPERTIES("replication_num" = "1")
+ """
+ // The same group key spreads over all tablets, so several sink instances
+ // build a state for it and the source side has to merge them.
+ sql """
+ INSERT INTO test_javaudaf_bucketed_agg_tbl
+ SELECT number, number % 3, number FROM numbers("number" = "100")
+ """
+
+ sql "DROP FUNCTION IF EXISTS test_javaudaf_bucketed_agg_sum(int)"
+ sql """ CREATE AGGREGATE FUNCTION test_javaudaf_bucketed_agg_sum(int)
RETURNS BigInt PROPERTIES (
+ "file"="file://${jarPath}",
+ "symbol"="org.apache.doris.udf.MySumInt",
+ "always_nullable"="false",
+ "type"="JAVA_UDF"
+ ); """
+
+ // A builtin aggregate on the same shape still uses bucketed agg.
+ explain {
+ sql "SELECT k, sum(v) FROM test_javaudaf_bucketed_agg_tbl GROUP BY k"
+ contains("BUCKETED AGGREGATE")
+ }
+ explain {
+ sql "SELECT k, test_javaudaf_bucketed_agg_sum(v) FROM
test_javaudaf_bucketed_agg_tbl GROUP BY k"
+ notContains("BUCKETED AGGREGATE")
+ }
+ explain {
+ sql """SELECT k, sum(v), test_javaudaf_bucketed_agg_sum(v)
+ FROM test_javaudaf_bucketed_agg_tbl GROUP BY k"""
+ notContains("BUCKETED AGGREGATE")
+ }
+
+ order_qt_udaf """
+ SELECT k, test_javaudaf_bucketed_agg_sum(v) FROM
test_javaudaf_bucketed_agg_tbl GROUP BY k
+ """
+ order_qt_udaf_with_builtin """
+ SELECT k, sum(v), test_javaudaf_bucketed_agg_sum(v) FROM
test_javaudaf_bucketed_agg_tbl GROUP BY k
+ """
+
+ // Force the one-phase plan so that only the translator fusion gate can
keep
+ // the Java UDAF away from bucketed agg.
+ sql "set agg_phase=1"
+ explain {
+ sql "SELECT k, test_javaudaf_bucketed_agg_sum(v) FROM
test_javaudaf_bucketed_agg_tbl GROUP BY k"
+ notContains("BUCKETED AGGREGATE")
+ }
+ order_qt_udaf_one_phase """
+ SELECT k, test_javaudaf_bucketed_agg_sum(v) FROM
test_javaudaf_bucketed_agg_tbl GROUP BY k
+ """
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]