LinSimon-901101 commented on code in PR #7155:
URL: https://github.com/apache/datafusion-comet/pull/7155#discussion_r4511117198


##########
spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeWrite.scala:
##########
@@ -297,6 +299,64 @@ object CometIcebergNativeWrite extends 
CometOperatorSerde[IcebergWriteExec] {
         Unsupported(Some(s"Iceberg native write detection failed: 
${e.getMessage}"))
     }
 
+  /**
+   * Row-to-Arrow conversion reads ahead before the writer consumes the batch. 
Keep fixed fields
+   * on the JVM because Spark's BinaryType does not enforce their length. For 
a clustered writer,
+   * materialize the writer partition so a later Spark expression failure 
cannot overtake an
+   * earlier partition-order error.
+   */
+  def isRowInputEvaluationSupported(
+      op: IcebergWriteExec,
+      fallbackReasons: ListBuffer[String]): Boolean = {
+    val rowInputConstraints =
+      try {
+        for {
+          sparkWrite <- IcebergReflection.getOuterSparkWrite(op.batchWrite)
+          table <- IcebergReflection.getTableFromSparkWrite(sparkWrite)
+          specId <- IcebergReflection.getOutputSpecIdFromSparkWrite(sparkWrite)
+          spec <- IcebergReflection.getPartitionSpecById(table, specId)
+          fanout <- 
IcebergReflection.getUseFanoutWriterFromSparkWrite(sparkWrite)
+          writeSchema <- 
IcebergReflection.getWriteSchemaFromSparkWrite(sparkWrite)
+        } yield {
+          val writerMode = IcebergWriteProtoTranslation.resolveWriterMode(
+            specIsUnpartitioned = isUnpartitionedSpec(spec),
+            useFanoutWriter = fanout)
+          val hasFixedFields =
+            IcebergReflection.findFieldWithTypeIds(writeSchema, 
Set("FIXED")).nonEmpty
+          (writerMode, hasFixedFields)
+        }
+      } catch {
+        case NonFatal(_) => None
+      }
+
+    rowInputConstraints match {
+      case None =>
+        fallbackReasons +=
+          "Iceberg writer row conversion could not resolve the effective 
writer constraints"
+        false
+      case Some((_, true)) =>
+        fallbackReasons += "Iceberg writer row conversion keeps fixed fields 
on the JVM to " +
+          "preserve fixed-width validation failures"
+        false
+      case Some((IcebergWriterMode.ICEBERG_WRITER_CLUSTERED, false))
+          if !hasMaterializedRowInput(op.child) =>
+        fallbackReasons += "Iceberg clustered writer row conversion requires 
materialized Spark " +
+          "input so batch read-ahead cannot change expression and 
partition-order error precedence"
+        false
+      case _ => true

Review Comment:
   I’ve kept both guards and documented error precedence, exception type, and 
message preservation for the new row bridge only. Existing Arrow-native writes 
are outside this guarantee.



##########
spark/src/main/scala/org/apache/spark/sql/comet/CometSparkToColumnarExec.scala:
##########
@@ -132,6 +135,73 @@ case class CometSparkToColumnarExec(child: SparkPlan)
 
 object CometSparkToColumnarExec extends CometSink[SparkPlan] with 
DataTypeSupport {
 
+  private object RowTypeSupport extends DataTypeSupport with CometTypeShim
+
+  // RowArrowReader recursively writes the collection types supported by 
DataTypeSupport.
+  def isRowSchemaSupported(schema: StructType, fallbackReasons: 
ListBuffer[String]): Boolean =
+    RowTypeSupport.isSchemaSupported(schema, fallbackReasons)

Review Comment:
   I’ve added readback tests against the Java writer for nested arrays, struct 
elements, and map values, including nulls and empty collections, using small 
batches with AQE on and off.



##########
spark/src/test/scala/org/apache/comet/CometIcebergWriteActionSuite.scala:
##########
@@ -3980,6 +4544,135 @@ class CometIcebergWriteActionSuite
     }
   }
 
+  for {
+    adaptive <- Seq(false, true)
+    laterNull <- Seq(false, true)
+  } {
+    test(
+      "native Iceberg writer: fixed-length validation preserves the first 
error, " +
+        s"laterNull=$laterNull, AQE=$adaptive") {
+      assume(icebergAvailable && isSpark35Plus)
+      withSQLConf(
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> adaptive.toString,
+        CometConf.COMET_BATCH_SIZE.key -> "8") {
+        withIcebergCatalog { _ =>
+          withTempPath { path =>
+            val schema = StructType(
+              Seq(
+                StructField("id", IntegerType, nullable = true),
+                StructField("fx", BinaryType, nullable = true)))
+            val invalidRows = Seq(Row(1, Array[Byte](1, 2, 3))) ++
+              (if (laterNull) Seq(Row(null, Array[Byte](5, 6, 7, 8))) else 
Seq.empty)
+            val invalidPath = new File(path, "invalid").toString
+            val validPath = new File(path, "valid").toString
+            withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+              spark
+                .createDataFrame(spark.sparkContext.parallelize(invalidRows, 
1), schema)
+                .write
+                .parquet(invalidPath)
+              spark
+                .createDataFrame(
+                  spark.sparkContext.parallelize(
+                    Seq(Row(1, Array[Byte](1, 2, 3, 4)), Row(2, Array[Byte](5, 
6, 7, 8))),
+                    1),
+                  schema)
+                .write
+                .parquet(validPath)
+              val expectedInput = Seq(Row(1, "010203")) ++
+                (if (laterNull) Seq(Row(null, "05060708")) else Seq.empty)
+              assert(
+                spark.read.parquet(invalidPath).selectExpr("id", 
"hex(fx)").collect().toSeq ==
+                  expectedInput)
+            }
+            val suffix = s"${laterNull}_$adaptive"
+            val candidate = s"required_fixed_native_$suffix"
+            val reference = s"required_fixed_jvm_$suffix"
+            val nativeControl = s"required_fixed_control_native_$suffix"
+            val jvmControl = s"required_fixed_control_jvm_$suffix"
+            Seq(candidate, reference, nativeControl, jvmControl).foreach { 
table =>
+              sql(s"""CREATE TABLE $catalog.$ns.$table (id INT NOT NULL) USING 
iceberg
+                |TBLPROPERTIES ('write.distribution-mode'='none',
+                |'write.parquet.dict-size-bytes'='1')""".stripMargin)
+              addIcebergColumn(
+                loadIcebergTable(spark, catalog, ns, table),
+                "fx",
+                icebergFixedType(4))
+              sql(s"REFRESH TABLE $catalog.$ns.$table")
+            }
+            def assertJvmRequiredWrite(plans: Seq[SparkPlan]): Unit = {
+              val writers =
+                plans.flatMap(p => collectWithSubqueries(p) { case w: 
IcebergWriteExec => w })
+              assert(writers.nonEmpty, plans.mkString("\n"))
+              assert(writers.forall(hasRequiredProjection), 
plans.mkString("\n"))
+              assert(
+                plans.forall(p =>
+                  collectWithSubqueries(p) { case w: CometIcebergWriteExec => 
w }.isEmpty),
+                plans.mkString("\n"))
+              plans.foreach(assertColumnarContract)
+            }
+            val valid = spark.read.parquet(validPath)
+            val successful = withNativeEnabled {
+              captureWrite(nativeControl) {
+                valid.writeTo(s"$catalog.$ns.$nativeControl").append()
+              }
+            }
+            assert(successful.snapshotDelta == 1)
+            assertJvmRequiredWrite(successful.plans)
+            assert(
+              successful.plans.forall(p =>
+                collectWithSubqueries(p) {
+                  case s: SortExec => s
+                  case e: ShuffleExchangeLike => e
+                }.isEmpty),
+              successful.plans.mkString("\n"))
+            withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+              valid.writeTo(s"$catalog.$ns.$jvmControl").append()
+              assert(countSnapshots(jvmControl) == 1)
+              Seq(nativeControl, jvmControl).foreach { table =>
+                checkAnswer(
+                  sql(s"SELECT id, hex(fx) FROM $catalog.$ns.$table"),
+                  Seq(Row(1, "01020304"), Row(2, "05060708")))
+              }
+            }
+
+            // A one-byte dictionary limit forces the first fixed(4) value 
through Parquet's
+            // plain writer, which rejects its three bytes before any later 
null assertion.
+            def appendInvalid(table: String): Unit =
+              
spark.read.parquet(invalidPath).writeTo(s"$catalog.$ns.$table").append()
+            var candidateFailure: Throwable = null
+            val candidatePlans = withNativeEnabled {
+              capturePlans(spark, includeFailures = true) {
+                candidateFailure = 
intercept[Exception](appendInvalid(candidate))
+              }
+            }
+            var referenceFailure: Throwable = null
+            withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+              referenceFailure = intercept[Exception](appendInvalid(reference))
+            }
+            assertJvmRequiredWrite(candidatePlans)
+            Seq(candidate, reference).foreach { table =>
+              assert(countSnapshots(table) == 0)
+              assert(parquetFiles(dataDir(table)).isEmpty)
+              checkAnswer(sql(s"SELECT * FROM $catalog.$ns.$table"), 
Seq.empty[Row])
+            }
+            val expected = causeChain(referenceFailure).last
+            assert(expected.isInstanceOf[IllegalArgumentException], 
expected.toString)
+            assert(
+              expected.getStackTrace.exists(frame =>
+                
frame.getClassName.endsWith(".FixedLenByteArrayPlainValuesWriter") &&
+                  frame.getMethodName == "writeBytes"),
+              expected.toString)

Review Comment:
   I’ve replaced the stack-frame assertion with exception type and message 
checks against the Java writer. The dictionary setting remains only to 
reproduce the failure.



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