dongjoon-hyun commented on code in PR #59143:
URL: https://github.com/apache/spark/pull/59143#discussion_r4328857799


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2252,7 +2252,7 @@ object InferFiltersFromConstraints extends 
Rule[LogicalPlan]
       val newFilters = filter.constraints --
         (child.constraints ++ splitConjunctivePredicates(condition))
       if (newFilters.nonEmpty) {
-        Filter(And(newFilters.reduce(And), condition), child)
+        Filter(And(buildBalancedPredicate(newFilters.toSeq, And), condition), 
child)

Review Comment:
   **Filters inferred here on a file scan are folded left-deep again later in 
the same optimizer run.** They usually end up in the `Filter` right above a 
scan. The "Early Filter and Projection Push-Down" batch (L252), which runs 
after `operatorOptimizationBatch` (L244), splits that `Filter` and folds all of 
its conjuncts back into a left-deep `And`: `filtersPushDown.reduceOption(And)` 
in SchemaPruning.scala:45, `postScanFilters.reduceLeftOption(And)` in 
V2ScanRelationPushDown.scala:163 (and :1001), `filters.reduceLeft(And)` in 
PruneFileSourcePartitions.scala:42, and the same pattern in 
PruneHiveTablePartitions.scala:117 and PushVariantIntoScan.scala:424.
   
   For the default V1 Parquet source, `SchemaPruning` does this for every 
`HadoopFsRelation` scan with filters and walks the chain recursively in 
`normalizeAttributeRefNames` (SchemaPruning.scala:69 and :163) and 
`identifyRootFields` (:70). The `canPruneDataSchema` check, which reads 
`nestedSchemaPruningEnabled`, only comes after that (:76), and `.getOrElse(op)` 
(:51) means the walk happens even when nothing is pruned. So a wide `WHERE`, or 
the large inferred constraint sets that motivated this PR, on a Parquet table 
still produces an n-deep tree that is walked inside `Optimizer.execute`, and a 
DSv2 scan keeps a left-deep `Filter` in the optimized plan. I traced this by 
reading the code and did not run it.
   
   Could you convert these early scan push-down folds too (`SchemaPruning` and 
`PruneFileSourcePartitions` would need `with PredicateHelper`), or, if they are 
out of scope, say so in the description and file a follow-up JIRA?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/PushdownPredicatesAndPruneColumnsForCTEDef.scala:
##########
@@ -147,10 +147,10 @@ object PushdownPredicatesAndPruneColumnsForCTEDef extends 
Rule[LogicalPlan] with
             // used as-is: re-pushing the combined predicate is redundant but 
always
             // semantics-preserving, since the disjunction of the reference 
predicates is
             // valid for every row of the CTE definition.
-            removePushedDownFilter(child, prevPreds.reduce(Or))
+            removePushedDownFilter(child, buildBalancedPredicate(prevPreds, 
Or))
           case _ => child
         }
-        val newCombinedPred = newPreds.reduce(Or)
+        val newCombinedPred = buildBalancedPredicate(newPreds, Or)

Review Comment:
   **`BooleanSimplification`'s common-factor branches still fold unbounded 
lists left-deep.** The description says `buildBalancedPredicate` "is used the 
same way in `BooleanSimplification`", but only its de-duplication branches use 
it (expressions.scala:500 and :538). The common-factor branches still use 
`reduce`: `common.reduce(Or)` and `(common :+ And(ldiff.reduce(Or), 
rdiff.reduce(Or))).reduce(Or)` at expressions.scala:485 and :489, and the `And` 
mirror at :523 and :527. They run in the same fixed-point batch as the rules 
converted here.
   
   For example, `WHERE (k > 0 AND a1 > 0 AND ... AND a3000 > 0) OR (k > 0 AND 
b1 > 0 AND ... AND b3000 > 0)` is parsed balanced, but expressions.scala:527 
turns it into `And(k > 0, Or(<3000-deep chain>, <3000-deep chain>))`. The 
converted rules only rebuild top-level conjunct lists, so the nested chains 
survive into the optimized plan. The `Or` built here can take the same path 
when the references of a non-inlined CTE share conjuncts. I traced this by 
reading the code and did not run it.
   
   Could you build these four folds with `buildBalancedPredicate` as well, as 
expressions.scala:500 and :538 already do?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2303,7 +2303,7 @@ object InferFiltersFromConstraints extends 
Rule[LogicalPlan]
     if (newPredicates.isEmpty) {
       plan
     } else {
-      Filter(newPredicates.reduce(And), plan)
+      Filter(buildBalancedPredicate(newPredicates.toSeq, And), plan)

Review Comment:
   Minor: `inferNewFilter` balances the conjunction here, but one inferred 
constraint can itself be an `Or` as deep as the number of union branches. 
`Union.validConstraints` (basicLogicalOperators.scala:577-581) merges the 
children's constraints with `.reduce(merge(_, _))`, and `merge` builds 
`Or(othera(attr).reduceLeft(And), otherb(attr).reduceLeft(And))` (:572), so 
each branch adds one level. For `(SELECT k FROM t WHERE k = 1 UNION ALL ... 
UNION ALL SELECT k FROM t WHERE k = 3000) u JOIN d ON u.k = d.k`, the union's 
constraint is a 3000-deep left-deep `Or`. `inferAdditionalConstraints` rewrites 
it with a recursive `transform` (QueryPlanConstraints.scala:134), and if that 
survives, this line installs the rewritten `Or` on `d` as a single leaf, which 
the balanced `And` cannot flatten. I traced this by reading the code and did 
not run it. Could you balance the folds in `Union.merge` as well, or list it as 
a known remaining site?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -3401,13 +3414,14 @@ object ReplaceIntersectWithSemiJoin extends 
Rule[LogicalPlan] {
  * 2. This rule has to be done after de-duplicating the attributes; otherwise, 
the generated
  *    join conditions will be incorrect.
  */
-object ReplaceExceptWithAntiJoin extends Rule[LogicalPlan] {
+object ReplaceExceptWithAntiJoin extends Rule[LogicalPlan] with 
PredicateHelper {
   def apply(plan: LogicalPlan): LogicalPlan = plan.transformWithPruning(
     _.containsPattern(EXCEPT), ruleId) {
     case Except(left, right, false) =>
       assert(left.output.size == right.output.size)
       val joinCond = left.output.zip(right.output).map { case (l, r) => 
EqualNullSafe(l, r) }
-      Distinct(Join(left, right, LeftAnti, joinCond.reduceLeftOption(And), 
JoinHint.NONE))
+      Distinct(
+        Join(left, right, LeftAnti, buildBalancedPredicateOption(joinCond, 
And), JoinHint.NONE))

Review Comment:
   Minor: This balances the one-`EqualNullSafe`-per-column condition of 
`EXCEPT`, but its analyzer counterpart is unchanged. 
NaturalAndUsingJoinResolution.scala:73 builds `(condition ++ 
joinPairs.map(EqualTo.tupled)).reduceOption(And)`, one `EqualTo` per USING 
column, for `JOIN ... USING`, `NATURAL JOIN` and `df1.join(df2, usingColumns, 
joinType)`. So `df1.join(df2, df1.columns.toSeq, "left_anti")` on a wide 
schema, a common way to diff two DataFrames, gets an N-deep left-deep condition 
from analysis on. `CheckAnalysis` walks it, and it is rebalanced only when 
`PushPredicateThroughJoin` first reaches the join. For `FULL OUTER JOIN ... 
USING`, `canPushThrough` excludes `FullOuter`, so the condition stays left-deep 
through the whole optimizer. Could you convert it too (the object would need 
`with PredicateHelper`), or mention it as a follow-up?



##########
sql/core/src/test/resources/tpcds-plan-stability/approved-plans-v1_4/q58/explain.txt:
##########
@@ -199,7 +199,7 @@ Arguments: HashedRelationBroadcastMode(List(input[0, 
string, true]),false), [pla
 Left keys [1]: [item_id#11]
 Right keys [1]: [item_id#22]
 Join type: Inner
-Join condition: ((((cast(ss_item_rev#12 as decimal(19,3)) >= (0.9 * 
cs_item_rev#23)) AND (cast(ss_item_rev#12 as decimal(20,3)) <= (1.1 * 
cs_item_rev#23))) AND (cast(cs_item_rev#23 as decimal(19,3)) >= (0.9 * 
ss_item_rev#12))) AND (cast(cs_item_rev#23 as decimal(20,3)) <= (1.1 * 
ss_item_rev#12)))
+Join condition: (((cast(ss_item_rev#12 as decimal(19,3)) >= (0.9 * 
cs_item_rev#23)) AND (cast(ss_item_rev#12 as decimal(20,3)) <= (1.1 * 
cs_item_rev#23))) AND ((cast(cs_item_rev#23 as decimal(19,3)) >= (0.9 * 
ss_item_rev#12)) AND (cast(cs_item_rev#23 as decimal(20,3)) <= (1.1 * 
ss_item_rev#12))))

Review Comment:
   Minor: Only join conditions planned through `ExtractEquiJoinKeys` change 
shape in these goldens. `Filter` conditions over scans are still left-deep, 
e.g. `approved-plans-v1_4/q19/explain.txt` L55, `Condition : 
((((isnotnull(d_moy#3) AND isnotnull(d_year#2)) AND (d_moy#3 = 11)) AND 
(d_year#2 = 1998)) AND isnotnull(d_date_sk#1))`, and q64/explain.txt L656. 
Physical planning folds every after-scan filter again: 
`afterScanFilters.toSeq.reduceOption(expressions.And)` in 
FileSourceStrategy.scala:362, and the same pattern in 
DataSourceStrategy.scala:489, DataSourceV2Strategy.scala:1041 and 
SparkPlanner.scala:100. So for `spark.read.parquet(path).where(<wide AND>)`, 
the condition the optimizer now keeps balanced becomes n-deep again in 
`FilterExec`, whose constructor splits it recursively 
(basicPhysicalOperators.scala:250), and AQE canonicalization and the explain 
string walk it too. The `StackOverflowError` would move from optimization to 
planning. I traced this by reading the code and d
 id not run it. Could you convert these planner sites, or state in the 
description that the PR covers the optimizer only and track them in a follow-up?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/predicates.scala:
##########
@@ -185,6 +185,16 @@ trait PredicateHelper extends AliasHelper with Logging {
     currentResult.head
   }
 
+  /**
+   * Option-returning form of [[buildBalancedPredicate]]: `None` for an empty 
input, otherwise the

Review Comment:
   Minor: The description keeps the plan-canonicalization `reduce` sites "to 
preserve their canonical form", but that does not apply to 
`QueryPlan.normalizePredicates` (QueryPlan.scala:871). There 
`predicates.reduce(And)` is only an intermediate value: `normalizeExpressions` 
ends with `.canonicalized`, which gathers the operands, sorts them by hash code 
and builds a flat `MultiCommutativeOp` (for 3 or more operands by default), and 
the result is split again. Both sides of a comparison go through this same 
function, so any deterministic fold gives a valid canonical form. Meanwhile, 
the left-deep fold goes through the recursive `transformUp` in 
`normalizeExpressions` (QueryPlan.scala:839) each time a `FileSourceScanExec` 
is canonicalized, e.g. for exchange reuse or AQE, since its `doCanonicalize` 
passes all `dataFilters` (DataSourceScanExec.scala:971). Since `object 
QueryPlan extends PredicateHelper`, could you use 
`buildBalancedPredicate(predicates, And)` there?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/subquery.scala:
##########
@@ -999,7 +1000,7 @@ object RewriteCorrelatedScalarSubquery extends 
Rule[LogicalPlan] with AliasHelpe
                 currentChild.output :+ caseExpr,
                 Join(currentChild,
                   Project(subqueryRoot.output :+ alwaysTrueExpr, subqueryRoot),
-                  joinType, conditions.reduceOption(And), joinHint))
+                  joinType, buildBalancedPredicateOption(conditions, And), 
joinHint))

Review Comment:
   Minor: The description says `RewriteLateralSubquery` is left as `reduce` 
because of its "at-most-two-way condition", but `(condition ++ 
joinCond).reduceOption(And)` at L1096 folds `joinCond`, which has one entry per 
correlated predicate. `PullupCorrelatedPredicates` fills it from 
`DecorrelateInnerQuery` (L664-666, and DecorrelateInnerQuery.scala:629 and 
:643), the same kind of list as the `conditions` converted here in 
`constructLeftJoins`. For example, `SELECT * FROM t1 JOIN LATERAL (SELECT * 
FROM t2 WHERE t2.k1 = t1.k1 AND ... AND t2.kN = t1.kN) ON t1.x > 0` gets an 
N-deep chain. The impact is small, since `PushPredicateThroughJoin` rebalances 
lateral join conditions (Inner, LeftOuter or Cross) on the next iteration. 
Could you either convert L1096 (the rule would need `with PredicateHelper`) or 
correct the description?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/joins.scala:
##########
@@ -270,11 +270,11 @@ object ExtractPythonUDFFromJoinCondition extends 
Rule[LogicalPlan] with Predicat
           log" it will be moved out and the join plan will be turned to cross 
join.")
         None
       } else {
-        Some(rest.reduceLeft(And))
+        Some(buildBalancedPredicate(rest, And))

Review Comment:
   Nit: `PlanExternalUDFs` documents that its "Join-condition handling mirrors 
`ExtractPythonUDFFromJoinCondition`" (PlanExternalUDFs.scala:35) and already 
mixes in `PredicateHelper`, but its copies of L273 and L277 still use 
`otherConditions.reduceLeft(And)` and `udfConditions.reduceLeft(And)` 
(PlanExternalUDFs.scala:90 and :92). Both rules run in the same "Extract UDFs" 
batch, so the two now produce different shapes. The external UDF path is behind 
the experimental `spark.sql.execution.udf.unified.execution.enabled`, so the 
impact is low. Could you convert those two lines as well, to keep the mirror in 
sync?



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/FilterPushdownSuite.scala:
##########
@@ -2082,4 +2082,21 @@ class FilterPushdownSuite extends PlanTest {
     val optimizedC = Optimize.execute(queryC)
     comparePlans(optimizedC, queryC, checkAnalysis = false)
   }
+
+  test("SPARK-59871: predicates pushed through a join form a balanced tree") {

Review Comment:
   Nit: This is about the PR description, not this line. Its generative AI 
section says "Yes - Claude Code Opus 5.5", while the template asks to "include 
the phrase: 'Generated-by: ' followed by the name of the tool and its version". 
`dev/merge_spark_pr.py` copies the description into the commit message, so the 
merged commit would have no `Generated-by:` line. Your other recent commits use 
it, e.g. c79c1a3f8b9 (`Generated-by: Claude Code (claude-opus-5-5)`). Could you 
change it to `Generated-by: Claude Code (Opus 5.5)`?



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/PredicateHelperSuite.scala:
##########
@@ -0,0 +1,45 @@
+/*
+ * 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.spark.sql.catalyst.expressions
+
+import org.apache.spark.SparkFunSuite
+import org.apache.spark.sql.types.BooleanType
+
+class PredicateHelperSuite extends SparkFunSuite with PredicateHelper {
+
+  test("buildBalancedPredicate builds a balanced (log-depth) tree") {
+    val a = AttributeReference("a", BooleanType)()
+    val b = AttributeReference("b", BooleanType)()
+    val c = AttributeReference("c", BooleanType)()
+    val d = AttributeReference("d", BooleanType)()
+    assert(buildBalancedPredicate(Seq(a), And) == a)
+    assert(buildBalancedPredicate(Seq(a, b), And) == And(a, b))
+    assert(buildBalancedPredicate(Seq(a, b, c, d), And) == And(And(a, b), 
And(c, d)))
+    assert(buildBalancedPredicate(Seq(a, b, c, d), Or) == Or(Or(a, b), Or(c, 
d)))
+  }
+
+  test("buildBalancedPredicateOption returns None for empty input, balanced 
tree otherwise") {
+    val a = AttributeReference("a", BooleanType)()

Review Comment:
   Nit: These four attributes repeat L26-29. Could you make them class-level 
`private val`s, as `ExtractPredicatesWithinOutputSetSuite` does? An n = 3 
assertion (`And(And(a, b), c)`) would also pin the description's statement that 
two or three operands keep the old left-deep shape.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/predicates.scala:
##########
@@ -185,6 +185,16 @@ trait PredicateHelper extends AliasHelper with Logging {
     currentResult.head
   }
 
+  /**
+   * Option-returning form of [[buildBalancedPredicate]]: `None` for an empty 
input, otherwise the
+   * combined predicate.
+   */
+  protected def buildBalancedPredicateOption(

Review Comment:
   Minor: The remaining sites in my other comments show how easy it is to miss 
one, and nothing prevents new linear folds. At 774871c, a search for `reduce`, 
`reduceLeft`, `reduceOption` or `reduceLeftOption` over `And`/`Or` still finds 
55 lines of main code (25 in catalyst, 27 in core, 3 in hive). Master has also 
gained one since this branch's base: `boundExpressions.reduce(And)` in 
`ParquetStorageFilter.create` (SPARK-59620), which is harmless because its 
input is bounded. Also, this helper is a `protected` member of a trait, so 
`DataSourceV2Strategy`, `SparkPlanner`, `SchemaPruning`, 
`PruneFileSourcePartitions` and `NaturalAndUsingJoinResolution` would have to 
change their supertypes to call it. As a follow-up, could you consider a public 
helper that these trait methods delegate to (catalyst is excluded from MiMa), 
plus a scalastyle `RegexChecker` rule against linear `And`/`Or` folds, with `// 
scalastyle:off` at the deliberate sites?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/subquery.scala:
##########
@@ -444,15 +444,15 @@ object RewritePredicateSubquery extends Rule[LogicalPlan] 
with PredicateHelper {
           // Deduplicate conflicting attributes if any.
           val newSub = dedupSubqueryOnSelfJoin(newPlan, sub, Some(values))
           val inConditions = values.zip(newSub.output).map(EqualTo.tupled)
-          val newConditions = (inConditions ++ 
conditions).reduceLeftOption(And)
+          val newConditions = buildBalancedPredicateOption(inConditions ++ 
conditions, And)
           val joinHint = JoinHint(None, subHint)
           newPlan = Join(newPlan, rewriteDomainJoinsIfPresent(newPlan, newSub, 
newConditions),
             ExistenceJoin(exists), newConditions, joinHint)
           introducedAttrs += exists
           exists
       }
     }
-    (newExprs.reduceOption(And), newPlan, introducedAttrs.toSeq)
+    (buildBalancedPredicateOption(newExprs.toSeq, And), newPlan, 
introducedAttrs.toSeq)

Review Comment:
   Nit: `newExprs` is already an immutable `Seq` (`exprs.map { ... }` at L406), 
so `.toSeq` is a no-op here, unlike the `ExpressionSet` conversions at 
Optimizer.scala:2255, :2306 and :2407 and CostBasedJoinReorder.scala:308. Could 
you drop it, i.e. `buildBalancedPredicateOption(newExprs, And)`?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to