Conversation
Native Parquet writes previously replaced the whole DataWritingCommandExec, so InsertIntoHadoopFsRelationCommand.run never ran and Comet had to re-implement the write framework itself: a hardcoded SQLHadoopMapReduceCommitProtocol, a hand-ported copy of the SaveMode logic, its own commit-message accumulator, and its own commitJob call. Most of the open native-writer bugs are symptoms of that one decision rather than independent defects. Replace only WriteFilesExec instead. On Spark 4.0+ V1WritesUtils.getWriteFilesOpt matches the WriteFilesExecBase trait, so extending it is what makes Spark route the write through CometWriteFilesExec.doExecuteWrite while keeping ownership of the commit protocol, staging, _SUCCESS, dynamic partition overwrite, stats tracker aggregation, and catalog refresh. Comet is left owning just the per-task native encode. Gated to Spark 4.0+: Spark 3.x matches the concrete WriteFilesExec case class, so a Comet node would be silently ignored and the write would drop into FileFormatWriter's non-planned, row-based branch. Other behaviour changes that fall out of the new seam: - File paths come from FileCommitProtocol.newTaskTempFile and are used verbatim, so names match Spark's part-<id>-<uuid>-c000.<codec>.parquet and committers that track individual files work. Previously only the parent directory was kept and the native writer invented its own names. - Column names come from WriteJobDescription.dataColumns rather than the query output, so INSERT INTO t SELECT a+1 writes the target column name. - Byte and row counts come from BasicWriteTaskStatsTracker, which stats files through the FileSystem API and so is correct for HDFS. The native writer's std::fs::metadata call reported 0 there. CometMetricNode's now-redundant reportNativeWriteOutputMetrics is removed. - AQE re-plans the write command's child and re-inserts a WriteFilesExec above the already-converted node; collapse it rather than nesting two native writes, which would write the data twice. Deletes CometNativeWriteExec and CometDataWritingCommand (594 lines). The operator opt-in moves to spark.comet.operator.WriteFilesExec.allowIncompatible, with the previous DataWritingCommandExec key kept as an alternative.
Reuse: - Use CometExec.serializeNativePlan and CometExec.getCometIterator instead of hand-rolling protobuf serialization and the CometExecIterator constructor. - Use CometArrowStream.countingIterator instead of a bespoke counting map. Altitude: - originalPlan now points at the WriteFilesExec rather than the child. CometExecRule copies originalPlan's logical link onto every CometExec, so pointing it at the child linked the write node to the child's logical plan; AQE then mistook it for the child's query stage and re-wrapped it in a second WriteFilesExec. That was the actual cause of the nested-write problem, so the collapse guard in CometExecRule is no longer needed and is removed. This also fixes RevertNativeForTransitionHeavyStages, which reverts a CometExec via originalPlan.withNewChildren and would otherwise have dropped the write. - DataWritingCommandExec joins the never-replaced list. It is deliberately left in the plan for a fully native write, so tagging it reported an accelerated write as a fallback in extended explain and skewed the accelerated-operator count. - Delete the ColumnarToRowExec(CometWriteFilesExec) arm in EliminateRedundantTransitions. Spark's ensureOutputsRowBased already special-cases a DataWritingCommandExec over a V1WriteCommand with a columnar child under plannedWrite, so no transition is ever inserted and the arm was dead. - Move the Spark 4.0+ gate from CometExecRule into CometWriteFiles.getSupportLevel, where every other operator's version and shape gating lives. This also gives users on 3.x a reason whether or not they opted in. Efficiency: - The write task closure no longer captures the exec node. Per-task work moves to object CometWriteFilesExec, taking a NativeWriteTask container built on the driver, so the task binary no longer carries nativeOp plus the whole converted child subtree (each node of which holds its own non-transient protobuf). Mirrors how Spark's WriteFilesExec delegates to the static FileFormatWriter.executeTask, and hoists CometMetricNode.fromCometPlan to the driver as CometNativeExec already does. - recordRows loops per tracker on the outside, so the per-row inner loop has one receiver and no per-row closure allocation. Simplification: - createOperatorIncompatConfig takes Option[String]; ConfigBuilder mutates in place, so the Seq destructuring was rebinding the same object. - Flatten the writeFile/writtenFile Option, which only fed a debug message. Also fixes the scalafix CI failure: FileCommitProtocol was imported only for a scaladoc link, which RemoveUnused does not count as a use. Adds a regression assertion that a fully native write leaves no fallback reason on DataWritingCommandExec.
Relationship to #4658@jordepic you may be interested in this one. It reaches the same shape as #4658 from the opposite direction: both separate per-task data-file writing from the commit so the write runs inside AQE. #4658 had to build that split because Spark's Iceberg V2 write is one operator; for V1 Parquet Spark already provides it ( Relevant to writing Iceberg data files natively via iceberg-rust: that would inherit this writer's output, which is not yet Spark-comparable — INT96 (#3425), footer metadata keys (#3427), Catalyst nullability and field IDs (#5305), writer properties (#5304). Also borrowed from #4658: No merge conflicts with |
|
|
||
| import testImplicits._ | ||
|
|
||
| /** |
There was a problem hiding this comment.
we prob need to include a test with dynamic overwrite.
better to fallback to spark for dynamicPartitionOverwrite
There was a problem hiding this comment.
Added in 43bce31: a dynamic-overwrite test that asserts the untouched partitions survive, alongside tests for the abort-and-retry path (injected failing commit protocol), the maxRecordsPerFile fallback (both the write option and the conf, verifying Spark's writer rolls 10 files), and the schema-only empty-input write.
On the fallback itself: dynamicPartitionOverwrite is only set when staticPartitions.size < partitionColumns.length, which implies partition columns, so it always lands on the existing partitionColumns.nonEmpty || staticPartitions.nonEmpty guard and declines.
|
@andygrove wondering if you can trigger https://github.com/apache/datafusion-comet/actions/workflows/spark_sql_writer_tests.yml for your local branch? |
| // concrete `WriteFilesExec` case class instead, so a Comet node would be silently ignored and | ||
| // the write would fall into FileFormatWriter's non-planned, row-based branch. | ||
| if (!isSpark40Plus) { | ||
| return Unsupported(Some("Native Parquet writes require Spark 4.0 or later")) |
There was a problem hiding this comment.
so Spark 3.x falls back to JVM writes?
There was a problem hiding this comment.
Yes. On 3.x getSupportLevel returns Unsupported("Native Parquet writes require Spark 4.0 or later") and the write runs through Spark's own writer unchanged. It is reported as a fallback reason so users on 3.x can see why, rather than silently getting a different path.
| return Unsupported(Some("Only InsertIntoHadoopFsRelationCommand writes are supported")) | ||
| } | ||
|
|
||
| if (!outputPath.startsWith("file:") && !outputPath.startsWith("hdfs:")) { |
There was a problem hiding this comment.
we need also fallback for dynamicPartitionOverwrite
There was a problem hiding this comment.
Covered in 43bce31. InsertIntoHadoopFsRelationCommand only sets dynamicPartitionOverwrite when staticPartitions.size < partitionColumns.length, which implies partition columns, so a dynamic overwrite always hits the existing partitionColumns.nonEmpty || staticPartitions.nonEmpty guard and is declined. I made that explicit in a comment rather than adding a redundant condition.
comphead
left a comment
There was a problem hiding this comment.
P1 — No test for task failure / commit abort path
Description: CometWriteFilesExec.executeTask wraps the native write in try { ... } catch { case t: Throwable => committer.abortTask(...); throw t } (spark/src/main/scala/org/apache/spark/sql/comet/CometWriteFilesExec.scala). No test forces a failure inside the native writer to exercise the abort branch.
This leaves the entire failure lifecycle uncovered: whether abortTask is actually called, whether the staging file for the failing attempt is cleaned up, whether the exception surfaces unwrapped, and whether a subsequent retry produces correct output.
Reason: The try/catch is new code that was previously handled by an ad-hoc handleTaskEnd block. Without coverage a broken abortTask would ship silently.
Example tests:
- Write to a read-only local path (
chmod 555 outputPath) and assert (i) anIOException/SparkExceptionpropagates, (ii)_temporaryunderoutputPathis empty, (iii) the output directory contains nopart-*.parquetfiles. - Include a
raise_error('boom')expression in the projected columns and assert the write fails and leaves nothing behind. Then remove the expression and assert the retry succeeds and produces correct data.
P2 — Zero-partition RDD no longer produces the SPARK-23271 schema-only metadata file
Description: Spark's WriteFilesExec.doExecuteWrite (Spark 4.0 WriteFiles.scala:80-92) inserts a dummy single-partition RDD when rdd.partitions.length == 0 so a schema-only Parquet file is written for downstream readers (SPARK-23271). CometWriteFilesExec.doExecuteWrite calls child.executeColumnar()
and immediately does childRDD.mapPartitionsInternal { ... } with no such fallback.
Reason: If the child produces zero partitions, no task is spawned and no schema-only file is written, so spark.read.parquet(<empty-write>) sees no schema. This diverges from Spark 4.0's own writer path.
P2 — description.maxRecordsPerFile is silently ignored
Description: Spark's SingleDirectoryDataWriter.write (FileFormatDataWriter.scala:185-197) rolls a new file every description.maxRecordsPerFile rows and increments the -c$fileCounter%03d suffix. CometWriteFilesExec.executeTask reads no maxRecordsPerFile, hard-codes -c000, and calls
newTaskTempFile once per task. Neither CometWriteFiles.getSupportLevel nor executeTask inspects the field.
Reason: Any user with spark.sql.files.maxRecordsPerFile>0 gets a different file layout than Spark's own writer with no warning or fallback, quietly violating the contract even though the write itself is only labeled Incompatible for other reasons.
Interesting finding on spark.sql.files.maxRecordsPerFile we should fallback if it is set
|
Another thing, would this PR supersede #5143 ? |
- Decline the write when spark.sql.files.maxRecordsPerFile > 0 (option or conf), so users don't silently get a single oversized file where Spark would have rolled several. Matches Spark's option-over-conf precedence. - Note that the existing partitionColumns.nonEmpty guard also declines dynamic partition overwrite, since Spark only sets dynamicPartitionOverwrite when staticPartitions.size < partitionColumns.length. - Match Spark's SPARK-23271 behavior: if the child RDD has zero partitions, swap in a dummy single-partition RDD so the output still carries a schema-only file. Defensive - requiresNativeChildren rules out LocalTableScan today, but the swap costs nothing and preserves the semantics. - Add tests for the abort-and-retry path via an injected failing commit protocol, for the dynamic-overwrite fallback (checking untouched partitions survive), for the maxRecordsPerFile fallback (option and conf, verifying Spark's writer rolls 10 files), and for the schema-only empty-input write.
Prettier widened the notes column on the writes table after the maxRecordsPerFile fallback note was appended.
I think you'd need to rebase 5143 once this merges, or we can merge that one first and then rebase this PR |
|
Thanks for the review @comphead. I have addresses feedback so far. |
comphead
left a comment
There was a problem hiding this comment.
Thanks @andygrove the improvement makes sense to me, however we may want to wait with merge as this would block writer testing on top of Spark3. Maybe this PR can be merged closer to 1.1.0 release where Comet rescinds the support for Spark 3.4.3
peterxcli
left a comment
There was a problem hiding this comment.
Is it possible to use git rename for (DataWritingCommand -> WriteFiles) and (NativeWriteExec -> WriteFilesExec) instead? because the renamed looks similar.
IIRC, git can still detect this as a move as long as git diff sees that more than 50% of the file content is the same.
Perhaps we could still view the diff between the original file and renamed file on the review page.
| // Was work_dir / job_id / task_attempt_id, used when the native writer derived its own file | ||
| // names from the task context. File naming now comes from the commit protocol instead. | ||
| reserved 5, 6, 7; |
There was a problem hiding this comment.
IIUC, we can just remove them, as the native parquet writer is experimental, and it's merely impossible to have different version of comet nodes in same cluster.
There was a problem hiding this comment.
also the number can be rearrange to be continuous
There was a problem hiding this comment.
Done in 551c604 - dropped the reserved block and renumbered so the tags are contiguous:
message ParquetWriter {
string output_path = 1;
CompressionCodec compression = 2;
repeated string column_names = 3;
map<string, string> object_store_options = 4;
}Agreed on the reasoning: plans are serialized on the driver and deserialized on executors within a single jar version and are never persisted, so there is no wire-compatibility surface the reserved tags were protecting. Generated accessors key off field names rather than tags, so no Rust or Scala call site changed. cargo check and a full build pass, and CometParquetWriterSuite is 37/37 on Spark 4.1.
One consistency note for a possible follow-up: HashAggregate still carries reserved 3, 8 from #4507 for the same removed-field situation, so the file now does this two ways.
…usly The reserved 5, 6, 7 block held the removed work_dir / job_id / task_attempt_id tags. Plans are serialized on the driver and deserialized on executors within a single jar version and are never persisted, so there is no wire-compatibility surface for those tags to protect. Remove them and renumber column_names and object_store_options so the field numbers are contiguous. Generated accessors key off field names rather than tags, so no Rust or Scala call site changes.
|
@peterxcli on the rename question - I looked at whether this is achievable and it isn't, for two reasons. Git doesn't record renames in commits. It stores trees, and rename detection is a diff-time heuristic run over file content. Doing The content is also well under the threshold you mention. Measured similarity index: 27% and 14%, against git's 50% default. The exec file in particular is a rewrite rather than a move: the old one owned the commit protocol, the SaveMode logic and What does work is lowering the threshold locally, which pairs both files and gives the side-by-side view you're after: Your proto suggestion is applied in 551c604. |
There is already a PR open to drop support for Spark 3.4, so I think this is going to happen pretty quickly. |
|
Related to #5358 |
sunchao
left a comment
There was a problem hiding this comment.
Thanks for moving job-level write handling back into Spark. I found one compatibility issue with the legacy native-write opt-in. The inline comment includes the behavior reproduced against Spark 4.0.4.
| createOperatorIncompatConfig( | ||
| "WriteFilesExec", | ||
| Some(getOperatorAllowIncompatConfigKey("DataWritingCommandExec"))) |
There was a problem hiding this comment.
[P2] Could the planner read the registered COMET_OPERATOR_WRITE_FILES_ALLOW_INCOMPAT entry? CometExecRule.isOperatorEnabled calls isOperatorAllowIncompat("WriteFilesExec"), which reads only the new key through SQLConf.getConfString. That bypasses the alternative declared here.
On Spark 4, an existing job with spark.comet.parquet.write.enabled=true and only spark.comet.operator.DataWritingCommandExec.allowIncompatible=true now falls back to Spark's writer. I reproduced this with the exact PR's CometConf and Spark 4.0.4: the registered config entry returns true, while the planner's check returns false.
Could you preserve the old-key-only behavior and add a regression test that also checks that an explicitly set new key takes precedence?
sunchao
left a comment
There was a problem hiding this comment.
LGTM. So the plan is to merge this once we drop the support of Spark 3.4?
…configs Matches the reviewed form on apache#5293: ConfigBuilder mutates in place, so the Seq destructuring was rebinding the same object. Only one operator has an alternative and there is no reason to expect more.
|
Closing in favour of #5763, which makes this change purely additive. @comphead's concern was the one that mattered: this PR deleted the Spark 3.x native Every review item here is carried over:
One thing worth flagging for #5143: it adds an |
…Spark 4.0+ (apache#5763) * feat: hook native Parquet writes into Spark's WriteFilesExec seam on Spark 4.0+ Native writes replace the whole DataWritingCommandExec, which means InsertIntoHadoopFsRelationCommand.run never runs. Everything that method does has to be re-implemented inside CometNativeWriteExec: a hardcoded SQLHadoopMapReduceCommitProtocol (so spark.sql.sources.commitProtocolClass is ignored), dynamicPartitionOverwrite pinned to false, a hand-ported copy of the SaveMode logic, a bespoke commit-message accumulator, and its own commitJob call. Most of the open native-writer issues are symptoms of that one decision rather than independent defects. On Spark 4.0+, V1WritesUtils.getWriteFilesOpt matches the WriteFilesExecBase trait (introduced in 4.0 precisely for this), so a Comet node that extends it gets driven through FileFormatWriter.executeWrite -> SparkPlan.executeWrite -> doExecuteWrite, and Spark keeps ownership of everything above the per-task write. Spark 3.x has no such trait: getWriteFilesOpt matches the concrete WriteFilesExec case class, a Comet node there would not be found, and Spark would silently take FileFormatWriter's non-planned, row-based branch. So the new seam is additive. CometDataWritingCommand and CometNativeWriteExec are kept unchanged and remain the 3.4/3.5 path; CometExecRule picks the path by version and the two never both fire. The legacy path goes away with Spark 3.x support. Add: - CometWriteFilesExec, overriding doExecuteWrite and mirroring FileFormatWriter.executeTask for the parts Comet must do itself: build the TaskAttemptContext, ask the commit protocol for a path, run the native writer, drive the stats trackers, commit or abort. Plus the CometWriteFiles serde and a two-line ShimCometWriteFilesExec in spark-4.x / spark-3.x. - File paths come from FileCommitProtocol.newTaskTempFile and are used verbatim, so names match Spark's part-<id>-<uuid>-c000.<codec>.parquet and committers that track individual files (S3A magic, streaming manifest) work. - Column names, nullability and field IDs come from WriteJobDescription.dataColumns rather than the query output, so INSERT INTO t SELECT a+1 writes the target column's name. - Byte and row counts come from BasicWriteTaskStatsTracker, which stats files through the FileSystem API and is therefore correct on HDFS. - ParquetWriter proto: work_dir is now optional. When set (3.x) the native writer derives the file name as before; when unset (4.0+) output_path is the exact file to write and is used verbatim. - On 4.0+ the opt-in moves to spark.comet.operator.WriteFilesExec .allowIncompatible, with the old DataWritingCommandExec key kept as a deprecated alternative. isOperatorAllowIncompat now resolves alternatives, which the planner's by-name lookup previously bypassed. AQE re-plans the write command's child and re-inserts a WriteFilesExec above the node Comet already converted; leaving DataWritingCommandExec in place means Comet no longer has to guard against the resulting nested native writes. * refactor: take a single deprecated alternative for operator incompat configs Matches the reviewed form on apache#5293: ConfigBuilder mutates in place, so the Seq destructuring was rebinding the same object. Only one operator has an alternative and there is no reason to expect more. * fix: escape the output path before parsing it as a URI The output path reaches both write serdes as `Path.toString`, which decodes percent escapes: a directory containing a space or a literal `%` yields a string that is not a valid URI, and `URI.create` throws on it. On Spark 4.0+ that exception escaped `CometWriteFiles.convert` and failed the query. On 3.x `CometDataWritingCommand.convert` caught it and silently handed the write back to Spark, so the native writer was never used for those paths. Round-trip through `Path` instead, which re-escapes. Only the scheme and authority reach `extractObjectStoreOptions`, but parsing has to succeed to get at them. Also assert that the INSERT INTO visibility test's write actually went native, rather than inferring it from the read-back. * fix: decline HDFS writes whose output path needs URI escaping * fix: also decline Unicode HDFS write destinations The raw/decoded URI comparison only catches what java.net.URI had to escape, and java.net.URI leaves non-ASCII path characters alone, so an hdfs://ns/cafe<U+0301>/output destination was admitted. percent_encoding's should_percent_encode is !byte.is_ascii() || set.contains(byte), so the native parser escapes every non-ASCII byte regardless of the encode set and the writer creates caf%C3%A9 outside Spark's staging directory. The guard now also declines any character the native parser rewrites. The ASCII half of that set was determined against the locked url 2.5 crate by parsing hdfs://ns/pre<c>post/output for every printable ASCII c: space, ", #, <, >, ?, backtick, { and } are rewritten and the rest survive, so partition directories and Spark's _temporary attempt paths still qualify. The comment no longer claims the Java comparison detects the divergence on its own; both conditions are kept because the Java one still catches a literal % that the native parser leaves alone. Tests add accented (precomposed and combining), CJK, emoji and nested non-ASCII cases plus the remaining escaped ASCII characters, built from code points since scalastyle forbids non-ASCII source. Disabling the new condition makes the accented case fail, so the Java comparison alone demonstrably does not cover it. * fix: address review feedback on the native WriteFilesExec seam Correctness: - Decline HDFS writes whose *file names* would diverge, not just their directory. `mapreduce.output.basename` is caller-controlled and reaches every committed name through `HadoopMapReduceCommitProtocol.getFilename`; a basename holding `?` or `#` makes the native URL parser truncate, so every task writes the same name and they overwrite each other at commit. Adds an execution-time backstop over the complete `newTaskTempFile` path, which a custom commit protocol owns and planning cannot predict. - Read the compression option case-insensitively, as Spark's `ParquetOptions` does. `option("Compression", "lz4_raw")` used to fall through to the SQLConf default, so the unsupported-codec guard was bypassed and Comet wrote SNAPPY into a file Spark had named `.lz4raw.parquet`. The codec is now also re-derived per task from `CodecConfig.from(taskAttemptContext)`, the same place the file extension comes from, so the name and the contents agree by construction. The shared helpers move to `NativeWriteUtils`, which fixes the identical bug on the Spark 3.x path. - Use `Utils.tryWithSafeFinallyAndFailureCallbacks` / `tryWithSafeFinally` in `executeTask` and `writeNatively`, matching `FileFormatWriter`: a failure while aborting or closing the iterator is attached as a suppressed exception instead of replacing the failure that caused it. `statsTrackers` moves inside the guard so a throwing `newTaskInstance` still reaches `abortTask`. Planning and reporting: - Convert `WriteFilesExec` from its enclosing `DataWritingCommandExec` rather than from a separate tag pre-pass, so the output path comes straight from the command that owns it and neither `withNewChildren` copying tags nor "nothing hands us a bare WriteFilesExec" has to hold. - Only skip the fallback reason on `DataWritingCommandExec` when its child really was converted; a write with no native child now says why it fell back. - Drop the node's duplicate `files_written`/`bytes_written`/`rows_written`. `BasicWriteJobStatsTracker` is authoritative here, and the native `bytes_written` reads 0 on HDFS. Tests and docs: - Rust `url_path_rewritten_characters` pins the `url` crate's path encode set, which the JVM guard mirrors; a crate upgrade can no longer reopen the hole with a green build. - New JVM coverage: mixed-case `compression` (honored, and declined when unsupported), the apache#3426 nested-name INSERT, an empty non-zero partition writing no file, the third-party `WriteTaskStatsTracker` warning, and the basename/committer-path guards. - The abort test no longer claims to demonstrate task retry or speculation. - `installation.md` says which Spark versions its EXPLAIN output applies to. * fix: drop a redundant string interpolator flagged by scalafix RedundantSyntax * fix: decline percent-bearing HDFS basenames at planning mapreduce.output.basename=part%foo passed the planning guard and then failed checkNativeWriteDestination at execution, aborting the job instead of falling back to Spark's writer. needsNativeUrlEscaping deliberately excludes % because the url crate leaves it alone, so only Java's raw-vs-decoded comparison sees it. The basename now goes through the same hdfsPathDivergence predicate the task guard uses, applied to the path the basename produces. That closes the gap for % and for the other characters java.net.URI escapes but the url crate keeps ([, ^, |), and makes the two guards agree by construction rather than by two character sets staying in sync. Also updates CometEmptyRelationParquetWriterSuite, which main added while this branch was open: a native empty relation is the zero-partition input CometWriteFilesExec swaps a single-partition RDD in for, so on 4.0+ that write is accelerated rather than declined. * fix: drop imports left unused by moving the write assertions into the base
Which issue does this PR close?
Part of #2967 and #1625. Restructures the native write path so the following can be
fixed at all, and closes the ones that were purely symptoms of the old design:
Closes #2985 (no
_SUCCESSfile)Closes #3521 (
INSERT INTO ... SELECTinvisible to subsequent reads)Closes #3426 (complex type with different names)
Unblocks (not fixed here, but no longer require re-implementing Spark's write
framework inside Comet): #2957, #2970, #3015, #3041, #3193, #3194, #3417, #3428.
Rationale for this change
Native writes replaced the whole
DataWritingCommandExec, which meansInsertIntoHadoopFsRelationCommand.runnever ran. Everything that method does had tobe re-implemented inside
CometNativeWriteExec: a hardcodedSQLHadoopMapReduceCommitProtocol(sospark.sql.sources.commitProtocolClasswasignored),
dynamicPartitionOverwritepinned tofalse, a hand-ported copy of theSaveMode logic, a bespoke commit-message accumulator, and its own
commitJobcall.Most of the open native-writer issues are symptoms of that one decision rather than
independent defects. Fixing them one at a time against the old design means writing a
second, worse
FileFormatWriterinside Comet.Spark already has the right seam. On Spark 4.0+,
V1WritesUtils.getWriteFilesOptmatches the
WriteFilesExecBasetrait (introduced in 4.0 precisely for this), so aComet node that extends it gets driven through
FileFormatWriter.executeWrite→SparkPlan.executeWrite→doExecuteWrite, andSpark keeps ownership of everything above the per-task write.
Why Spark 4.0+ only. On 3.4/3.5
getWriteFilesOptmatches the concreteWriteFilesExeccase class. A Comet node there would not be found,writeFilesOptwould be
None, and Spark would silently takeFileFormatWriter's non-planned,row-based branch — ignoring
doExecuteWriteentirely. The only way in on 3.x is toinherit from a case class, which brings
copy/equalshazards; that isn't worthcarrying, so native writes now require 4.0+ and report a fallback reason on 3.x.
What changes are included in this PR?
CometWriteFilesExecoverridingdoExecuteWrite, mirroringFileFormatWriter.executeTaskfor the parts Comet must do itself: build theTaskAttemptContext, ask the commit protocol for a path, run the native writer,drive the stats trackers, commit or abort. Plus the
CometWriteFilesserde and atwo-line
ShimCometWriteFilesExecinspark-4.x/spark-3.x.CometNativeWriteExec.scalaandCometDataWritingCommand.scala(594lines). Net −549 lines.
FileCommitProtocol.newTaskTempFileand are used verbatim,so names match Spark's
part-<id>-<uuid>-c000.<codec>.parquetand committers thattrack individual files (S3A magic, streaming manifest) work. Previously only
.getParentwas kept and the native writer invented its own names.WriteJobDescription.dataColumnsrather than the queryoutput, so
INSERT INTO t SELECT a+1writes the target column's name ([COMET NATIVE WRITER] INSERT INTO TABLE - complex type but different names #3426).BasicWriteTaskStatsTracker, which stats files through theFileSystemAPI and is therefore correct on HDFS. The native writer'sstd::fs::metadatacall reported0there.CometMetricNode's now-redundantreportNativeWriteOutputMetricsis removed.work_dir/job_id/task_attempt_idremoved (reserved);output_pathisnow the exact file to write, set per task.
spark.comet.operator.WriteFilesExec.allowIncompatible, with the oldDataWritingCommandExeckey kept viawithAlternative.Fixed along the way
AQE re-plans the write command's child and re-inserts a
WriteFilesExecabove thenode Comet already converted. Without a guard that produced nested native writes — the
data written twice, and the inner node's empty output read as a zero-column schema.
CometExecRulenow collapses the redundant node. Thebasic parquet write with repartitiontest catches this.How are these changes tested?
CometParquetWriterSuite: 33/33 on Spark 4.1 — the 30 existing tests plus threenew regression tests for
_SUCCESS(Comet writer doesn't create _SUCCESS file #2985), Spark-compatible file naming, andINSERT INTO ... SELECTvisibility ([Native Writer] INSERT INTO ... SELECT fails due to stale catalog cache after write #3521). Suite-levelassume(isSpark40Plus).CometTaskMetricsSuite: 6/6. Note this suite's native-write test was pinned to theold operator's config key, so it would have silently fallen back to Spark's writer
and still passed; it is repointed and now genuinely exercises the native path.
CometExecSuite(143),CometExpressionSuite+CometAggregateSuite+CometFuzzTestSuite(273) — all pass.cargo checkand the nativeparquet_writerunit tests pass.Known limitation
WriteTaskStatsTracker.newRow(filePath, row)is a per-row callback. Comet has columnarbatches, so rather than materializing every row just to hand it straight back,
recordRowspassesInternalRow.emptyand feeds only the count. That is exactly rightfor
BasicWriteTaskStatsTracker, which ignores the row argument, but a third-partytracker inspecting row contents would see empty rows — so that case logs a warning
rather than silently reporting wrong statistics. A plan-time guard isn't possible
because
statsTrackersonly exists at execution time.Follow-ups
Independent of this change and the next highest-value work, since the Spark default is
affected: full
WriterProperties(block/page size, dictionary, writer version), INT96timestamps (#3425 —
spark.sql.parquet.outputTimestampTypedefaults toINT96and wewrite INT64 micros), the four footer metadata keys (#3427 —
legacyINT96andtimeZonedrive rebase decisions on read, so omitting them is a correctness risk), field IDs, and
Catalyst nullability. Then partitioned (#3193) → bucketed (#3194) → object stores.