sunchao commented on code in PR #5889:
URL: https://github.com/apache/datafusion-comet/pull/5889#discussion_r4361964356


##########
spark/src/main/scala/org/apache/comet/serde/CometScalarSubquery.scala:
##########
@@ -19,30 +19,362 @@
 
 package org.apache.comet.serde
 
-import org.apache.spark.sql.catalyst.expressions.Attribute
-import org.apache.spark.sql.execution.ScalarSubquery
+import scala.jdk.CollectionConverters._
+import scala.util.DynamicVariable
+
+import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute, 
BoundReference, CaseWhen, Cast, Coalesce, CreateArray, CreateMap, 
CreateNamedStruct, DateFromUnixDate, DayOfWeek, ElementAt, EqualNullSafe, 
EqualTo, Expression, ExprId, GetArrayItem, GetArrayStructFields, GetMapValue, 
GetStructField, GreaterThan, GreaterThanOrEqual, Greatest, Hour, If, In, InSet, 
IsNotNull, IsNull, KnownNotNull, KnownNullable, Least, LessThan, 
LessThanOrEqual, Literal, Minute, NamedExpression, PreciseTimestampConversion, 
Second, SortOrder, ToUnixTimestamp, TryEval, UnixDate, UnixTimestamp, WeekDay}
+import org.apache.spark.sql.catalyst.trees.TreeNodeTag
+import org.apache.spark.sql.catalyst.trees.TreePattern.PLAN_EXPRESSION
+import org.apache.spark.sql.comet.CometExec
+import org.apache.spark.sql.execution.{ExecSubqueryExpression, ExpandExec, 
GenerateExec, ScalarSubquery, SparkPlan, UnionExec}
+import org.apache.spark.sql.execution.adaptive.{AdaptiveSparkPlanExec, 
QueryStageExec}
+import org.apache.spark.sql.execution.aggregate.BaseAggregateExec
+import org.apache.spark.sql.execution.exchange.ReusedExchangeExec
+import org.apache.spark.sql.types._
+
+import com.google.protobuf.Message
 
 import org.apache.comet.CometSparkSessionExtensions.withFallbackReason
 import org.apache.comet.serde.QueryPlanSerde.{serializeDataType, 
supportedDataType}
 
 object CometScalarSubquery extends CometExpressionSerde[ScalarSubquery] {
 
+  private val structSubqueryInputs =
+    new TreeNodeTag[Set[ExprId]]("CometStructScalarSubqueryInputs")
+  private val structSubqueryContext =
+    new TreeNodeTag[Boolean]("CometStructScalarSubqueryContext")
+  private val currentStructSubqueryInputs = new 
DynamicVariable[Set[ExprId]](Set.empty)
+  private val currentPlanHasStructSubquery = new 
DynamicVariable[Boolean](false)
+
+  /**
+   * Trace struct scalar values before native conversion replaces their 
producing projections.
+   * Attribute object identity is insufficient: aliases, nullable join outputs 
and reused stages
+   * can create fresh attributes. Keep provenance by ExprId on the owning plan 
instead.
+   *
+   * This is recomputed on every rule application, including the plans hidden 
by AQE/reuse nodes.
+   * No provenance is inferred from a column's data type or carried in a 
global ExprId registry.
+   */
+  private[comet] def tagPlanInputs(root: SparkPlan): Unit = {
+    val visited = new java.util.IdentityHashMap[SparkPlan, Set[ExprId]]()
+    def remember(plan: SparkPlan, sources: Set[ExprId]): Unit = {
+      val _ = visited.put(plan, sources)
+    }
+
+    def visit(plan: SparkPlan): Set[ExprId] = {
+      if (visited.containsKey(plan)) return visited.get(plan)
+      remember(plan, Set.empty)
+      plan.unsetTagValue(structSubqueryInputs)
+      plan.unsetTagValue(structSubqueryContext)
+
+      val original = plan match {
+        case comet: CometExec => comet.originalPlan
+        case other => other
+      }
+      val children = plan match {
+        case reused: ReusedExchangeExec => Seq(reused.child)
+        case stage: QueryStageExec => Seq(stage.plan)
+        case adaptive: AdaptiveSparkPlanExec => Seq(adaptive.executedPlan)
+        case comet: CometExec if comet.children.isEmpty => 
Seq(comet.originalPlan)
+        case other => other.children
+      }
+      val childSources = children.map(visit)
+      var sources = childSources.flatten.toSet
+      // A struct subquery can enable native conversion of both its operator 
and ancestors.
+      // Keep that context even when a later projection drops the struct: 
sibling/ancestor
+      // calendar consumers can still receive ordinary columns outside the 
native range.
+      val hasStructContext =
+        children.exists(_.getTagValue(structSubqueryContext).contains(true)) ||
+          original.expressions.exists(hasStructSubquerySource(_, sources))
+      plan.setTagValue(structSubqueryContext, hasStructContext)
+      if (sources.isEmpty &&
+        !original.expressions.exists(_.containsPattern(PLAN_EXPRESSION))) {
+        return Set.empty
+      }
+
+      // Union and reused stages can expose different ExprIds for the same 
output positions.
+      original match {
+        case _: UnionExec | _: ReusedExchangeExec | _: QueryStageExec |
+            _: AdaptiveSparkPlanExec =>
+          plan.output.zipWithIndex.foreach { case (output, ordinal) =>
+            if (children.zip(childSources).exists { case (child, ids) =>
+                child.output.lift(ordinal).exists(a => ids.contains(a.exprId))
+              }) {
+              sources += output.exprId
+            }
+          }
+        case _ =>
+      }
+
+      val named = original.expressions.flatMap(_.collect { case e: 
NamedExpression => e })
+      var changed = true
+      while (changed) {
+        val previous = sources
+        named.foreach { expression =>
+          if (hasStructSubquerySource(expression, sources)) sources += 
expression.exprId
+        }
+        original match {
+          case aggregate: BaseAggregateExec =>
+            
aggregate.aggregateExpressions.zip(aggregate.aggregateAttributes).foreach {
+              case (expression, output) =>
+                val function = expression.aggregateFunction
+                if (hasStructSubquerySource(expression, sources) ||
+                  function.inputAggBufferAttributes.exists(a => 
sources.contains(a.exprId))) {
+                  sources += output.exprId
+                  sources ++= function.aggBufferAttributes.map(_.exprId)
+                  sources ++= function.inputAggBufferAttributes.map(_.exprId)
+                }
+            }
+          case generate: GenerateExec if 
hasStructSubquerySource(generate.generator, sources) =>
+            // Generators expose fresh attributes rather than NamedExpression 
aliases.
+            sources ++= generate.generatorOutput.map(_.exprId)
+          case expand: ExpandExec =>
+            expand.output.zipWithIndex.foreach { case (output, ordinal) =>
+              if (expand.projections.exists(p => 
hasStructSubquerySource(p(ordinal), sources))) {
+                sources += output.exprId
+              }
+            }
+          case _ =>
+        }
+        changed = previous != sources
+      }
+
+      plan.setTagValue(structSubqueryInputs, sources)
+      val outputs = 
plan.output.iterator.map(_.exprId).filter(sources.contains).toSet
+      remember(plan, outputs)
+      outputs
+    }
+
+    val _ = visit(root)
+  }
+
+  /** Keep the lineage local to this conversion, even when an attribute was 
copied or rebound. */
+  private[comet] def withPlanInputs[T](plan: SparkPlan)(f: => T): T = {
+    val inputs = plan.getTagValue(structSubqueryInputs).getOrElse(Set.empty)
+    currentStructSubqueryInputs.withValue(inputs) {
+      currentPlanHasStructSubquery.withValue(
+        plan.getTagValue(structSubqueryContext).getOrElse(false))(f)
+    }
+  }
+
+  private val calendarConsumerFallbackReason =
+    "Calendar operations on struct scalar subqueries require Spark because 
their runtime values " +
+      "may exceed the native calendar range"
+
+  private val timezoneConsumerFallbackReason =
+    "Timezone-dependent scalar subquery operations require Spark for non-fixed 
timezones"
+
+  private val dateUnixTimestampFallbackReason =
+    "Date scalar subqueries in unix_timestamp require Spark because their 
runtime values " +
+      "may exceed the native calendar range"
+
+  private def containsCalendarType(dt: DataType): Boolean = dt match {
+    case DateType | TimestampType | TimestampNTZType => true
+    case s: StructType => s.fields.exists(f => 
containsCalendarType(f.dataType))
+    case ArrayType(elementType, _) => containsCalendarType(elementType)
+    case MapType(keyType, valueType, _) =>
+      containsCalendarType(keyType) || containsCalendarType(valueType)
+    case _ => false
+  }
+
+  private def containsVariableWidth(dt: DataType): Boolean = dt match {
+    case StringType | BinaryType => true
+    case s: StructType => s.fields.exists(f => 
containsVariableWidth(f.dataType))
+    case ArrayType(elementType, _) => containsVariableWidth(elementType)
+    case _ => false
+  }
+
+  private def hasVariableWidthStructSubquery(expr: Expression): Boolean =
+    expr.containsPattern(PLAN_EXPRESSION) && expr.exists {
+      case subquery: ExecSubqueryExpression =>
+        subquery.dataType.isInstanceOf[StructType] && 
containsVariableWidth(subquery.dataType)
+      case _ => false
+    }
+
+  /** Opaque JVM kernels cannot participate in the native consumer's bounded 
evaluation. */
+  private[serde] def dispatchedConsumerFallbackReason(
+      expr: Expression,
+      proto: ExprOuterClass.Expr): Option[String] = {
+    def containsDispatch(message: Message): Boolean = message match {
+      case e: ExprOuterClass.Expr if e.hasJvmScalarUdf => true
+      case other =>
+        other.getAllFields.values().asScala.exists {
+          case child: Message => containsDispatch(child)
+          case children: java.util.List[_] =>
+            children.asScala.exists {
+              case child: Message => containsDispatch(child)
+              case _ => false
+            }
+          case _ => false
+        }
+    }
+    if (hasVariableWidthStructSubquery(expr) && containsDispatch(proto)) {
+      Some("JVM-dispatched consumers of variable-width struct scalar 
subqueries require Spark")
+    } else {
+      None
+    }
+  }
+
+  private def isTimestamp(dt: DataType): Boolean =
+    dt == TimestampType || dt == TimestampNTZType
+
+  private def isEpochCast(from: DataType, to: DataType): Boolean =
+    (isTimestamp(from) && to == LongType) || (from == LongType && 
isTimestamp(to))
+
+  private def hasStructSubquerySource(expr: Expression, inputs: Set[ExprId]): 
Boolean =
+    expr.references.exists(a => inputs.contains(a.exprId)) ||
+      (expr.containsPattern(PLAN_EXPRESSION) && expr.exists {
+        case subquery: ExecSubqueryExpression => 
subquery.dataType.isInstanceOf[StructType]
+        case _ => false
+      })
+
+  private def hasStructSubquery(expr: Expression): Boolean =
+    hasStructSubquerySource(expr, currentStructSubqueryInputs.value)
+
+  private def hasStructSubqueryContext(expr: Expression): Boolean =
+    currentPlanHasStructSubquery.value || hasStructSubquery(expr)
+
+  private def clockFallbackReason(
+      child: Expression,
+      timeZoneId: Option[String]): Option[String] = {
+    if (child.dataType == TimestampNTZType || 
CometTimeZone.isFixedOffset(timeZoneId)) {
+      None
+    } else {
+      Some(timezoneConsumerFallbackReason)
+    }
+  }
+
+  /**
+   * Admit only reviewed consumers of dates and timestamps on newly supported 
struct-subquery
+   * paths. Unknown consumers stay on Spark by default, including expressions 
whose result is not
+   * a calendar type. Transport, comparisons and epoch arithmetic do not 
interpret a calendar.
+   *
+   * Check the whole expression before serialization or codegen dispatch, 
including wrappers and
+   * whole-struct casts. A physical subquery may be reused, but it is still an
+   * [[ExecSubqueryExpression]]; its result must never be evaluated while 
planning this check.
+   */
+  private[serde] def consumerFallbackReason(expr: Expression): Option[String] 
= {
+    // Skip paths without struct subqueries. A newly admitted calendar 
consumer can be a
+    // sibling or ancestor of a struct-producing operator without using its 
result directly.
+    // Aliases still carry value provenance through the owning plan's ExprIds.
+    val derivedInput =
+      expr.references.exists(a => 
currentStructSubqueryInputs.value.contains(a.exprId))
+    if (!currentPlanHasStructSubquery.value && !derivedInput &&
+      (!expr.containsPattern(PLAN_EXPRESSION) || 
!ExecSubqueryExpression.hasSubquery(expr))) {
+      return None
+    }
+
+    // Native consumers may change batch boundaries to avoid repeating a large 
scalar field.
+    // Stateful consumers must retain Spark's evaluation order and their 
pre-existing fallback.
+    if (!expr.deterministic && hasVariableWidthStructSubquery(expr)) {
+      return Some(
+        "Nondeterministic consumers of variable-width struct scalar subqueries 
require Spark")
+    }
+
+    expr match {
+      // Dynamic nested IN stops after a batch-wide match in native execution. 
Keep row-dependent
+      // candidates on Spark rather than change ANSI/shared-expression 
evaluation by slicing.
+      // Literal candidates use the separate static IN implementation. 
Constant membership also
+      // needs sharing authorization: Spark may eagerly evaluate a shared, 
failing candidate.
+      case in: In
+          if hasStructSubqueryContext(in) &&
+            (in.value.dataType.isInstanceOf[StructType] ||
+              in.value.dataType.isInstanceOf[ArrayType]) &&
+            containsVariableWidth(in.value.dataType) &&
+            in.list.exists(!_.isInstanceOf[Literal]) &&
+            (in.references.nonEmpty || 
!QueryPlanSerde.canSkipEvaluation(in.list)) =>
+        return Some("Nested membership of struct scalar subqueries requires 
Spark")
+      case _ =>
+    }
+
+    val touchesCalendar =
+      containsCalendarType(expr.dataType) ||
+        expr.children.exists(child => containsCalendarType(child.dataType))
+
+    val reason = expr match {
+      // Preserve the existing guard for plain scalar date subqueries as well 
as struct fields.
+      case unix: UnixTimestamp
+          if unix.timeExp.dataType == DateType &&
+            (ExecSubqueryExpression.hasSubquery(unix.timeExp) || 
hasStructSubqueryContext(
+              unix.timeExp)) =>
+        Some(dateUnixTimestampFallbackReason)
+      // Retain timezone protection for plain scalar subqueries too.
+      case hour: Hour => clockFallbackReason(hour.child, hour.timeZoneId)
+      case minute: Minute => clockFallbackReason(minute.child, 
minute.timeZoneId)
+      case second: Second => clockFallbackReason(second.child, 
second.timeZoneId)
+      case _ if !hasStructSubqueryContext(expr) || !touchesCalendar => None
+      // These nodes only carry, select, compare or inspect the validity of 
raw values.
+      // Keep the list explicit: a new date-consuming expression must be 
reviewed before admission.
+      case _: Literal | _: Attribute | _: BoundReference | _: ScalarSubquery | 
_: Alias |
+          _: GetStructField | _: CreateNamedStruct | _: CreateArray | _: 
CreateMap |
+          _: GetArrayItem | _: GetArrayStructFields | _: GetMapValue | _: 
ElementAt | _: If |
+          _: CaseWhen | _: Coalesce | _: Greatest | _: Least | _: KnownNotNull 
|
+          _: KnownNullable | _: TryEval | _: SortOrder | _: IsNull | _: 
IsNotNull | _: In |
+          _: InSet | _: EqualTo | _: EqualNullSafe | _: LessThan | _: 
LessThanOrEqual |
+          _: GreaterThan | _: GreaterThanOrEqual =>
+        None
+      case cast: Cast
+          if cast.child.dataType == cast.dataType ||
+            isEpochCast(cast.child.dataType, cast.dataType) =>
+        None
+      case conversion: PreciseTimestampConversion
+          if isEpochCast(conversion.fromType, conversion.toType) =>
+        None
+      case _: UnixDate | _: DateFromUnixDate => None
+      // These two date fields use epoch-day arithmetic rather than chrono's 
calendar.
+      case _: DayOfWeek | _: WeekDay => None
+      // Timestamp-to-epoch conversions are arithmetic; dates first need 
calendar conversion.
+      case unix: UnixTimestamp if isTimestamp(unix.timeExp.dataType) => None
+      case unix: ToUnixTimestamp if isTimestamp(unix.timeExp.dataType) => None
+      case _ => Some(calendarConsumerFallbackReason)
+    }
+
+    // An allowed wrapper must not hide an unreviewed consumer in its children.
+    reason.orElse {
+      expr.children.iterator.map(consumerFallbackReason).collectFirst { case 
Some(childReason) =>
+        childReason
+      }
+    }
+  }
+
   override def getUnsupportedReasons(): Seq[String] = Seq(
-    "Not all data types are supported for scalar subquery results")
+    "Not all data types are supported for scalar subquery results",
+    "Struct fields must have supported types and distinct names at every 
nesting level")
 
-  override def getSupportLevel(expr: ScalarSubquery): SupportLevel =
-    if (supportedDataType(expr.dataType)) {
+  // The shared type gate handles general capabilities. Only the Arrow IPC 
scalar-subquery
+  // bridge's additional struct-only and decimal-scale restrictions belong 
here.
+  private def supportedStructShape(dt: DataType): Boolean = dt match {
+    case s: StructType => s.fields.forall(f => 
supportedStructShape(f.dataType))
+    case _: ArrayType | _: MapType => false
+    case d: DecimalType => d.scale >= 0 && d.scale <= d.precision
+    case _ => true
+  }
+
+  override def getSupportLevel(expr: ScalarSubquery): SupportLevel = {
+    val supported = expr.dataType match {
+      case s: StructType =>

Review Comment:
   [P2] [P2] Preserve a supported planning path for merged Bloom-filter 
subqueries
   
   This new struct admission also enables the shape Spark creates when an 
injected runtime Bloom-filter aggregate merges with another scalar aggregate. 
`CometBloomFilterMightContain` serializes the resulting 
`GetStructField(ScalarSubquery(...))`, but native 
`BloomFilterMightContain::try_new` evaluates it on an empty batch and accepts 
only a scalar binary value. `GetStructField` returns an empty binary array, so 
planning fails with `Bloom filter expression should be evaluated as a scalar 
binary value`. This affects ordinary joins with runtime Bloom filtering and a 
mergeable scalar aggregate, which previously retained Spark fallback. Please 
adapt Bloom’s planning-time evaluator to extract one binary value, or retain 
fallback for this argument shape, while preserving the batch-sized field 
contract for normal consumers.
   
   Evidence: SQL reproduction on Spark 3.5.9: create Parquet `dim` with IDs 
0–99 and `v = 'keep'` for IDs 0 and 1, otherwise `'drop'`. Create Parquet 
`fact` with IDs 0–999. Set `spark.sql.adaptive.enabled=false`, 
`spark.sql.execution.reuseSubquery=true`, 
`spark.sql.autoBroadcastJoinThreshold=-1`, 
`spark.sql.optimizer.runtime.bloomFilter.enabled=true`, 
`spark.sql.optimizer.runtime.bloomFilter.applicationSideScanSizeThreshold=0`, 
`spark.sql.optimizer.runtime.bloomFilter.creationSideThreshold=10485760`, and 
`spark.sql.shuffle.partitions=1`. Run `SELECT fact.id, (SELECT percentile(id, 
0.5) FROM dim WHERE v = 'keep' AND id IS NOT NULL) AS p FROM fact JOIN dim ON 
fact.id = dim.id WHERE dim.v = 'keep'`. Spark produces a merged 
`named_struct(bloomFilter, ..., percentile(id, 0.5, 1), ...)` and a filter 
containing `might_contain(scalar-subquery[].bloomFilter, xxhash64(id, 42))`. 
Expected rows are `(0, 0.5)` and `(1, 0.5)`, which Spark returned. At the 
reviewed head, construct the resolved 
 `Literal(ScalarValue::Struct(...))`, select its binary field with 
`GetStructField`, pass it through `ScalarSubqueryConsumer::wrap`, then call 
`BloomFilterMightContain::try_new`. Both null and valid non-null filter cases 
reproduce the stated planning error. Equivalent direct `ScalarValue::Binary` 
controls succeed. The production planner calls this same constructor at 
`native/core/src/execution/planner.rs:977`, and the scalar-only match is at 
`native/spark-expr/src/bloom_filter/bloom_filter_might_contain.rs:56`. 
Reproduction source and logs are preserved under 
`/tmp/pr5889-30b4-review-bloom-*`. A full Comet JVM SQL run was not performed.



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