parthchandra commented on code in PR #6940:
URL: https://github.com/apache/datafusion-comet/pull/6940#discussion_r4392274964


##########
spark/src/main/scala/org/apache/spark/sql/comet/CometWriteFilesExec.scala:
##########
@@ -280,6 +299,29 @@ object CometWriteFilesExec extends Logging {
     })
   }
 
+  /**
+   * Runs `f`, failing the task the way Spark's writer does when `f` fails, as
+   * `FileFormatDataWriter.enrichWriteError` does on Spark 4.0+ and 
`FileFormatWriter.executeTask`
+   * on 3.x: a `FetchFailedException` as it was, so that Spark reruns the 
stage that produced the
+   * lost shuffle output, a `FileAlreadyExistsException` as a
+   * `TaskOutputFileAlreadyExistException` when 
`spark.sql.execution.fastFailOnFileFormatOutput`
+   * is set, which fails the job without retrying the task, and anything else 
as
+   * `TASK_WRITE_FAILED` for `path`.
+   *
+   * Spark's writer creates a task's file before this applies. So does Comet 
for a file it creates
+   * through Hadoop, but the native writer creates local and HDFS files during 
the write, so a
+   * failure to create one of those is enriched here and not in Spark.
+   */
+  private[comet] def enrichWriteError[T](path: => String)(f: => T): T =
+    try {
+      f
+    } catch {
+      case e: FetchFailedException => throw e
+      case e: FileAlreadyExistsException if 
SQLConf.get.fastFailFileFormatOutput =>

Review Comment:
   This fast-fail branch turns a `FileAlreadyExistsException` into 
`TaskOutputFileAlreadyExistException` when 
`spark.sql.execution.fastFailOnFileFormatOutput` is set, but nothing tests it. 
`RecordingFileSystem` already creates files with `overwrite=false`, so a test 
that pre-creates the target path and sets that conf would cover this branch. 
Right now `TaskOutputFileAlreadyExistException` is only imported, never 
asserted.



##########
spark/src/test/scala/org/apache/comet/parquet/CometParquetWriterSuite.scala:
##########
@@ -1997,6 +2003,259 @@ class CometParquetWriterSuite extends 
CometParquetWriterTestBase {
     }
   }
 
+  test("a task file the native writer cannot create is created through 
Hadoop") {
+    // The native writer creates only local and HDFS files. Every other file 
is created through
+    // Hadoop's FileSystem API (see HadoopFileOutput) and the native writer 
only supplies the
+    // bytes. RecordingFileSystemCommitProtocol puts task files on a scheme 
the native writer
+    // cannot open at all, so the write can only succeed that way.
+    withRecordingFileSystem(rows = 1000, partitions = 3) { (df, outputPath) =>

Review Comment:
   Two things on these RecordingFileSystem tests:
   
   1. They check the file reads back equal to the input DataFrame, which is a 
round-trip check, not a comparison against Spark's own writer. For at least one 
case it would be good to assert the output matches Spark (either 
`checkSparkAnswer`, or byte-identical to Spark's Parquet writer), so we catch a 
case where Comet and Spark write the same data differently.
   
   2. They all go through `df.write.parquet`, which is the V1 file-source path. 
The DataSource V2 write path isn't exercised through the Hadoop output-stream 
route. Worth one V2 write so both planning paths are covered.



##########
spark/src/test/scala/org/apache/comet/parquet/CometParquetWriterTestBase.scala:
##########
@@ -43,7 +45,58 @@ abstract class CometParquetWriterTestBase extends 
CometTestBase {
       SQLConf.SESSION_LOCAL_TIMEZONE.key -> "America/Halifax")(f)
   }
 
+  // Persist `df` to `sourcePath` with Comet disabled and return a DataFrame 
that reads it back,
+  // so the source plan is a Comet scan (satisfying 
CometExecRule.requiresNativeChildren).
+  protected def materializeAsCometSource(df: DataFrame, sourcePath: String): 
DataFrame = {
+    withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+      df.write.parquet(sourcePath)
+    }
+    spark.read.parquet(sourcePath)
+  }
+
   /** The opt-in config key for native writes, which moved with the operator 
on Spark 4.0+. */
+  /**
+   * Runs `write` with a source of `rows` rows in `partitions` partitions, see 
the overload below.
+   */
+  protected def withRecordingFileSystem(rows: Int, partitions: Int)(
+      write: (DataFrame, String) => Unit): Unit = {
+    val session = spark
+    import session.implicits._
+    withRecordingFileSystem(
+      (1 to rows).map(i => (i, s"str_$i")).toDF("id", 
"name").repartition(partitions))(write)
+  }
+
+  /**
+   * Runs `write` with `source` read natively from a local copy, and the path 
to write it to,
+   * while RecordingFileSystemCommitProtocol puts the write's task files on
+   * [[RecordingFileSystem]], whose recordings start empty and are cleared 
afterwards.
+   *
+   * The output directory's name has a space, `%`, `#`, `?` and a non-ASCII 
character, which a
+   * path has to keep as it passes from the commit protocol to Hadoop.
+   */
+  protected def withRecordingFileSystem(source: => DataFrame)(

Review Comment:
   Every write through this new Hadoop output-stream path uses simple `(id, 
name)` or random-string data. The complex-type and temporal coverage (structs 
with map and array fields, nullable and non-nullable, decimals, dates, 
timestamps, timestamp_ntz, timezones) lives in the local-file tests, so it 
never runs through this path. Could we add at least one complex-typed source 
and one timestamp / timestamp_ntz source here? The path just forwards Arrow 
bytes, so the risk is low, but nothing proves a nested or temporal file 
survives the chunked 1 MiB writes and the abort path.



##########
native/jni-bridge/src/output_stream_sink.rs:
##########
@@ -0,0 +1,141 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::fmt::{self, Debug, Formatter};
+
+use crate::{check_exception, errors::CometError, JVMClasses};
+use datafusion::common::{DataFusionError, Result};
+use jni::objects::{Global, JByteArray, JMethodID, JObject, JValue};
+use jni::signature::{Primitive, ReturnType};
+use jni::Env;
+
+/// Receives the bytes of a file that the JVM created, in order.
+///
+/// The JVM creates the file, closes it once the native writer has written all 
of it, and aborts
+/// it when the write fails, so an implementation only ever sees the file's 
bytes. One writer
+/// writes one file, so calls never overlap.
+pub trait OutputStreamSink: Send + Sync {
+    /// The most bytes one call to [`Self::write`] may carry.
+    fn max_write_size(&self) -> usize;
+
+    /// Appends `data`, at most [`Self::max_write_size`] bytes, to the file.
+    fn write(&self, data: &[u8]) -> Result<()>;
+}
+
+impl Debug for dyn OutputStreamSink {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        f.debug_struct("OutputStreamSink")
+            .field("max_write_size", &self.max_write_size())
+            .finish()
+    }
+}
+
+/// Writes to a JVM `org.apache.comet.parquet.HadoopFileOutput` from any 
native thread.
+///
+/// Each write copies its bytes into a Java array this sink allocates once, 
then hands the array
+/// to `write(byte[], int)`, so a write costs one copy and one upcall but no 
Java allocation. The
+/// global references keep the Java object, its class and the array alive for 
as long as the
+/// cached method ID is used.
+pub struct JavaOutputStreamSink {
+    output: Global<JObject<'static>>,
+    buffer: Global<JByteArray<'static>>,
+    write_method: JMethodID,
+}
+
+impl JavaOutputStreamSink {
+    /// Bytes per upcall, large enough that the upcall costs little next to 
the copy it carries.
+    pub const MAX_WRITE_SIZE: usize = 1024 * 1024;
+
+    /// Captures `output` while running on an attached JVM thread.
+    pub fn try_new(env: &mut Env<'_>, output: &JObject<'_>) -> Result<Self> {
+        if output.is_null() {
+            return Err(DataFusionError::Execution(
+                "The output stream of a native write must not be 
null".to_string(),
+            ));
+        }
+
+        let output_class = 
env.get_object_class(output).map_err(CometError::from)?;
+        let write_method = env
+            .get_method_id(
+                &output_class,
+                jni::jni_str!("write"),
+                jni::jni_sig!("([BI)V"),
+            )
+            .map_err(CometError::from)?;
+        let buffer = env
+            .new_byte_array(Self::MAX_WRITE_SIZE)
+            .map_err(CometError::from)?;
+
+        Ok(Self {
+            output: env.new_global_ref(output).map_err(CometError::from)?,
+            buffer: env.new_global_ref(&buffer).map_err(CometError::from)?,
+            write_method,
+        })
+    }
+}
+
+impl OutputStreamSink for JavaOutputStreamSink {
+    fn max_write_size(&self) -> usize {
+        Self::MAX_WRITE_SIZE
+    }
+
+    fn write(&self, data: &[u8]) -> Result<()> {
+        if data.len() > Self::MAX_WRITE_SIZE {

Review Comment:
   This oversize guard (`data.len() > MAX_WRITE_SIZE`) and the null-output 
check near line 65 are never hit by a test. This file has no unit tests, and 
the Scala integration tests only drive the normal path. A small Rust test for 
each (an oversize write returns the `Internal` error, a null output is 
rejected) would lock in these defensive branches.



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