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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/V2ExpressionUtils.scala:
##########
@@ -113,25 +128,35 @@ object V2ExpressionUtils extends SQLConfHelper with 
Logging {
       expr: V2Expression,
       query: LogicalPlan,
       funCatalogOpt: Option[FunctionCatalog] = None): Expression =
-    toCatalystOpt(expr, query, funCatalogOpt)
+    toCatalystCore(expr, query, funCatalogOpt, failOnUnresolved = true)
         .getOrElse(throw new AnalysisException(
           errorClass = "_LEGACY_ERROR_TEMP_3054", messageParameters = 
Map("expr" -> expr.toString)))
 
   def toCatalystOpt(
       expr: V2Expression,
       query: LogicalPlan,
-      funCatalogOpt: Option[FunctionCatalog] = None): Option[Expression] = {
+      funCatalogOpt: Option[FunctionCatalog] = None): Option[Expression] =
+    toCatalystCore(expr, query, funCatalogOpt, failOnUnresolved = false)
+
+  /**
+   * Shared implementation of `toCatalyst`/`toCatalystOpt`, keyed on 
`failOnUnresolved`.
+   */
+  private def toCatalystCore(

Review Comment:
   Now that `V2ScanPartitioningAndOrdering` checks every key reference against 
`relation` before calling `toCatalystOpt`, the `None`-on-unresolved path here 
is reachable only from tests: `partitioning()` is the only production caller of 
`toCatalystOpt`, and `toCatalystTransformOpt` has no production caller. The 
only production-visible effect left is that a nested transform whose function 
cannot be loaded drops the partitioning instead of failing the query.
   
   Could we keep `V2ExpressionUtils` unchanged except for `resolveRefOpt`? Then 
this PR becomes smaller, the contract of 
`toCatalystOpt`/`toCatalystTransformOpt` and the nested-transform behavior stay 
as they are, and the new `KeyGroupedPartitioningSuite` cases should still pass. 
If you prefer to keep it as a defensive change, could you mention it in the PR 
description?



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -3396,6 +3403,97 @@ class KeyGroupedPartitioningSuite
     }
   }
 
+  private val customReportingCatalogName = "custom_reporting_cat"
+
+  private def withCustomReportingTable(f: CustomReportingCatalog => Unit): 
Unit = {
+    spark.conf.set(s"spark.sql.catalog.$customReportingCatalogName",
+      classOf[CustomReportingCatalog].getName)
+    val reportingCatalog = 
spark.sessionState.catalogManager.catalog(customReportingCatalogName)
+      .asInstanceOf[CustomReportingCatalog]
+    try {
+      val reportingColumns = Array(
+        Column.create("id", IntegerType),
+        Column.create("data", StringType),
+        Column.create("s", new StructType().add("x", IntegerType)))
+      createTable(table, reportingColumns, Array(identity("id")), catalog = 
reportingCatalog)
+      sql(s"INSERT INTO $customReportingCatalogName.ns.$table VALUES " +
+          "(1, 'aa', named_struct('x', 10)), (2, 'bb', named_struct('x', 20))")
+      createTable(table, columns, Array(identity("id")))
+      sql(s"INSERT INTO testcat.ns.$table VALUES " +
+          "(1, 'cc', cast('2020-01-03' as timestamp)), " +
+          "(2, 'dd', cast('2020-01-04' as timestamp))")
+      withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+        f(reportingCatalog)
+      }
+    } finally {
+      reportingCatalog.reportedKeys = Seq.empty
+      reportingCatalog.reportedOrdering = Seq.empty
+      reportingCatalog.clearTables()
+      spark.conf.unset(s"spark.sql.catalog.$customReportingCatalogName")
+    }
+  }
+
+  private def customReportingJoin(warningText: String): (SparkPlan, 
BatchScanExec, Seq[String]) = {
+    val df = sql(
+      s"""SELECT t1.data, t2.data FROM $customReportingCatalogName.ns.$table t1
+         |JOIN testcat.ns.$table t2 ON t1.id = t2.id
+         |""".stripMargin)
+    val logAppender = new LogAppender("custom reported partitioning or 
ordering")
+    withLogAppender(logAppender, level = Some(Level.WARN)) {
+      checkAnswer(df, Seq(Row("aa", "cc"), Row("bb", "dd")))
+    }
+    val warnings = 
logAppender.loggingEvents.map(_.getMessage.getFormattedMessage)
+      .filter(_.contains(warningText)).toSeq
+    val plan = df.queryExecution.executedPlan
+    val reportingScans = 
collectScans(plan).filter(_.scan.isInstanceOf[CustomReportingScan])
+    assert(reportingScans.length == 1)
+    (plan, reportingScans.head, warnings)
+  }
+
+  test("SPARK-59721: an unresolvable reported partition key falls back to 
unknown partitioning") {
+    val nestedTransform =
+      ApplyTransform("f", Seq(ApplyTransform("g", 
Seq(FieldReference("missing")))))
+    val cases = Seq[(Seq[Expression], Option[String])](
+      (Seq(identity("id")), None),
+      (Seq(bucket(4, "missing")), Some("missing")),
+      (Seq(nestedTransform), Some("missing")),
+      (Seq(identity("id"), identity("missing")), Some("missing")),
+      (Seq(identity("missing"), bucket(4, "missing")), Some("missing")),
+      (Seq(identity("s.missing")), Some("s.missing")))
+    withCustomReportingTable { reportingCatalog =>
+      cases.foreach { case (keys, unresolvedColumns) =>
+        reportingCatalog.reportedKeys = keys
+        val (plan, reportingScan, warnings) =
+          customReportingJoin("partition key columns cannot be resolved")
+        val keysString = keys.map(_.describe()).mkString(", ")
+        unresolvedColumns match {
+          case Some(columnsString) =>
+            assert(warnings.nonEmpty, s"no warning for keys [$keysString]")
+            assert(warnings.forall(_.contains(s"resolved: $columnsString.")), 
warnings)
+            assert(reportingScan.keyGroupedPartitioning.isEmpty, keysString)
+            assert(collectShuffles(plan).nonEmpty, keysString)
+          case None =>
+            assert(warnings.isEmpty, warnings)
+            assert(reportingScan.keyGroupedPartitioning.isDefined, keysString)

Review Comment:
   Could you also assert `collectShuffles(plan).isEmpty` for this control case? 
Otherwise, `collectShuffles(plan).nonEmpty` in the fallback cases would pass 
even if this fixture could never plan a storage-partitioned join.
   
   BTW, `CustomReportingCatalog` extends `InMemoryTableCatalog`, which is not a 
`FunctionCatalog`, so the `bucket(4, missing)` and `f(g(missing))` keys would 
be dropped even if their columns were resolvable. It's fine because the warning 
assertion distinguishes them, but a short comment would help.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanPartitioningAndOrdering.scala:
##########
@@ -47,9 +49,19 @@ object V2ScanPartitioningAndOrdering extends 
Rule[LogicalPlan] with Logging {
         if d.keyGroupedPartitioning.isEmpty =>
       val catalystPartitioning = scan.outputPartitioning() match {
         case kgp: KeyGroupedPartitioning =>
-          val partitioning = sequenceToOption(
-            kgp.keys().map(V2ExpressionUtils.toCatalystOpt(_, relation, 
relation.funCatalog))
-              .toImmutableArraySeq)
+          val unresolvedColumns = 
unresolvableColumns(kgp.keys().toImmutableArraySeq, relation)
+          val partitioning = if (unresolvedColumns.nonEmpty) {
+            logWarning(

Review Comment:
   This warning does not say which relation reports the key, and 
`kgp.getClass.getSimpleName` is `KeyGroupedPartitioning` in practice, so it's 
hard to find the connector at fault in a query with several V2 scans. Could you 
include the relation name like `V2ScanRelationPushDown` does, e.g., 
`${MDC(RELATION_NAME, relation.name)}`, here and in the ordering warning below? 
In addition, the reason of the caught `AnalysisException` is dropped, so an 
ambiguous column is reported as "cannot be resolved" in the same way as a 
missing one.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanPartitioningAndOrdering.scala:
##########
@@ -75,8 +87,34 @@ object V2ScanPartitioningAndOrdering extends 
Rule[LogicalPlan] with Logging {
   private def ordering(plan: LogicalPlan) = plan.transformDownWithPruning(
       _.containsPattern(DATA_SOURCE_V2_SCAN_RELATION)) {
     case d @ ExtractV2ScanInfo(relation, scan: SupportsReportOrdering, _) =>
-      val ordering =
-        V2ExpressionUtils.toCatalystOrdering(scan.outputOrdering(), relation, 
relation.funCatalog)
-      d.copy(ordering = Some(ordering))
+      val unresolvedColumns =
+        unresolvableColumns(scan.outputOrdering().toImmutableArraySeq, 
relation)

Review Comment:
   nit. `scan.outputOrdering()` is now called twice, and `kgp.keys()` above 
too. Could you keep the result in a local variable? A connector may build a new 
array on each call.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanPartitioningAndOrdering.scala:
##########
@@ -75,8 +87,34 @@ object V2ScanPartitioningAndOrdering extends 
Rule[LogicalPlan] with Logging {
   private def ordering(plan: LogicalPlan) = plan.transformDownWithPruning(
       _.containsPattern(DATA_SOURCE_V2_SCAN_RELATION)) {
     case d @ ExtractV2ScanInfo(relation, scan: SupportsReportOrdering, _) =>
-      val ordering =
-        V2ExpressionUtils.toCatalystOrdering(scan.outputOrdering(), relation, 
relation.funCatalog)
-      d.copy(ordering = Some(ordering))
+      val unresolvedColumns =
+        unresolvableColumns(scan.outputOrdering().toImmutableArraySeq, 
relation)
+      if (unresolvedColumns.nonEmpty) {
+        logWarning(
+          log"Spark ignores the reported ordering because the ordering columns 
cannot be " +
+            log"resolved: ${MDC(COLUMN_NAMES, unresolvedColumns.mkString(", 
"))}.")
+        d.copy(ordering = None)
+      } else {
+        val ordering =
+          V2ExpressionUtils.toCatalystOrdering(scan.outputOrdering(), 
relation, relation.funCatalog)
+        d.copy(ordering = Some(ordering))
+      }
+  }
+
+  private def unresolvableColumns(
+      exprs: Seq[V2Expression],
+      relation: LogicalPlan): Seq[String] = {
+    def references(expr: V2Expression): Seq[NamedReference] = expr match {

Review Comment:
   nit. Could you add a short comment on why this does not use 
`Expression.references()`? `ApplyTransform` and `BucketTransform` override it 
with only their top-level arguments, so it would miss `f(g(missing))`.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanPartitioningAndOrdering.scala:
##########
@@ -47,9 +49,19 @@ object V2ScanPartitioningAndOrdering extends 
Rule[LogicalPlan] with Logging {
         if d.keyGroupedPartitioning.isEmpty =>
       val catalystPartitioning = scan.outputPartitioning() match {
         case kgp: KeyGroupedPartitioning =>
-          val partitioning = sequenceToOption(
-            kgp.keys().map(V2ExpressionUtils.toCatalystOpt(_, relation, 
relation.funCatalog))
-              .toImmutableArraySeq)
+          val unresolvedColumns = 
unresolvableColumns(kgp.keys().toImmutableArraySeq, relation)
+          val partitioning = if (unresolvedColumns.nonEmpty) {
+            logWarning(
+              log"Spark ignores the reported ${MDC(CLASS_NAME, 
kgp.getClass.getSimpleName)} " +
+                log"because the partition key columns cannot be resolved: " +

Review Comment:
   FYI, the motivating case in apache/spark#55518 (a relation narrowed for 
column-level UPDATE) is not a connector bug, so this warning would be logged 
whenever the narrowed relation omits a partition source column if 
apache/spark#55518 lands as is. WARN looks fine for this PR because it helps to 
find connector bugs, but we may need to revisit the level there.



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -17,8 +17,11 @@
 package org.apache.spark.sql.connector
 
 import java.sql.Timestamp
+import java.util

Review Comment:
   nit. This file uses `java.util.Map` fully qualified elsewhere (e.g., 
`emptyProps`). Could you use `java.util.Map` in `CustomReportingCatalog` and 
remove this import?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanPartitioningAndOrdering.scala:
##########
@@ -75,8 +87,34 @@ object V2ScanPartitioningAndOrdering extends 
Rule[LogicalPlan] with Logging {
   private def ordering(plan: LogicalPlan) = plan.transformDownWithPruning(
       _.containsPattern(DATA_SOURCE_V2_SCAN_RELATION)) {
     case d @ ExtractV2ScanInfo(relation, scan: SupportsReportOrdering, _) =>
-      val ordering =
-        V2ExpressionUtils.toCatalystOrdering(scan.outputOrdering(), relation, 
relation.funCatalog)
-      d.copy(ordering = Some(ordering))
+      val unresolvedColumns =
+        unresolvableColumns(scan.outputOrdering().toImmutableArraySeq, 
relation)
+      if (unresolvedColumns.nonEmpty) {
+        logWarning(
+          log"Spark ignores the reported ordering because the ordering columns 
cannot be " +
+            log"resolved: ${MDC(COLUMN_NAMES, unresolvedColumns.mkString(", 
"))}.")
+        d.copy(ordering = None)

Review Comment:
   Before this PR, a `SupportsReportOrdering` scan always had `ordering = 
Some(...)`. With `None` here, `DataSourceV2ScanExecBase.outputOrdering` falls 
back to the partition-key-derived ordering when the scan keeps a key-grouped 
partitioning and `spark.sql.sources.v2.bucketing.partitionKeyOrdering.enabled` 
is true (the default), while a scan reporting an empty ordering (`Some(Nil)`) 
does not get it. It looks correct to me because the derived ordering does not 
depend on the dropped report. Could you add a short comment here and assert 
`reportingScan.outputOrdering` in the new ordering test so that this behavior 
is explicit?



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