cloud-fan commented on code in PR #57162:
URL: https://github.com/apache/spark/pull/57162#discussion_r3616882975


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/joins/StreamedSideJoinCondition.scala:
##########
@@ -0,0 +1,58 @@
+/*
+ * 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.execution.joins
+
+import org.apache.spark.sql.catalyst.expressions.{And, Expression, 
PredicateHelper}
+import org.apache.spark.sql.catalyst.plans.{ExistenceJoin, JoinType, LeftAnti, 
LeftOuter, RightOuter}
+import org.apache.spark.sql.execution.SparkPlan
+
+/**
+ * Helper for splitting a join condition into a streamed-side-only part and 
the remaining part,
+ * for join types that preserve all streamed rows.
+ *
+ * The split is valid because for these join types, if a streamed-only 
predicate S is FALSE/NULL
+ * for a streamed row, the full join condition S AND other(S, B) is FALSE/NULL 
for ANY buffered
+ * row B. The streamed row outcome is therefore already determined before any 
probe: it is
+ * emitted for outer/existence joins, and emitted for left anti joins when no 
match exists.
+ */
+private[joins] object StreamedSideJoinCondition extends PredicateHelper {
+
+  /**
+   * Splits `condition` into conjuncts that reference only the streamed side 
and the remaining
+   * conjuncts, when `splitEnabled` is true and the join type preserves all 
streamed rows.
+   * The streamed-only part can be evaluated once per streamed row before 
probing/walking the
+   * buffered matches. Returns `(None, condition)` unchanged otherwise.
+   */
+  def split(
+      condition: Option[Expression],
+      joinType: JoinType,
+      streamedPlan: SparkPlan,
+      splitEnabled: Boolean): (Option[Expression], Option[Expression]) = {
+    val supported = joinType match {
+      case LeftAnti | LeftOuter | RightOuter | _: ExistenceJoin => true
+      case _ => false
+    }
+    if (condition.isDefined && splitEnabled && supported) {
+      val conjuncts = splitConjunctivePredicates(condition.get)
+      val (streamedOnly, rest) = 
conjuncts.partition(_.references.subsetOf(streamedPlan.outputSet))

Review Comment:
   Please restrict the hoisted set to non-throwable conjuncts. With this 
reference-only partition, `raise_error(left_col)` runs before probing even when 
the key has no buffered match; previously that residual was never evaluated for 
the row, so enabling the config changes an outer/anti result into an exception. 
`PushPredicateThroughJoin` handles the analogous relocation with 
`cond.deterministic && !cond.throwable`.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/joins/ExistenceJoinSuite.scala:
##########
@@ -401,4 +459,242 @@ class ExistenceJoinSuite extends SharedSparkSession {
     Some(And(EqualTo(left.col("a").expr, rightUniqueKey.col("c").expr),
       LessThan(left.col("b").expr, rightUniqueKey.col("d").expr))),
     Seq(Row(1, 2.0), Row(1, 2.0), Row(3, 3.0), Row(null, null), Row(null, 
5.0), Row(6, null)))
+
+  // ---- Tests for streamed-side-only residual predicate hoisting ----
+
+  // LeftAnti: rows where b >= 3.0 OR no right match with d < 4.0
+  // (1, 2.0): no c=1 match -> emitted
+  // (2, 1.0): match exists -> dropped
+  // (3, 3.0): b=3.0 >= 3.0 -> emitted
+  // (null, null): null key -> emitted
+  // (null, 5.0): b=5.0 >= 3.0 -> emitted
+  // (6, null): b=null -> emitted
+  testExistenceJoin(
+    "test mixed residual condition for left anti join",
+    LeftAnti,
+    left,
+    right,
+    Some(mixedResidualCondition),
+    Seq(Row(1, 2.0), Row(1, 2.0), Row(3, 3.0), Row(null, null), Row(null, 
5.0), Row(6, null)))
+
+  // LeftOuter: rows where b < 3.0 and right match with d < 4.0 get matched; 
others emitted as
+  // null-padded. Equi-matches: (2,1.0)-(2,3.0), (3,3.0)-(3,2.0), 
(6,null)-(6,null).
+  // For (2,1.0): b<3.0 true, right d=3.0<4.0 true -> matched output 
(2,1.0,2,3.0).
+  //   Both sides contain two rows with the matching key, so the Cartesian 
product emits 4 rows.
+  // For (3,3.0): b<3.0 false -> emitted as (3,3.0,null,null)
+  // For (6,null): b<3.0 null -> emitted as (6,null,null,null)
+  // For (1,2.0): no equi-match -> emitted as (1,2.0,null,null)
+  // Null keys are emitted as null-padded.
+  testExistenceJoin(
+    "test mixed residual condition for left outer join",
+    LeftOuter,
+    left,
+    right,
+    Some(mixedResidualCondition),
+    Seq(
+      Row(1, 2.0, null, null),
+      Row(1, 2.0, null, null),
+      Row(2, 1.0, 2, 3.0),
+      Row(2, 1.0, 2, 3.0),
+      Row(2, 1.0, 2, 3.0),
+      Row(2, 1.0, 2, 3.0),
+      Row(3, 3.0, null, null),
+      Row(null, null, null, null),
+      Row(null, 5.0, null, null),
+      Row(6, null, null, null)))
+
+  // ExistenceJoin with the same left-only residual condition: exists=true 
only for (2,1.0).

Review Comment:
   This case passes `mixedResidualCondition`, which also includes `d < 4`, so 
the comment should call it the mixed residual condition.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/joins/SplitStreamedSideJoinConditionSuite.scala:
##########
@@ -0,0 +1,185 @@
+/*
+ * 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.execution.joins
+
+import org.apache.spark.sql.{DataFrame, QueryTest}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+
+/**
+ * Tests for streamed-side join condition hoisting in whole-stage codegen. The
+ * generated-code shape under test requires all of the following:
+ *  - spark.sql.join.splitStreamedSideJoinCondition = true
+ *  - a join type that preserves streamed rows (LeftOuter, RightOuter, 
LeftAnti, ExistenceJoin)
+ *  - a broadcast hash join, so the scan and the join share one whole-stage. 
Sort-merge and
+ *    shuffled hash joins are out of scope here: their streamed side crosses a 
Sort or an
+ *    exchange, which advances its cursor before running the inlined consume 
code, and the
+ *    sort-merge guard is folded into the match condition rather than emitted 
as an early

Review Comment:
   The sort-merge guard is a standalone pre-loop guard that emits and 
`continue`s before the match loop; it is not folded into the match condition. 
The relevant contrast is that it continues its own loop instead of returning 
from an inlined consumer.



-- 
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