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


##########
spark/src/main/scala/org/apache/comet/serde/strings.scala:
##########
@@ -557,7 +557,7 @@ object CometStringLPad extends 
CometExpressionSerde[StringLPad] with CodegenDisp
     Seq(PadReasons.literalStrReason, PadReasons.nonLiteralPadReason)
 
   override def getSupportLevel(expr: StringLPad): SupportLevel = {
-    if (expr.str.isInstanceOf[Literal]) {
+    if (expr.str.references.isEmpty) {

Review Comment:
   [P2] Handle scalar-producing conditional inputs in both padding guards. 
Nonempty `references` does not guarantee an array: `IfExpr` returns its scalar 
branch when every row takes that branch. With AQE disabled, subquery reuse 
enabled, and Parquet rows `(p,s) = (true,'a'), (true,'b')`, run `SELECT 
lpad(IF(p, (SELECT max(s) FROM t), s), 8, '0'), (SELECT min(s) FROM t) FROM t`. 
The merged struct now converts, and this guard admits the `IF` because it 
references `p` and `s`. Padding then fails with `Unsupported arguments 
[Scalar(Utf8("b")), ...]` instead of returning `0000000b` for both rows. `rpad` 
fails identically. Previously the unsupported struct kept the projection on 
Spark. Please support runtime scalar strings in the kernels or conservatively 
retain fallback for these conditional subquery consumers.
   
   Evidence: An exact-head Rust reproduction evaluated `GetStructField` from a 
struct literal containing "b", then `IfExpr` over predicates [true,true] and 
strings ["a","b"]. The result was `Scalar(Utf8("b"))`. Both padding kernels 
rejected it with length 8 and pad "0". Materializing the same value as a 
two-row array produced ["0000000b","0000000b"] for lpad and 
["b0000000","b0000000"] for rpad. Spark’s StringLPad/StringRPad implementations 
support these values across all five checked versions.



##########
spark/src/main/scala/org/apache/comet/serde/CometScalarSubquery.scala:
##########
@@ -19,30 +19,271 @@
 
 package org.apache.comet.serde
 
-import org.apache.spark.sql.catalyst.expressions.Attribute
-import org.apache.spark.sql.execution.ScalarSubquery
+import scala.util.DynamicVariable
+
+import org.apache.spark.sql.catalyst.expressions.{Attribute, Cast, DayOfWeek, 
Expression, ExprId, GetDateField, Hour, LastDay, Minute, NamedExpression, 
NextDay, Second, TimeZoneAwareExpression, ToUnixTimestamp, 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, 
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 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 currentStructSubqueryInputs = new 
DynamicVariable[Set[ExprId]](Set.empty)
+
+  /**
+   * 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)
+
+      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
+      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 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 =
+    currentStructSubqueryInputs.withValue(
+      plan.getTagValue(structSubqueryInputs).getOrElse(Set.empty))(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 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 clockFallbackReason(
+      child: Expression,
+      timeZoneId: Option[String]): Option[String] = {
+    if (child.dataType == TimestampNTZType || 
CometTimeZone.isFixedOffset(timeZoneId)) {
+      None
+    } else {
+      Some(timezoneConsumerFallbackReason)
+    }
+  }
+
+  /**
+   * Keep newly supported scalar consumers on Spark when native calendar or 
timezone rules cannot
+   * cover their unknown runtime values. The struct bridge itself remains 
compatible: transport,
+   * field access, 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] 
= {
+    // The cached pattern check skips direct-subquery searches for ordinary 
expressions. Aliased
+    // values still carry their struct-subquery origin through the owning 
plan's input ExprIds.
+    val derivedInput =
+      expr.references.exists(a => 
currentStructSubqueryInputs.value.contains(a.exprId))
+    if (!derivedInput &&
+      (!expr.containsPattern(PLAN_EXPRESSION) || 
!ExecSubqueryExpression.hasSubquery(expr))) {
+      return None
+    }
+
+    val reason = expr match {
+      case cast: Cast if hasStructSubquery(cast) =>
+        val from = cast.child.dataType
+        val to = cast.dataType
+        if (from != to && !isEpochCast(from, to) &&
+          (containsCalendarType(from) || containsCalendarType(to))) {
+          Some(calendarConsumerFallbackReason)
+        } else {
+          None
+        }
+      // These two date fields use epoch-day arithmetic rather than chrono's 
calendar.
+      case _: DayOfWeek | _: WeekDay => None
+      case field: GetDateField if hasStructSubquery(field) =>
+        Some(calendarConsumerFallbackReason)
+      case lastDay: LastDay if hasStructSubquery(lastDay) =>
+        Some(calendarConsumerFallbackReason)
+      case nextDay: NextDay if hasStructSubquery(nextDay) =>
+        Some(calendarConsumerFallbackReason)
+      // These scalar kernels cover the full timestamp range without calendar 
conversion when
+      // the offset is fixed, and NTZ clock fields never apply the session 
timezone.
+      case hour: Hour => clockFallbackReason(hour.child, hour.timeZoneId)
+      case minute: Minute => clockFallbackReason(minute.child, 
minute.timeZoneId)
+      case second: Second => clockFallbackReason(second.child, 
second.timeZoneId)
+      // 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 unix: UnixTimestamp
+          if unix.timeExp.dataType == DateType &&
+            (ExecSubqueryExpression.hasSubquery(unix.timeExp) || 
hasStructSubquery(
+              unix.timeExp)) =>
+        Some(dateUnixTimestampFallbackReason)
+      // Other newly admitted timezone-aware consumers can use chrono even in 
UTC (for example
+      // date_format). Unlike the exceptions above, their full runtime range 
is not compatible.
+      case aware: TimeZoneAwareExpression if hasStructSubquery(aware) =>
+        Some(calendarConsumerFallbackReason)
+      case _ => None

Review Comment:
   [P2] Include `TruncDate` in the calendar-consumer fallback. Spark’s 
`TruncDate` extends `TruncInstant`, so it matches neither `GetDateField` nor 
`TimeZoneAwareExpression` and reaches this default branch. With AQE disabled, 
subquery reuse enabled, and a Spark-written Parquet table containing `d = 
date_from_unix_date(100000000)`, run `SELECT unix_date(trunc((SELECT max(d) 
FROM t), 'YEAR')), unix_date(trunc((SELECT min(d) FROM t), 'YEAR')) FROM t`. 
Spark merges the subqueries into a struct and returns `99999744` for both 
expressions. The newly admitted native path returns NULL because its truncation 
kernel converts through chrono. This silently changes results and downstream 
predicates or aggregates where the base previously fell back successfully. 
Please guard struct-derived `TruncDate` inputs or implement full-range 
truncation.
   
   Evidence: An exact-head Rust reproduction extracted `Date32(100000000)` from 
a struct literal and invoked `SparkDateTrunc`. YEAR, MONTH, QUARTER and WEEK 
all returned `Scalar(Date32(NULL))`. Java LocalDate calculations returned epoch 
days 99999744, 99999988, 99999926 and 99999995 respectively. Spark 3.4.3–4.2.0 
sources use equivalent calendar/arithmetic operations and have the same 
`TruncDate` inheritance that bypasses this guard.



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