comphead commented on code in PR #7155:
URL: https://github.com/apache/datafusion-comet/pull/7155#discussion_r4486201716


##########
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:
   As far as I can tell, these two declines only decide which of two errors a 
failing write reports, and I could not find a case where committed data 
differs. Native input with `fixed(N)` already stays eligible and reports 
Arrow's cast error where iceberg-java reports Parquet's. If error identity is 
part of the contract here, could the description say so? Otherwise dropping 
both would remove the reflection chain in `isRowInputEvaluationSupported` 
(which repeats lines 1122 to 1174), `hasMaterializedRowInput` and two of the 
longest tests.



##########
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:
   `isRowSchemaSupported` admits every array and map by going around the 
`isTypeSupported` override below, which #6726 removes for every caller. Could 
this build on #6726, or add reads of the deeper shapes it now admits (struct 
elements, nested arrays, null elements)? The only end-to-end coverage is 
`ARRAY<INT>` and `MAP<STRING, INT>`.



##########
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:
   This pins the Parquet internal frame 
`FixedLenByteArrayPlainValuesWriter.writeBytes` and relies on 
`write.parquet.dict-size-bytes=1`, so a parquet-mr refactor in one of the 
tested Iceberg versions would fail it without any Comet change. Could it assert 
on the error message instead?



##########
spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala:
##########
@@ -600,7 +601,30 @@ case class CometExecRule(session: SparkSession, 
queryStagePrep: Boolean = false)
       // A shuffle directly under the native write stays in the child stage 
and is not wrapped
       // again.
       case op: IcebergWriteExec if 
CometConf.COMET_ICEBERG_NATIVE_WRITE_ENABLED.get(op.conf) =>
-        convertToComet(op, CometIcebergNativeWrite).getOrElse(op)
+        // Spark inserts AssertNotNull projections for nullable input to 
required Iceberg fields.
+        // Keep their row-wise evaluation in Spark, including any sort or 
limit above them, and
+        // convert the writer's input to Arrow only after Spark has evaluated 
those operators.
+        val needsRowConversion = !op.child.isInstanceOf[CometNativeExec] &&
+          op.child.exists {
+            case project: ProjectExec =>
+              
project.projectList.exists(_.exists(_.isInstanceOf[AssertNotNull]))
+            case _ => false
+          }
+        val rowConversionReasons = ListBuffer.empty[String]
+        val prepared =
+          if (needsRowConversion &&
+            CometSparkToColumnarExec.isRowConversionSupported(op.child, 
rowConversionReasons) &&
+            CometIcebergNativeWrite.isRowInputEvaluationSupported(op, 
rowConversionReasons)) {
+            val rows = ApplyColumnarRulesAndInsertTransitions(Seq.empty, 
outputsColumnar = false)
+              .apply(op.child)
+            convertToComet(rows, CometSparkToColumnarExec)
+              .map(child => op.withNewChildren(Seq(child)))
+              .getOrElse(op)
+          } else {
+            rowConversionReasons.foreach(reason => withFallbackReason(op, 
reason))
+            op
+          }
+        convertToComet(prepared, CometIcebergNativeWrite).getOrElse(op)

Review Comment:
   `prepared` is a copy of `op`, so a fallback reason that 
`convertToComet(prepared, CometIcebergNativeWrite)` records is tagged on 
`prepared` and dropped when `getOrElse(op)` returns the original node. Before 
this change those reasons landed on `op`. I expect `EXPLAIN` for a declined 
write with a required-field projection to show no Comet reason, but I have not 
run it. Could this copy the reasons back as the broadcast arm below does, or 
call `CometIcebergNativeWrite.getSupportLevel(op)` before building the bridge, 
which would also skip the bridge for ineligible writes?



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