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


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/PlanDynamicPruningFilters.scala:
##########
@@ -46,47 +46,86 @@ case class PlanDynamicPruningFilters(sparkSession: 
SparkSession) extends Rule[Sp
     HashedRelationBroadcastMode(packedKeys)
   }
 
+  private def reusableBroadcast(

Review Comment:
   This tightens the *existing* direct reuse path, independently of the new 
feature: the old code only checked `left.sameResult(sparkPlan)`, while the new 
code additionally requires broadcast mode equality and excludes 
`isNullAwareAntiJoin` joins.
   
   With the default 
`spark.sql.optimizer.dynamicPartitionPruning.reuseBroadcastOnly=true`, an edge 
case that previously got DPP (at the cost of planning an extra broadcast that 
`ReuseExchange` couldn't dedup, e.g. when key order differs or the only 
matching join is a null-aware anti join) now falls back to 
`Literal.TrueLiteral`, i.e. loses DPP entirely.
   
   Making the non-AQE path consistent with the AQE path (which already compares 
the whole exchange including its mode) is arguably an improvement, but it is a 
semantic change to released behavior that ships silently inside this feature 
PR. Could you either call this out explicitly in the PR description (ideally 
with a test pinning the new behavior), or split it into a separate commit/PR so 
it can be evaluated on its own?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/ReusableBroadcastValueProjection.scala:
##########
@@ -0,0 +1,126 @@
+/*
+ * 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.optimizer
+
+import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.catalyst.plans.Inner
+import org.apache.spark.sql.catalyst.plans.logical._
+import org.apache.spark.sql.catalyst.trees.TreePattern.{CTE, 
DYNAMIC_PRUNING_SUBQUERY}
+import org.apache.spark.sql.types.{DateType, StringType, TimestampNTZType, 
TimestampType}
+
+/**
+ * Finds an existing broadcast whose stored rows provide a safe superset of a 
pruning domain.
+ */
+private[sql] object ReusableBroadcastValueProjection extends PredicateHelper {
+
+  private def isSafeValueExpression(expression: Expression): Boolean = {

Review Comment:
   The `isSafeValueExpression` whitelist seems inconsistent with the motivating 
example in the PR description. The `Attribute` case requires 
`UnsafeRow.isFixedLength(attribute.dataType)`, which rejects variable-length 
types like `StringType` — yet the example in the description projects a string 
column (`category`) from the broadcast. As written, that example query would 
not benefit from this optimization.
   
   Also, the `DateFormatClass` case hardcodes the `'yyyy-MM-dd'` format string, 
which looks tailored to the test queries rather than a principled rule.
   
   Could you clarify:
   1. Why is fixed-length required for plain attribute projections? Output size 
is already bounded by `maxOutputBytes`, so it doesn't seem to be a memory 
concern, and correctness shouldn't depend on the value width.
   2. If the restriction is intentional, please update the PR description so 
the example matches what the whitelist actually supports; if not, consider 
generalizing the whitelist (at least plain string attributes) and documenting 
the criteria for extending it.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/subquery.scala:
##########
@@ -127,23 +127,33 @@ case class InSubqueryExec(
 
   override def nullable: Boolean = child.nullable
   override def toString: String = s"$child IN ${plan.name}"
-  override def withNewPlan(plan: BaseSubqueryExec): InSubqueryExec = copy(plan 
= plan)
+  override def withNewPlan(plan: BaseSubqueryExec): InSubqueryExec =
+    copy(plan = plan, resultBroadcast = null, result = null)
   final override def nodePatternsInternal(): Seq[TreePattern] = 
Seq(IN_SUBQUERY_EXEC)
 
   def updateResult(): Unit = {
-    val rows = plan.executeCollect()
-    result = if (plan.output.length > 1) {
+    val (rows, unavailable) = 
ProjectedBroadcastValueSubqueryExec.resultOf(plan) match {
+      case Some(BroadcastValueResult.Available(values)) => (values, false)
+      case Some(BroadcastValueResult.Unavailable) => 
(Array.empty[InternalRow], true)
+      case None => (plan.executeCollect(), false)
+    }
+    result = if (unavailable) {

Review Comment:
   Since `InSubqueryExec` is also used a general `IN` subquery, shall we 
`assert(isDynamicPruning)` here?
   
   ```suggestion
       result = if (unavailable) {
         assert(isDynamicPruning,
           "An unavailable broadcast value projection result is only allowed 
for dynamic " +
             "pruning, where the filter can safely fail open to true.")
   ```



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/ProjectedBroadcastValueSubqueryExec.scala:
##########
@@ -0,0 +1,132 @@
+/*
+ * 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
+
+import java.util.concurrent.{Future => JFuture}
+
+import scala.concurrent.duration.Duration
+
+import org.apache.spark.rdd.RDD
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.{Attribute, 
AttributeReference, Cast, Expression, NamedExpression, UnsafeRow}
+import org.apache.spark.sql.catalyst.plans.QueryPlan
+import org.apache.spark.sql.errors.QueryExecutionErrors
+import org.apache.spark.sql.execution.BroadcastValueResult.{Available, 
Unavailable}
+import org.apache.spark.sql.execution.joins.HashedRelation
+import org.apache.spark.sql.execution.metric.SQLMetrics
+import org.apache.spark.util.ThreadUtils
+
+/** Collects pruning values from the full rows of an already required hash 
broadcast. */
+case class ProjectedBroadcastValueSubqueryExec(
+    name: String,
+    valueExpression: Expression,
+    child: SparkPlan) extends BaseSubqueryExec with UnaryExecNode {
+
+  override def output: Seq[Attribute] = {
+    val outputName = valueExpression match {
+      case named: NamedExpression => named.name
+      case Cast(named: NamedExpression, _, _, _) => named.name
+      case _ => "key"
+    }
+    Seq(AttributeReference(outputName, valueExpression.dataType, 
valueExpression.nullable)())
+  }
+
+  override lazy val metrics = Map(
+    "numInputRows" -> SQLMetrics.createMetric(sparkContext, "number of input 
rows"),
+    "numOutputRows" -> SQLMetrics.createMetric(sparkContext, "number of output 
rows"),
+    "dataSize" -> SQLMetrics.createMetric(sparkContext, "data size (bytes)"),
+    "projectionDisabled" -> SQLMetrics.createMetric(sparkContext, "projection 
disabled"),
+    "projectionErrors" -> SQLMetrics.createMetric(sparkContext, "projection 
errors"),
+    "collectTime" -> SQLMetrics.createMetric(sparkContext, "time to collect 
(ms)"))
+
+  override def doCanonicalize(): SparkPlan = {
+    ProjectedBroadcastValueSubqueryExec(
+      "dpp",
+      QueryPlan.normalizeExpressions(valueExpression, child.output),
+      child.canonicalized)
+  }
+
+  @transient
+  private lazy val relationFuture: 
JFuture[BroadcastValueResult[Array[InternalRow]]] = {
+    val executionId = 
sparkContext.getLocalProperty(SQLExecution.EXECUTION_ID_KEY)
+    
SQLExecution.withThreadLocalCaptured[BroadcastValueResult[Array[InternalRow]]](
+      session, SubqueryBroadcastExec.executionContext) {
+      SQLExecution.withExecutionId(session, executionId) {
+        val beforeCollect = System.nanoTime()
+        val broadcast = child.executeBroadcast[HashedRelation]()
+        val limits = BroadcastValueProjectionLimits(
+          maxInputRows = 
conf.dynamicPartitionPruningBroadcastProjectionMaxRows.toLong,
+          maxOutputBytes = 
conf.dynamicPartitionPruningBroadcastProjectionMaxBytes,
+          maxSourceBytes = 
conf.dynamicPartitionPruningBroadcastProjectionMaxSourceBytes)
+        val result = BroadcastValueProjector.collectExactValueDomain(
+          broadcast,
+          child,
+          valueExpression,
+          limits,
+          onInputRow = () => longMetric("numInputRows") += 1,
+          onError = () => longMetric("projectionErrors") += 1)
+
+        longMetric("collectTime") += (System.nanoTime() - beforeCollect) / 
1000000
+        result match {
+          case Available(rows) =>
+            longMetric("numOutputRows") += rows.length
+            longMetric("dataSize") +=
+              
rows.iterator.map(_.asInstanceOf[UnsafeRow].getSizeInBytes.toLong).sum
+          case Unavailable =>
+            longMetric("projectionDisabled") += 1
+        }
+        SQLMetrics.postDriverMetricUpdates(sparkContext, executionId, 
metrics.values.toSeq)
+        result
+      }
+    }
+  }
+
+  override protected def doPrepare(): Unit = {
+    relationFuture
+  }
+
+  override protected def doExecute(): RDD[InternalRow] = {
+    throw QueryExecutionErrors.executeCodePathUnsupportedError(
+      "ProjectedBroadcastValueSubqueryExec")
+  }
+
+  override def executeCollect(): Array[InternalRow] = executeCollectResult() 
match {
+    case Available(rows) => rows
+    case Unavailable => Array.empty

Review Comment:
   `executeCollect()` maps `Unavailable` to an **empty array**. The whole 
design rests on "an unavailable domain is never interpreted as an empty 
domain", but that invariant is only upheld because 
`InSubqueryExec.updateResult` goes through 
`ProjectedBroadcastValueSubqueryExec.resultOf`. Any future caller that invokes 
`executeCollect()` directly would read "empty domain" and prune *every* 
partition — a silent correctness bug. Consider throwing from `executeCollect()` 
instead (like `doExecute()` does), or at minimum adding a prominent comment 
stating that callers must use `resultOf`/`executeCollectResult()`?



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