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


##########
spark/src/test/scala/org/apache/comet/CometBroadcastBuildSideSuite.scala:
##########
@@ -249,4 +262,709 @@ class CometBroadcastBuildSideSuite extends CometTestBase 
with CometIcebergTestBa
       }
     }
   }
+
+  test("broadcast build side with unsupported text source goes native 
(#6008)") {
+    // A tiny lookup table read from a Text file (which Comet cannot scan 
natively) sits on the
+    // build side of a broadcast join over a large native probe. Without the 
auto-bridge the whole
+    // join stays on Spark. With 
spark.comet.convert.broadcastBuildSide.enabled (default on) and
+    // its Text opt-in (on for this suite) the text leaf is bridged to Arrow 
so the broadcast and
+    // the join run natively.
+    
assert(CometConf.COMET_CONVERT_BROADCAST_BUILD_SIDE_ENABLED.defaultValue.contains(true))
+    withTempDir { dir =>
+      val probePath = s"${dir.getAbsolutePath}/probe.parquet"
+      val allowPath = s"${dir.getAbsolutePath}/allow.txt"
+      withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+        spark
+          .range(0, 100)
+          .selectExpr("CAST(id AS STRING) AS key", "id AS v")
+          .write
+          .parquet(probePath)
+        spark
+          .range(0, 5)
+          .selectExpr("CAST(id AS STRING) AS value")
+          .write
+          .text(allowPath)
+      }
+
+      // Cover both the AQE and non-AQE planning paths - the tag pre-pass runs 
per stage under AQE.
+      for (aqe <- Seq(false, true)) {
+        withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString) {
+          spark.read.parquet(probePath).createOrReplaceTempView("probe_6008")
+          spark.read.text(allowPath).createOrReplaceTempView("allow_6008")
+          val query =
+            "SELECT /*+ BROADCAST(a) */ p.v FROM probe_6008 p JOIN allow_6008 
a ON p.key = a.value"
+
+          // Default: the text build side is bridged to Arrow and the join 
goes native. The plan is
+          // fully native (the bridge counts as a native leaf), so use 
checkSparkAnswerAndOperator -
+          // checkSparkAnswer would fail under COMET_STRICT_TESTING on a 
fully-native plan.
+          val (_, cometPlan) = checkSparkAnswerAndOperator(sql(query))
+          assert(
+            collect(cometPlan) { case s: CometSparkToColumnarExec => s 
}.nonEmpty,
+            s"aqe=$aqe: expected a CometSparkToColumnarExec on the text build 
side:\n" +
+              cometPlan.treeString)
+          assert(
+            collect(cometPlan) { case b: CometBroadcastExchangeExec => b 
}.nonEmpty,
+            s"aqe=$aqe: expected a 
CometBroadcastExchangeExec:\n${cometPlan.treeString}")
+          assert(
+            collect(cometPlan) { case j: CometBroadcastHashJoinExec => j 
}.nonEmpty,
+            s"aqe=$aqe: expected a 
CometBroadcastHashJoinExec:\n${cometPlan.treeString}")
+
+          // Off: the unsupported text leaf blocks native execution of the 
broadcast and the join.
+          withSQLConf(CometConf.COMET_CONVERT_BROADCAST_BUILD_SIDE_ENABLED.key 
-> "false") {
+            val (_, cometPlanOff) = checkSparkAnswer(sql(query))
+            assert(
+              collect(cometPlanOff) { case s: CometSparkToColumnarExec => s 
}.isEmpty,
+              s"aqe=$aqe: expected no CometSparkToColumnarExec when 
disabled:\n" +
+                cometPlanOff.treeString)
+            assert(
+              collect(cometPlanOff) { case b: CometBroadcastExchangeExec => b 
}.isEmpty,
+              s"aqe=$aqe: expected no CometBroadcastExchangeExec when 
disabled:\n" +
+                cometPlanOff.treeString)
+            assert(
+              collect(cometPlanOff) { case j: CometBroadcastHashJoinExec => j 
}.isEmpty,
+              s"aqe=$aqe: expected no CometBroadcastHashJoinExec when 
disabled:\n" +
+                cometPlanOff.treeString)
+          }
+        }
+      }
+    }
+  }
+
+  test("a Text build side keeps Spark's bytes unless its opt-in is on 
(#7118)") {
+    // The bridge is on by default, but a Text build side also needs
+    // spark.comet.convert.broadcastBuildSide.text.enabled. A Text file can 
hold bytes that are not
+    // valid UTF-8, here a Latin-1 "é" (0xE9) and "ï" (0xEF), and converting 
them to Arrow replaces
+    // each ill-formed sequence with U+FFFD. Without the opt-in the join stays 
on Spark and keeps
+    // the bytes, as in 1.1.0, and the scan records why it was not converted.
+    
assert(CometConf.COMET_CONVERT_BROADCAST_BUILD_SIDE_TEXT_ENABLED.defaultValue.contains(false))
+    
assert(CometConf.COMET_CONVERT_FROM_TEXT_ENABLED.defaultValue.contains(false))
+    withTempDir { dir =>
+      val factPath = s"${dir.getAbsolutePath}/fact.parquet"
+      val dimFile = new File(dir, "dim.txt")
+      withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+        spark
+          .range(0, 20)
+          .selectExpr("CAST(id % 2 + 1 AS STRING) AS k", "id AS v")
+          .write
+          .parquet(factPath)
+      }
+      Files.write(
+        dimFile.toPath,
+        "1 caf".getBytes(UTF_8) ++ Array(0xe9.toByte) ++ "\n2 
na".getBytes(UTF_8) ++
+          Array(0xef.toByte) ++ "ve\n".getBytes(UTF_8))
+      val query =
+        """SELECT /*+ BROADCAST(d) */ f.v, hex(d.value) AS h, length(d.value) 
AS n
+          |FROM fact_7118 f JOIN dim_7118 d ON f.k = substring(d.value, 1, 
1)""".stripMargin
+      def hexAndLength(df: DataFrame): Set[Row] = df.select("h", 
"n").collect().toSet
+      def bridged(df: DataFrame): Boolean =
+        collect(df.queryExecution.executedPlan) { case s: 
CometSparkToColumnarExec => s }.nonEmpty
+      for {
+        aqe <- Seq("false", "true")
+        // V1 FileSourceScanExec, then V2 BatchScanExec (TextScan).
+        v1List <- Seq("parquet,text", "parquet")
+      } {
+        withSQLConf(
+          SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe,
+          SQLConf.USE_V1_SOURCE_LIST.key -> v1List) {
+          spark.read.parquet(factPath).createOrReplaceTempView("fact_7118")
+          
spark.read.text(dimFile.getAbsolutePath).createOrReplaceTempView("dim_7118")
+          val ctx = s"aqe=$aqe v1=$v1List"
+
+          
withSQLConf(CometConf.COMET_CONVERT_BROADCAST_BUILD_SIDE_TEXT_ENABLED.key -> 
"false") {
+            checkSparkAnswerAndFallbackReason(
+              sql(query),
+              "Comet converts a Text scan on a broadcast build side to Arrow 
only when")
+            val df = sql(query)
+            assert(hexAndLength(df) == Set(Row("3120636166E9", 6), 
Row("32206E61EF7665", 5)), ctx)
+            assert(!bridged(df), s"$ctx: the Text build side must stay on 
Spark")
+          }
+
+          // With the opt-in the bridge converts the Text build side, and each 
ill-formed byte comes
+          // back as U+FFFD (EF BF BD), which changes `hex` and `length`. The 
compatibility guide
+          // quotes these values.
+          val df = sql(query)
+          assert(
+            hexAndLength(df) == Set(Row("3120636166EFBFBD", 6), 
Row("32206E61EFBFBD7665", 7)),
+            ctx)
+          assert(bridged(df), s"$ctx: expected the Text build side to be 
bridged")
+
+          // spark.comet.convert.text.enabled converts every Text scan, the 
build side included.
+          withSQLConf(
+            CometConf.COMET_CONVERT_BROADCAST_BUILD_SIDE_TEXT_ENABLED.key -> 
"false",
+            CometConf.COMET_CONVERT_FROM_TEXT_ENABLED.key -> "true") {
+            val df = sql(query)
+            assert(hexAndLength(df).size == 2, ctx)
+            assert(bridged(df), s"$ctx: expected 
spark.comet.convert.text.enabled to convert")
+          }
+        }
+      }
+    }
+  }
+
+  test("broadcast build side bridge is gated on broadcast-exchange conversion 
(#6008)") {
+    // The bridge is pointless when Comet broadcast exchange conversion is 
off: the broadcast (and
+    // so the join) cannot go native. With 
COMET_EXEC_BROADCAST_EXCHANGE_ENABLED=false the text
+    // build side must not be bridged even though broadcastBuildSide is on.
+    withTempDir { dir =>
+      val probePath = s"${dir.getAbsolutePath}/probe.parquet"
+      val allowPath = s"${dir.getAbsolutePath}/allow.txt"
+      withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+        spark
+          .range(0, 100)
+          .selectExpr("CAST(id AS STRING) AS key", "id AS v")
+          .write
+          .parquet(probePath)
+        spark.range(0, 5).selectExpr("CAST(id AS STRING) AS 
value").write.text(allowPath)
+      }
+      withSQLConf(
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+        CometConf.COMET_CONVERT_BROADCAST_BUILD_SIDE_ENABLED.key -> "true",
+        CometConf.COMET_EXEC_BROADCAST_EXCHANGE_ENABLED.key -> "false") {
+        
spark.read.parquet(probePath).createOrReplaceTempView("probe_gate_6008")
+        spark.read.text(allowPath).createOrReplaceTempView("allow_gate_6008")
+        val (_, cometPlan) = checkSparkAnswer(sql("""
+            |SELECT /*+ BROADCAST(a) */ p.v
+            |FROM probe_gate_6008 p JOIN allow_gate_6008 a ON p.key = a.value
+            |""".stripMargin))
+        assert(
+          collect(cometPlan) { case s: CometSparkToColumnarExec => s }.isEmpty,
+          "with broadcast-exchange conversion off the build side must not be 
bridged:\n" +
+            cometPlan.treeString)
+      }
+    }
+  }
+
+  test("broadcast build side scan below an aggregate is bridged, shuffle stage 
is not (#6008)") {
+    // A file scan can sit BELOW build-side operators. Broadcasting an 
aggregated Text table puts a
+    // Text FileSourceScanExec under a partial aggregate + shuffle on the 
build side. The tag
+    // pre-pass walks the whole broadcast subtree, so the deep text scan must 
be bridged to Arrow,
+    // yet no query stage (e.g. the build-side shuffle under AQE) may be 
wrapped in the bridge.
+    withTempDir { dir =>
+      val allowPath = s"${dir.getAbsolutePath}/allow.txt"
+      val probePath = s"${dir.getAbsolutePath}/probe.parquet"
+      withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+        spark.range(0, 20).selectExpr("CAST(id % 5 AS STRING) AS 
value").write.text(allowPath)
+        spark
+          .range(0, 100)
+          .selectExpr("CAST(id % 5 AS STRING) AS key", "id AS v")
+          .write
+          .parquet(probePath)
+      }
+      spark.read.text(allowPath).createOrReplaceTempView("allow_agg_6008")
+      spark.read.parquet(probePath).createOrReplaceTempView("probe_agg_6008")
+      val query = """
+          |SELECT /*+ BROADCAST(d) */ p.v, d.n
+          |FROM probe_agg_6008 p
+          |JOIN (SELECT value AS k, COUNT(*) AS n FROM allow_agg_6008 GROUP BY 
value) d
+          |  ON p.key = d.k
+          |""".stripMargin
+      for (aqe <- Seq(false, true)) {
+        withSQLConf(
+          SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+          SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> (10 * 1024 * 
1024).toString) {
+          // The plan is fully native (the bridged text scan under the 
aggregate is a native leaf),
+          // so checkSparkAnswerAndOperator also pins that the text scan did 
get bridged - an
+          // un-bridged Spark scan would read as non-native and fail here. It 
is also strict-testing
+          // safe, unlike checkSparkAnswer on a fully-native plan.
+          val (_, cometPlan) = checkSparkAnswerAndOperator(sql(query))
+          // `collect` descends AQE query stages, so the bridge on the deep 
text scan is reachable
+          // under AQE too (it lives in the build-side shuffle stage). Assert 
it in both modes - and
+          // because checkSparkAnswerAndOperator only inspects the initial 
plan under AQE, this is
+          // the only thing that checks the final AQE plan.
+          assert(
+            collect(cometPlan) { case s: CometSparkToColumnarExec => s 
}.nonEmpty,
+            s"aqe=$aqe: expected the build-side text scan to be 
bridged:\n${cometPlan.treeString}")
+          // A query stage (the build-side shuffle) must never be wrapped in 
the bridge.
+          collect(cometPlan) { case s: CometSparkToColumnarExec => s.child 
}.foreach { child =>
+            assert(
+              !child.isInstanceOf[QueryStageExec],

Review Comment:
   [P2] Remove the stale `QueryStageExec` import from `CometExecSuite.scala:42` 
when moving this assertion. This relocation removes its final use in that 
suite, but leaves the import behind. Running the standard Spark 3.5 
strict-warning build now fails during test compilation with `Unused import`, 
and all six Java-lint jobs fail Scalafix on the same import. Removing it from 
the original suite restores the build checks while retaining this assertion.
   
   Evidence: Exact-head CI reproduced the failure with `./mvnw -B test-compile 
-Pspark-3.5,delta -Pstrict-warnings -DskipTests`: `CometExecSuite.scala:42: 
Unused import`, followed by `one error found` and `BUILD FAILURE`. Strict 
compilation log: 
https://github.com/apache/datafusion-comet/actions/runs/39414444629/job/118601923006.
 The Spark 4.1 lint log explicitly proposes removing only `QueryStageExec` from 
that import: 
https://github.com/apache/datafusion-comet/actions/runs/39414444629/job/118601923427.
 All six lint-job logs show the same fix.



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