viirya commented on code in PR #6940:
URL: https://github.com/apache/datafusion-comet/pull/6940#discussion_r4304262258
##########
spark/src/main/scala/org/apache/comet/serde/operator/NativeWriteUtils.scala:
##########
@@ -187,6 +189,119 @@ object NativeWriteUtils {
"spark.comet.write.parquet.enabled=false to write this table with
Spark.")
}
+ /**
+ * Whether the native writer creates the file at `filePath` itself, which it
does for local and
+ * HDFS files. Any other file is created through Hadoop's FileSystem API, as
Spark's own writer
+ * creates it, and the native writer only writes its bytes. That is always
correct, whatever the
+ * file system and whatever the committer expects of it, which is why this
is decided per task
+ * on the path the commit protocol actually returned rather than at planning.
+ */
+ def nativeWriterCreatesFile(filePath: String): Boolean =
+ filePath.startsWith("file:") || filePath.startsWith("hdfs:")
+
+ /**
+ * A fallback reason when the native writer cannot write to `outputPath`'s
file system, or
+ * `None` when it can. Local and HDFS files are created natively. A write
that S3A's magic
+ * committer commits is supported too, since each of its files is created
through S3A (see
+ * `CometWriteFilesExec.writeFile`), which is the only way to give the
committer the pending
+ * upload it completes. Other S3 writes stay on Spark until the native
writer reproduces what
+ * S3A does at create time.
+ *
+ * @param hadoopConf
+ * the write's Hadoop configuration, only built for a destination the
native writer does not
+ * create files in
+ */
+ def unsupportedFileSystem(
+ outputPath: String,
+ commitProtocolClass: String,
+ hadoopConf: => Configuration): Option[String] =
+ if (nativeWriterCreatesFile(outputPath) ||
+ usesS3AMagicCommitter(outputPath, commitProtocolClass, hadoopConf)) {
+ None
+ } else {
+ Some("Supported output filesystems: local, HDFS, and S3 with the S3A
magic committer")
+ }
+
+ /** Spark's commit protocol for Hadoop's `PathOutputCommitter`s, in
spark-hadoop-cloud. */
+ val PathOutputCommitProtocolClass: String =
+ "org.apache.spark.internal.io.cloud.PathOutputCommitProtocol"
+
+ /**
+ * Spark's Parquet output committer that delegates to a
`PathOutputCommitter`, in
+ * spark-hadoop-cloud.
+ */
+ val BindingParquetOutputCommitterClass: String =
+ "org.apache.spark.internal.io.cloud.BindingParquetOutputCommitter"
+
+ /** S3A's committer factory that always creates the magic committer. */
+ private val MagicCommitterFactoryClass =
+ "org.apache.hadoop.fs.s3a.commit.magic.MagicS3GuardCommitterFactory"
+
+ /** S3A's committer factory that creates the committer
`fs.s3a.committer.name` names. */
+ private val S3ACommitterFactoryClass =
"org.apache.hadoop.fs.s3a.commit.S3ACommitterFactory"
+
+ /**
+ * Whether S3A's magic committer commits a write to `outputPath`.
+ *
+ * Spark reaches Hadoop's committer factories through one of two bindings in
spark-hadoop-cloud.
+ * Spark 4.1 configures both whenever that module is on the classpath
(SPARK-47618), and earlier
+ * versions for a bucket that sets
`fs.s3a.bucket.<bucket>.committer.magic.enabled`:
+ *
+ * - the commit protocol `PathOutputCommitProtocol`, or
+ * - `BindingParquetOutputCommitter` as
`spark.sql.parquet.output.committer.class`, under a
+ * commit protocol that instantiates that class, as Spark's default one
does.
+ *
+ * Either asks Hadoop's `PathOutputCommitterFactory` for the committer. Its
factory is
+ * `mapreduce.outputcommitter.factory.class` when set, else
+ * `mapreduce.outputcommitter.factory.scheme.<scheme>`. The committer is the
magic committer
+ * when that factory is `MagicS3GuardCommitterFactory`, or when it is
`S3ACommitterFactory` and
+ * the committer it is asked for is `magic`: `fs.s3a.committer.name` in the
job's configuration,
+ * or failing that in the file system's, where
`fs.s3a.bucket.<bucket>.committer.name` replaces
+ * it.
+ *
+ * Only planning asks, to decide whether a write is worth running natively.
A wrong answer
+ * cannot misplace data: each task writes the path the committer actually
returns, and creates
+ * any file that is not local or on HDFS through Hadoop, see
`CometWriteFilesExec.writeFile`.
+ *
+ * @param commitProtocolClass
+ * the write's `spark.sql.sources.commitProtocolClass`
+ * @param hadoopConf
+ * the write's Hadoop configuration, which carries
`spark.sql.parquet.output.committer.class`
+ * as Spark's Parquet writer reads it
+ */
+ def usesS3AMagicCommitter(
+ outputPath: String,
+ commitProtocolClass: String,
+ hadoopConf: Configuration): Boolean = {
+ val outputCommitterClass =
+ hadoopConf.getTrimmed(SQLConf.PARQUET_OUTPUT_COMMITTER_CLASS.key, "")
+ val asksCommitterFactory = commitProtocolClass ==
PathOutputCommitProtocolClass ||
+ (commitProtocolClass ==
SQLConf.FILE_COMMIT_PROTOCOL_CLASS.defaultValueString &&
+ outputCommitterClass == BindingParquetOutputCommitterClass)
+ val uri = new Path(outputPath).toUri
+ if (!asksCommitterFactory || uri.getScheme == null) {
+ return false
+ }
+
+ val factory = Seq(
+ PathOutputCommitterFactory.COMMITTER_FACTORY_CLASS,
+
PathOutputCommitterFactory.COMMITTER_FACTORY_SCHEME_PATTERN.format(uri.getScheme))
+ .map(key => hadoopConf.getTrimmed(key, ""))
+ .find(_.nonEmpty)
+ factory match {
+ case Some(MagicCommitterFactoryClass) => true
+ case Some(S3ACommitterFactoryClass) =>
+ // S3ACommitterFactory takes the name from the job's configuration
when it has one, and
Review Comment:
`core-default.xml` sets `fs.s3a.committer.name=file` in both 3.3.4 and
3.4.1, so with a defaults-loaded configuration this lookup always finds a
job-level name, and the bucket fallback never applies. That matches
`S3ACommitterFactory`, which also reads the task conf first, so it isn't wrong.
But Spark never reaches this branch. Could it be dropped?
##########
spark/src/main/scala/org/apache/comet/serde/operator/NativeWriteUtils.scala:
##########
@@ -187,6 +189,119 @@ object NativeWriteUtils {
"spark.comet.write.parquet.enabled=false to write this table with
Spark.")
}
+ /**
+ * Whether the native writer creates the file at `filePath` itself, which it
does for local and
+ * HDFS files. Any other file is created through Hadoop's FileSystem API, as
Spark's own writer
+ * creates it, and the native writer only writes its bytes. That is always
correct, whatever the
+ * file system and whatever the committer expects of it, which is why this
is decided per task
+ * on the path the commit protocol actually returned rather than at planning.
+ */
+ def nativeWriterCreatesFile(filePath: String): Boolean =
+ filePath.startsWith("file:") || filePath.startsWith("hdfs:")
+
+ /**
+ * A fallback reason when the native writer cannot write to `outputPath`'s
file system, or
+ * `None` when it can. Local and HDFS files are created natively. A write
that S3A's magic
+ * committer commits is supported too, since each of its files is created
through S3A (see
+ * `CometWriteFilesExec.writeFile`), which is the only way to give the
committer the pending
+ * upload it completes. Other S3 writes stay on Spark until the native
writer reproduces what
Review Comment:
The per-task routing makes every S3A-served write correct. Staging
committers hand out local `file:` paths, which the native writer creates
itself, and the `file` committer's `_temporary` paths are created through
Hadoop. So this gate now only decides scope, and "until the native writer
reproduces what S3A does at create time" no longer describes why other S3
writes fall back. The same reason appears in the last test of
`ParquetWriteWithMagicCommitterSuite`.
Since #6355 hasn't landed, the alternative for a non-magic S3A write today
is Spark's writer, not `object_store`. Would admitting every S3A destination be
the simpler choice? It would drop `usesS3AMagicCommitter` and its test, which
mirror Hadoop's committer resolution and could drift as Hadoop changes. If
you'd rather keep the scope, could the comments here and in the suite say the
real reason (benchmarks against #6355)?
##########
spark/src/test/scala/org/apache/comet/parquet/CometParquetWriterSuite.scala:
##########
@@ -1997,6 +2002,177 @@ class CometParquetWriterSuite extends
CometParquetWriterTestBase {
}
}
+ /**
+ * Runs `write` with a source of `rows` rows in `partitions` partitions,
read natively, 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.
+ */
+ private def withRecordingFileSystem(rows: Int, partitions: Int)(
+ write: (DataFrame, String) => Unit): Unit =
+ withTempPath { dir =>
+ val outputPath = new File(dir, "output.parquet").getAbsolutePath
+ val sourcePath = new File(dir, "source.parquet").getAbsolutePath
+ withNativeWriter {
+ val df = materializeAsCometSource(
+ (1 to rows).map(i => (i, s"str_$i")).toDF("id",
"name").repartition(partitions),
+ sourcePath)
+ RecordingFileSystem.reset()
+ try {
+ withSQLConf(
+ SQLConf.FILE_COMMIT_PROTOCOL_CLASS.key ->
+ classOf[RecordingFileSystemCommitProtocol].getName,
+ s"fs.${RecordingFileSystem.SCHEME}.impl" ->
classOf[RecordingFileSystem].getName) {
+ write(df, outputPath)
+ }
+ } finally {
+ RecordingFileSystem.reset()
+ }
+ }
+ }
+
+ 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,
such as the one
+ // S3A's magic committer hands out under its `__magic` directory, has to
be created through
+ // Hadoop's FileSystem API so the FileSystem can do whatever it does at
create time, 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) =>
+ val plan = captureWritePlan(p => df.write.parquet(p), outputPath)
+ assertHasCometNativeWriteExec(plan)
+
+ val created = RecordingFileSystem.created.asScala.toSeq
+ assert(created.size == 3, s"Expected one file per task, got $created")
+ assert(created.forall(_.startsWith(RecordingFileSystem.PREFIX)), created)
+ // Closed, which is where a FileSystem such as S3A completes or records
the upload, and
+ // only once the native writer had written all of it.
+ assert(RecordingFileSystem.closed.asScala.toSet == created.toSet)
+ assert(RecordingFileSystem.aborted.isEmpty)
+
+ checkAnswer(spark.read.parquet(outputPath), df)
+ assert(listPartFileNames(outputPath).size == 3)
+ assert(new File(outputPath, "_SUCCESS").exists())
+ }
+ }
+
+ test("a failed write through Hadoop aborts the file and fails with what its
stream threw") {
+ // A file the JVM created is discarded when the native write fails:
aborted, so that S3A
+ // abandons the upload rather than completing a truncated file. The native
writer fails the
+ // task with the exception the stream threw, which the JVM rethrows as it
was, as a failure in
+ // Spark's own writer would surface.
+ withRecordingFileSystem(rows = 100, partitions = 1) { (df, outputPath) =>
+ RecordingFileSystem.failWrites = true
+ val (plan, error) = captureFailedWritePlan(df.write.parquet(outputPath))
+ assertHasCometNativeWriteExec(plan)
+ assert(
+ causeChain(error).exists {
+ case e: IOException => e.getMessage ==
RecordingFileSystem.WRITE_FAILURE
+ case _ => false
+ },
+ s"Expected the stream's own IOException, got: $error")
+
+ assert(RecordingFileSystem.created.size == 1)
+ assert(RecordingFileSystem.aborted.asScala ==
RecordingFileSystem.created.asScala)
+ assert(RecordingFileSystem.closed.isEmpty)
+ assert(
+ listPartFileNames(outputPath).isEmpty,
+ s"A failed write must not leave part files:
${listPartFileNames(outputPath)}")
+ assert(!new File(outputPath, "_temporary").exists())
+ }
+ }
+
+ test("S3 writes plan natively when S3A's magic committer commits them") {
+ // Planning admits an S3 destination only when the magic committer will
commit it, as
+ // `PathOutputCommitterFactory` and `S3ACommitterFactory` resolve the
committer. Its tasks
+ // then have S3A create each file. Other S3 writes stay on Spark's writer.
+ val pathOutput = NativeWriteUtils.PathOutputCommitProtocolClass
+ val defaultProtocol = SQLConf.FILE_COMMIT_PROTOCOL_CLASS.defaultValueString
+ val bindingCommitter =
+ SQLConf.PARQUET_OUTPUT_COMMITTER_CLASS.key ->
NativeWriteUtils.BindingParquetOutputCommitterClass
+ val s3aFactory = S3ACommitterFactory.CLASSNAME
+ val magicFactory = MagicS3GuardCommitterFactory.CLASSNAME
+ // Main code names spark-hadoop-cloud's classes rather than linking them.
+ assert(pathOutput == classOf[PathOutputCommitProtocol].getName)
+ assert(
+ NativeWriteUtils.BindingParquetOutputCommitterClass ==
+ classOf[BindingParquetOutputCommitter].getName)
+ val s3aSchemeFactory = "mapreduce.outputcommitter.factory.scheme.s3a" ->
s3aFactory
+ val magicName = "fs.s3a.committer.name" -> "magic"
+
+ def usesMagic(protocol: String, settings: (String, String)*)(
+ path: String = "s3a://bucket/warehouse/table"): Boolean = {
+ val conf = new Configuration(false)
+ settings.foreach { case (key, value) => conf.set(key, value) }
+ NativeWriteUtils.usesS3AMagicCommitter(path, protocol, conf)
+ }
+
+ // What Spark 4.1 configures when spark-hadoop-cloud is on the classpath
(SPARK-47618).
+ assert(usesMagic(pathOutput, s3aSchemeFactory, magicName,
bindingCommitter)())
+ // Either binding asks the committer factory on its own.
+ assert(usesMagic(pathOutput, s3aSchemeFactory, magicName)())
+ assert(usesMagic(defaultProtocol, s3aSchemeFactory, magicName,
bindingCommitter)())
+ // Without one, the committer is Spark's own FileOutputCommitter, whatever
S3A's settings.
+ assert(!usesMagic(defaultProtocol, s3aSchemeFactory, magicName)())
+ // A protocol Comet does not know may never instantiate the binding
committer.
+ assert(
+ !usesMagic("com.example.CommitProtocol", s3aSchemeFactory, magicName,
bindingCommitter)())
+
+ // The global factory key wins over the destination scheme's, in either
direction.
+ val globalFactory = "mapreduce.outputcommitter.factory.class"
+ assert(usesMagic(pathOutput, globalFactory -> magicFactory)())
+ assert(
+ !usesMagic(
+ pathOutput,
+ globalFactory ->
"org.apache.hadoop.mapreduce.lib.output.FileOutputCommitterFactory",
+ s3aSchemeFactory,
+ magicName)())
+ // The scheme key is the destination's own, for any scheme S3A serves.
+ assert(!usesMagic(pathOutput, s3aSchemeFactory,
magicName)("s3://bucket/warehouse/table"))
+ assert(
+ usesMagic(
+ pathOutput,
+ "mapreduce.outputcommitter.factory.scheme.s3" -> s3aFactory,
+ magicName)("s3://bucket/warehouse/table"))
+ // No factory means FileOutputCommitterFactory.
+ assert(!usesMagic(pathOutput, magicName)())
+
+ // MagicS3GuardCommitterFactory creates the magic committer whatever the
name says, while
+ // S3ACommitterFactory creates the one named, matched as exactly as S3A
matches it.
+ assert(
+ usesMagic(pathOutput, "mapreduce.outputcommitter.factory.scheme.s3a" ->
magicFactory)())
+ Seq("file", "directory", "partitioned", "staging", "Magic", "").foreach {
name =>
+ assert(
+ !usesMagic(pathOutput, s3aSchemeFactory, "fs.s3a.committer.name" ->
name)(),
+ s"committer name '$name'")
+ }
+ // A bucket's committer name applies when the job sets none of its own,
and a global name in
Review Comment:
These cases build `new Configuration(false)`, so "a bucket's committer name
applies when the job sets none" describes a setup Spark never has. Spark's
Hadoop conf carries `fs.s3a.committer.name=file` from `core-default.xml` and,
once `JobConf` has loaded, the `s3a` scheme factory from `mapred-default.xml`.
Could you add a case with `new Configuration()` that pins the behavior Spark
actually sees, or drop these assertions together with the fallback?
##########
native/proto/src/proto/operator.proto:
##########
@@ -1016,6 +1016,13 @@ message ParquetWriter {
// Catalyst's target schema, including top-level nullability and Parquet
field IDs.
// Nested collection field IDs are carried by the corresponding DataType
messages.
repeated SparkStructField output_schema = 9;
+ // Whether the JVM has already created `output_path` through Hadoop's
FileSystem API and
+ // registered its output stream with the plan (Native.setOutputStream). The
native writer then
+ // writes the file's bytes to that stream instead of opening the path
itself, and the JVM closes
+ // the file afterwards. Set per task by CometWriteFilesExec and
CometNativeWriteExec for every
Review Comment:
Only `CometWriteFilesExec.writeFile` sets this flag. `CometNativeWriteExec`
reaches it through that helper. More generally, the explanation of why the
magic committer needs S3A to create the file now appears in about eight places:
here, `HadoopFileOutput`, both exec scaladocs, `writeFile`, `NativeWriteUtils`,
`parquet_writer.rs` and `operators.md`. Could it live once, in
`HadoopFileOutput` or `writeFile`, with the other places referring to it? This
sentence shows the copies are already drifting.
--
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]