spark-test commented on code in PR #60198:
URL: https://github.com/apache/spark/pull/60198#discussion_r4502438282


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -3199,6 +3215,35 @@ object PushPredicateThroughJoin extends 
Rule[LogicalPlan] with PredicateHelper {
     (leftEvaluateCondition, rightEvaluateCondition, commonCondition ++ stayUp)
   }
 
+  /**
+   * [[split]] for an external lookup join, which also returns the conjuncts 
that must not move.
+   * Moving a conjunct onto the lookup side must not turn a query that runs 
without it into one
+   * that fails to plan:
+   *  - A comparison that lookup planning can offer as a lookup candidate, 
such as `t.dept = 'x'`,
+   *    stays with the common conditions, in the join condition, so that the 
connector can still
+   *    select it as an access path.
+   *  - A conjunct over lookup columns that a lookup-side stage cannot 
evaluate, such as one with
+   *    a Python UDF or a subquery, is pinned: it is neither pushed nor moved 
into the condition.
+   *  - A conjunct without references, such as a literal, belongs to neither 
side. It stays with
+   *    the common conditions, so that a condition that folds to false keeps 
its result without a
+   *    lookup, instead of leaving a join whose stream side is empty.
+   *
+   * @return (canEvaluateInLeft, canEvaluateInRight, haveToEvaluateInBoth, 
pinned)
+   */
+  private def splitLookupJoin(condition: Seq[Expression], join: 
ExternalLookupJoin) = {
+    val (pinned, movable) = condition.partition { cond =>
+      cond.references.exists(join.right.outputSet.contains) &&
+        !ExternalLookupJoinShapes.canEvaluateInLookupStage(cond)

Review Comment:
   **Non-blocking (P2):** This check lets through a conjunct that a lookup-side 
stage cannot evaluate. `canEvaluateInLookupStage` accepts `UpdateFields`, which 
`Column.withField` and `Column.dropFields` produce. `UpdateFields` is 
`Unevaluable`, and only `ReplaceUpdateFieldsExpression` lowers it, in the 
optimizer's last batch. By then `PrepareExternalLookupJoin` has folded 
lookup-side filters into `PreparedExternalLookupJoin.lookupTransforms`, which 
no expression rule reaches.
   
   So a lookup-only conjunct that still holds a whole-struct 
`withField`/`dropFields` value now moves onto the lookup side: from the join 
condition for any join type, or from a filter above an inner join. The 
micro-batch then fails with an internal error that `update_fields` cannot be 
evaluated. For example, `stream.join(lookup, stream("id") === lookup("id") && 
lookup("info").dropFields("b") === struct(lit(1).as("a")), "left_outer")` 
returned its rows before this commit, because the conjunct stayed in the 
residual condition, where `ReplaceUpdateFieldsExpression` lowered it. Field 
reads through `withField` are not affected, since `SimplifyExtractValueOps` 
removes them earlier.
   
   Pinning conjuncts that contain `UpdateFields` here would keep them where 
they ran before. Rejecting `UpdateFields` in `canEvaluateInLookupStage`, in the 
stacked PR that adds lookup-side stages, would also cover lookup sides written 
with `withField`.
   
   **Verification:**
   - **Regression:** A plan test in the predicate pushdown suite for external 
lookup joins shows that a conjunct over lookup columns that contains 
UpdateFields stays in the join condition for every join type and stays above an 
inner join. An end-to-end test shows that a lookup join whose condition 
compares a withField or dropFields value of a lookup struct column returns the 
same rows as with PushDownPredicates excluded.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/ExternalLookupJoin.scala:
##########
@@ -0,0 +1,338 @@
+/*
+ * 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.plans.logical
+
+import org.apache.spark.SparkException
+import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeSet, 
BinaryComparison,
+  Cast, EqualNullSafe, EqualTo, Expression, ExprUtils, 
ExternalUserDefinedFunction, GreaterThan,
+  GreaterThanOrEqual, LessThan, LessThanOrEqual, NamedExpression, PythonUDF, 
SubqueryExpression}
+import org.apache.spark.sql.catalyst.plans.{Inner, JoinType, LeftAnti, 
LeftOuter, LeftSemi}
+import org.apache.spark.sql.catalyst.trees.TreePattern.{EXTERNAL_LOOKUP_HINT, 
EXTERNAL_LOOKUP_JOIN,
+  TreePattern}
+import org.apache.spark.sql.catalyst.util.CharVarcharUtils
+import org.apache.spark.sql.connector.read.{ExternalLookupReaderFactory, Scan}
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, 
CharType, DataType,
+  DateType, DecimalType, IntegerType, IntegralType, LongType, ShortType, 
StringType,
+  TimestampNTZType, TimestampType, VarcharType}
+
+/**
+ * The cache settings of one external lookup join, from the
+ * `spark.sql.join.externalLookup.cache.*` configurations unless the join's 
`EXTERNAL_LOOKUP` hint
+ * overrides them. A cache lives within a task, per input partition, so these 
bound each such cache:
+ * `maxEntries` keys, each kept for `ttlMillis` (0 keeps it until evicted). A 
key with more than
+ * `maxRowsPerKey` result rows is still joined but not cached, so that one 
large result cannot
+ * fill the cache. At present, a join that selects a range comparison (`<`, 
`<=`, `>`, `>=`) caches
+ * nothing, whatever these settings say.
+ */
+case class ExternalLookupCacheOptions(
+    enabled: Boolean,
+    maxEntries: Int,
+    ttlMillis: Long,
+    maxRowsPerKey: Int)
+
+/**
+ * Marks the plan below it as the lookup side that the `EXTERNAL_LOOKUP` hint 
asks an external
+ * lookup join to read, with the hint's cache options. The external lookup 
join rules consume it
+ * when they form the join.
+ *
+ * Join strategy hints are [[ResolvedHint]] nodes with a [[JoinStrategyHint]]. 
This hint is not,
+ * because that representation cannot keep two of its guarantees:
+ *  - A strategy hint that conflicts with it must fail the query. [[HintInfo]] 
holds one strategy,
+ *    and `ResolveJoinStrategyHints` merges a strategy hint on an already 
hinted relation into the
+ *    existing [[ResolvedHint]] with `HintInfo.merge`, which keeps one 
strategy and only warns.
+ *  - It selects how the lookup side is read, not a physical join. 
`EliminateResolvedHint` moves
+ *    every [[ResolvedHint]] it can attach into `Join.hint`, for physical join 
selection.
+ */
+case class ExternalLookupHint(
+    child: LogicalPlan,
+    cacheOptions: ExternalLookupCacheOptions) extends UnaryNode {
+  override def output: Seq[Attribute] = child.output
+
+  final override val nodePatterns: Seq[TreePattern] = Seq(EXTERNAL_LOOKUP_HINT)
+
+  override protected def withNewChildInternal(newChild: LogicalPlan): 
ExternalLookupHint =
+    copy(child = newChild)
+}
+
+/**
+ * A lookup join retained through ordinary expression and operator 
optimization.
+ *
+ * @param requiredLookupOutput Lookup-side attributes still required above 
this join. They are
+ *   part of this node's references, so column pruning keeps them on the 
lookup side.
+ *   `ColumnPruning` narrows them to what the parent reads, and 
`PrepareExternalLookupJoin` prunes
+ *   the lookup scan to them.
+ */
+case class ExternalLookupJoin(
+    left: LogicalPlan,
+    right: LogicalPlan,
+    joinType: JoinType,
+    condition: Option[Expression],
+    cacheOptions: ExternalLookupCacheOptions,
+    requiredLookupOutput: Seq[Attribute]) extends BinaryNode {
+
+  final override val nodePatterns: Seq[TreePattern] = Seq(EXTERNAL_LOOKUP_JOIN)
+
+  override def output: Seq[Attribute] = joinType match {
+    case Inner => left.output ++ right.output
+    case LeftOuter => left.output ++ right.output.map(_.withNullability(true))
+    case LeftSemi | LeftAnti => left.output
+    case other => throw SparkException.internalError(s"Unexpected external 
lookup join type $other")
+  }
+
+  override def maxRows: Option[Long] = joinType match {
+    case LeftSemi | LeftAnti => left.maxRows
+    case _ => None
+  }
+
+  override protected def withNewChildrenInternal(
+      newLeft: LogicalPlan,
+      newRight: LogicalPlan): ExternalLookupJoin = copy(left = newLeft, right 
= newRight)
+}
+
+/**
+ * The join-condition and lookup-side shapes that an external lookup join can 
run. Analysis,
+ * lookup planning and the optimizer rules for lookup joins share them.
+ */
+object ExternalLookupJoinShapes {
+  /**
+   * A lookup candidate: a comparison of a lookup column with a value, written 
with the lookup
+   * column on the left. `predicateName` is the name of the DSv2 `Predicate` 
that is offered to the
+   * connector for it: the SQL operator of the comparison, as 
`V2ExpressionBuilder` names it.
+   */
+  case class LookupComparison(
+      lookupField: Attribute,
+      parameter: Expression,
+      predicateName: String,
+      nullSafe: Boolean)
+
+  /**
+   * `conjunct` of the condition of a lookup join as a lookup candidate: `=`, 
`<=>`, `<`, `<=`, `>`
+   * or `>=` between a bare lookup column and a value, in either operand 
order. When the lookup
+   * column is written on the right, the comparison is rebuilt with it on the 
left, so
+   * `s.ts < t.event_time` is offered as `event_time > ?`.
+   *
+   * The value is the other operand, which Spark evaluates for each stream row 
to fill the
+   * comparison's parameter. It may read only stream columns, and it may read 
none, as a literal
+   * does. A value that reads lookup columns, as in `t.a = t.b`, cannot be a 
parameter.
+   *
+   * The lookup column must be of a type on which [[ExternalLookupKeyTypes]] 
lets Spark offer the
+   * comparison, and not a CHAR or VARCHAR column. A comparison on another 
column is an ordinary
+   * conjunct.
+   *
+   * @param streamOutput The output of the join's left side, the stream side.
+   * @param lookupOutput The output of the join's right side, the lookup side.
+   */
+  def lookupComparison(
+      conjunct: Expression,
+      streamOutput: AttributeSet,
+      lookupOutput: AttributeSet): Option[LookupComparison] = {
+    def isLookupComparison(lookupField: Attribute, parameter: Expression): 
Boolean =
+      lookupOutput.contains(lookupField) && 
parameter.references.subsetOf(streamOutput)
+    conjunct match {
+      case comparison: BinaryComparison if isLookupOperator(comparison) =>
+        val nullSafe = comparison.isInstanceOf[EqualNullSafe]
+        Seq(comparison, withOperandsSwapped(comparison)).collectFirst {
+          case candidate @ BinaryComparison(lookupField: Attribute, parameter)
+              if isLookupComparison(lookupField, parameter) &&
+                isOfferedOn(comparison, lookupField, parameter) =>
+            LookupComparison(lookupField, parameter, candidate.sqlOperator, 
nullSafe)
+        }
+      case _ => None
+    }
+  }
+
+  private def isLookupOperator(comparison: BinaryComparison): Boolean = 
comparison match {
+    case _: EqualTo | _: EqualNullSafe | _: LessThan | _: LessThanOrEqual | _: 
GreaterThan |
+        _: GreaterThanOrEqual => true
+    case _ => false
+  }
+
+  /**
+   * Whether Spark offers the comparison on this lookup column, with this 
parameter. Spark offers
+   * no comparison on a CHAR or VARCHAR column, whose data type alone does not 
show it.
+   *
+   * Spark evaluates every parameter on every stream row, before the other 
join conditions. That
+   * is safe for an equality, as a hash join evaluates its keys on every row 
too. A range is a
+   * join condition, which a join evaluates only after its keys match, so its 
parameter must be
+   * one that cannot fail, as `cannotFail` decides.
+   */
+  private def isOfferedOn(
+      comparison: BinaryComparison,
+      lookupField: Attribute,
+      parameter: Expression): Boolean =
+    !ExternalLookupKeyTypes.isCharOrVarchar(lookupField) && (comparison match {
+      case _: EqualTo | _: EqualNullSafe =>
+        ExternalLookupKeyTypes.supportsEquality(lookupField.dataType)
+      case _ =>
+        ExternalLookupKeyTypes.supportsRange(lookupField.dataType) && 
cannotFail(parameter)
+    })
+
+  /**
+   * Whether a range parameter cannot fail: 
ExprUtils.canEvaluateUnconditionally accepts it, or it
+   * is a widening numeric cast of such a value. Analysis adds such a cast 
when the stream side has
+   * a narrower type than the lookup column, as an INT column compared with a 
BIGINT key.
+   */
+  private def cannotFail(parameter: Expression): Boolean = parameter match {
+    case cast: Cast =>
+      isTotalNumericWidening(cast.child.dataType, cast.dataType) &&
+        ExprUtils.canEvaluateUnconditionally(cast.child)
+    case _ => ExprUtils.canEvaluateUnconditionally(parameter)
+  }
+
+  /**
+   * Whether a cast converts every value of its source type: an integral type 
to a wider one, or
+   * an integral type or DECIMAL to a DECIMAL with room for all of its digits. 
`Cast.canUpCast`
+   * is not enough: it also accepts casts that fail on some values, as a 
far-future DATE overflows
+   * a TIMESTAMP, and 9999999999999999999 as DECIMAL(19, 0) exceeds BIGINT 
under ANSI.
+   */
+  private def isTotalNumericWidening(from: DataType, to: DataType): Boolean = 
(from, to) match {
+    case (from: IntegralType, to: IntegralType) => from.defaultSize < 
to.defaultSize
+    case (from: IntegralType, to: DecimalType) => to.isWiderThan(from)
+    case (from: DecimalType, to: DecimalType) => to.isWiderThan(from)
+    case _ => false
+  }
+
+  /** The same comparison with its operands swapped, so that `a < b` becomes 
`b > a`. */
+  private def withOperandsSwapped(comparison: BinaryComparison): 
BinaryComparison =
+    comparison match {
+      case EqualTo(left, right) => EqualTo(right, left)
+      case EqualNullSafe(left, right) => EqualNullSafe(right, left)
+      case LessThan(left, right) => GreaterThan(right, left)
+      case LessThanOrEqual(left, right) => GreaterThanOrEqual(right, left)
+      case GreaterThan(left, right) => LessThan(right, left)
+      case GreaterThanOrEqual(left, right) => LessThanOrEqual(right, left)
+    }
+
+  /**
+   * Whether a lookup-side projection or filter can evaluate `expression` on 
the lookup results,
+   * inside the join. Python and external UDFs and subqueries are planned as 
separate operators,
+   * which a lookup-side stage cannot contain. A non-deterministic expression 
is not allowed
+   * either: the join evaluates a stage on each returned row, so one stored 
row could get
+   * different values for different stream rows, which a read of the table 
never does.
+   */
+  def canEvaluateInLookupStage(expression: Expression): Boolean =
+    expression.deterministic && !expression.exists {
+      case _: PythonUDF | _: ExternalUserDefinedFunction | _: 
SubqueryExpression => true
+      case _ => false
+    }
+}
+
+/** One optimized lookup-side operation, retained in evaluation order. */
+sealed trait ExternalLookupTransform {
+  def input: Seq[Attribute]
+  def output: Seq[Attribute]
+}
+
+case class ExternalLookupProject(
+    projectList: Seq[NamedExpression],
+    input: Seq[Attribute]) extends ExternalLookupTransform {
+  override def output: Seq[Attribute] = projectList.map(_.toAttribute)
+}
+
+case class ExternalLookupFilter(
+    condition: Expression,
+    input: Seq[Attribute]) extends ExternalLookupTransform {
+  override def output: Seq[Attribute] = input
+}
+
+/**
+ * A lookup join after its optimized lookup side has been converted to an 
external operation.
+ *
+ * @param lookupKeys The table column that each selected lookup predicate 
compares with the
+ *   parameter at the same position. Spark checks each row that the connector 
returns against
+ *   them, so the read schema must hold these columns.
+ * @param lookupKeyPredicates The name of each selected lookup predicate, such 
as `=` or `<`,
+ *   with the lookup column on the left. It tells how Spark checks the key.
+ */
+case class PreparedExternalLookupJoin(
+    child: LogicalPlan,
+    tableName: String,
+    @transient scan: Scan,
+    readerFactory: ExternalLookupReaderFactory,
+    maxParameterRowsPerRequest: Int,
+    lookupInput: Seq[Attribute],
+    lookupOutput: Seq[Attribute],
+    lookupTransforms: Seq[ExternalLookupTransform],
+    lookupParameters: Seq[Expression],
+    nullSafeParameters: Seq[Boolean],
+    lookupKeys: Seq[Attribute],
+    lookupKeyPredicates: Seq[String],
+    joinType: JoinType,
+    residualCondition: Option[Expression],
+    cacheOptions: ExternalLookupCacheOptions) extends UnaryNode {
+
+  override def output: Seq[Attribute] = joinType match {
+    case Inner => child.output ++ lookupOutput
+    case LeftOuter => child.output ++ lookupOutput.map(_.withNullability(true))
+    case LeftSemi | LeftAnti => child.output
+    case other => throw SparkException.internalError(s"Unexpected external 
lookup join type $other")
+  }
+
+  override def references: AttributeSet =
+    AttributeSet(lookupParameters.flatMap(_.references)) ++
+      residualCondition.map(_.references.intersect(child.outputSet))
+        .getOrElse(AttributeSet.empty)
+
+  override protected def withNewChildInternal(newChild: LogicalPlan): 
PreparedExternalLookupJoin =
+    copy(child = newChild)
+
+  // Connector descriptions may contain secrets. The physical node renders a 
redacted description.
+  override protected def stringArgs: Iterator[Any] = Iterator(child)
+}
+
+/**
+ * The types of lookup columns on which Spark offers a comparison to the 
connector as a lookup
+ * candidate. The connector evaluates a selected candidate in its store, so 
Spark offers only types
+ * whose values a store compares as Spark does. A comparison on any other type 
stays in Spark's

Review Comment:
   **Nit (P3):** With this PR's pushdown, this sentence no longer holds for a 
comparison that reads only lookup columns. Such a comparison, for example 
`t.score = 5.0` on a DOUBLE, now moves onto the lookup side: from the join 
condition for every join type, and from a filter above an inner join. Spark 
evaluates it there in a lookup-side stage, and the connector may also receive 
it as a pushed filter. So the connector's filter handling now matters for 
exactly the types this object keeps out of the offered candidates, while the 
doc says they stay with Spark. The PR description and the plan test 'move a 
comparison on a type that is never offered as a lookup key like any conjunct' 
already describe the new behavior.
   
   **Verification:**
   - **Inspection:** The ExternalLookupKeyTypes doc's account of where a 
comparison on a never-offered type is evaluated matches how predicate pushdown 
handles a lookup-only comparison that is not a lookup candidate, including the 
existing plan coverage that moves DOUBLE and STRING-range comparisons onto the 
lookup side.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -3293,6 +3338,62 @@ object PushPredicateThroughJoin extends 
Rule[LogicalPlan] with PredicateHelper {
         case other =>
           throw SparkException.internalError(s"Unexpected join type: $other")
       }
+    // The same for an external lookup join. The conjuncts keep their written 
order where they stay.
+    case f @ Filter(filterCondition, j: ExternalLookupJoin) =>
+      val conjuncts = splitConjunctivePredicates(filterCondition)
+      val (leftFilterConditions, rightFilterConditions, commonFilterCondition, 
_) =
+        splitLookupJoin(conjuncts, j)
+      val (newJoinConditions, rightFilterConditionsToPush) = j.joinType match {
+        case Inner =>
+          // don't push throwable expressions into join condition
+          (commonFilterCondition.filter(cond => canEvaluateWithinJoin(cond) && 
!cond.throwable),
+            rightFilterConditions)
+        // A filter on lookup columns above a left outer join also removes the 
null-padded rows of
+        // stream rows without a match, so it stays above. EliminateOuterJoin 
first makes the join
+        // inner when the filter rejects nulls. A semi or anti join outputs no 
lookup column.
+        case LeftOuter | LeftSemi | LeftAnti => (Nil, Nil)
+        case other =>
+          throw SparkException.internalError(s"Unexpected external lookup join 
type: $other")
+      }
+      val pushed = leftFilterConditions ++ rightFilterConditionsToPush ++ 
newJoinConditions
+      if (pushed.isEmpty) {
+        f
+      } else {
+        val newJoin = j.copy(
+          left = leftFilterConditions.
+            reduceLeftOption(And).map(Filter(_, j.left)).getOrElse(j.left),
+          right = rightFilterConditionsToPush.
+            reduceLeftOption(And).map(Filter(_, j.right)).getOrElse(j.right),
+          condition = (j.condition.toSeq ++ 
newJoinConditions).reduceLeftOption(And))
+        conjuncts.filterNot(pushed.contains).
+          reduceLeftOption(And).map(Filter(_, newJoin)).getOrElse(newJoin)
+      }
+
+    // Push the join filter of an external lookup join into its children.
+    case j: ExternalLookupJoin =>
+      val conjuncts = 
j.condition.map(splitConjunctivePredicates).getOrElse(Nil)
+      val (leftJoinConditions, rightJoinConditions, _, _) = 
splitLookupJoin(conjuncts, j)
+      val leftJoinConditionsToPush = j.joinType match {
+        case Inner | LeftSemi => leftJoinConditions
+        // A stream-side conjunct decides which stream rows find a match, so 
it stays in the
+        // condition. The lookup join checks it on each stream row before the 
lookup.

Review Comment:
   **Nit (P3):** This holds only for conjuncts that 
`ExprUtils.canEvaluateUnconditionally` accepts. 
`ExternalLookupJoinExec.splitResidualCondition` checks a stream-only conjunct 
before the lookup only in that case, and evaluates every other one on joined 
rows after the lookup. So for a conjunct such as `upper(s.region) = 'EU'` or 
`s.amount * 2 > 100`, every stream row it rejects is still looked up. 
`PushPredicateThroughExternalLookupJoinSuite` repeats the claim ("The join 
checks it there before the lookup."); its `streamKey > 1` example happens to be 
a shape that is checked early.
   
   **Verification:**
   - **Inspection:** Both comments describe when a stream-side conjunct of a 
left outer or left anti lookup join is checked before the lookup in the same 
way the operator splits its residual into the pre-lookup stream condition.



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