andygrove commented on code in PR #6940:
URL: https://github.com/apache/datafusion-comet/pull/6940#discussion_r4314196211
##########
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:
I am keeping the scope. Non-magic S3A writes stay on Spark until streaming a
file through S3A has been benchmarked against uploading it natively (#6944), so
the reason is scope, not correctness, and your point that the old wording no
longer describes it is right. The doc comment on `unsupportedFileSystem` and
the last test of `ParquetWriteWithMagicCommitterSuite` now say that.
(65e2bfdfe1)
##########
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:
Dropped. Spark's job configuration always has a name from
`core-default.xml`, and `S3ACommitterFactory` reads the job's first, so a
bucket's own name never decides it. The doc comment of `usesS3AMagicCommitter`
says so. (65e2bfdfe1)
##########
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:
Replaced. The `Configuration(false)` bucket assertions are gone with the
fallback. A `new Configuration()` case now asserts that the default name is
`file`, that a bucket's `magic` does not win over it, and that a global `magic`
name does, including against a bucket's `file`. (65e2bfdfe1)
##########
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:
Done. The full explanation is now in the `HadoopFileOutput` class doc, and
the other places refer to it: both exec scaladocs, `writeFile`, the
`NativeWriteUtils` docs, `parquet_writer.rs`, and the test comments.
`operators.md` keeps only the user-facing sentence. The proto comment now says
that `CometWriteFilesExec.writeFile` sets the flag, and that both write
operators call it. (65e2bfdfe1)
--
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]