feat: hook native Parquet writes into Spark's WriteFilesExec seam on Spark 4.0+ - #5763
Conversation
…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.
…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.
| outputPathOf(op).foreach { outputPath => | ||
| val hadoopConf = op.session.sessionState.newHadoopConfWithOptions(op.options) | ||
| NativeConfig | ||
| .extractObjectStoreOptions(hadoopConf, URI.create(outputPath)) |
There was a problem hiding this comment.
Could we use new Path(outputPath).toUri here? Paths containing spaces or a literal % currently fail in URI.create, while the base successfully falls back to Spark. I tested this change and both cases write natively. Could you also add regression tests for these paths?
There was a problem hiding this comment.
Done — switched to new Path(outputPath).toUri, and made the same change in CometDataWritingCommand, which had the same URI.create call on the 3.x path. There the serde's catch turned it into a silent fallback rather than a failure, so the native writer was simply never used for those paths.
The regression test covers a directory with spaces, one with a literal %, and one with both, and it isn't version-gated so it runs against both writers. I checked it fails without the fix: IllegalArgumentException: Illegal character in path on 4.1, and zero native write operators in the plan on 3.5.
|
@andygrove thanks for the patch! |
sunchao
left a comment
There was a problem hiding this comment.
Correctness
Reviewed df968953 against 7f1e0018. The prior writer replaced the entire DataWritingCommandExec, bypassing Spark's normal insertion, job-commit and catalog-refresh lifecycle. On Spark 4.0+, the new WriteFilesExecBase implementation leaves that lifecycle with Spark and supplies the per-task native write through doExecuteWrite. I compared this with the maintained Spark 3.5 and 4.0 branches: 3.5 discovers the concrete WriteFilesExec, whereas 4.0 discovers the base trait. Retaining the old path on 3.4/3.5 is therefore justified.
The task path follows Spark's job/task/attempt identifiers, requests the committer's exact filename, finishes the native writer before committing, and aborts/rethrows on write or commit failure. Partition zero retains a schema-only file for empty input; other empty partitions produce no file. Target names, nested types, nullability and field IDs come from WriteJobDescription.dataColumns. Partitioned/bucketed writes remain excluded, and the record-count rollover guard matches Spark's option-over-configuration precedence. The deprecated opt-in is respected, with an explicitly set new key taking precedence.
[P2] Preserve Hadoop path escaping
The existing path-parsing finding remains unresolved. The output-path tag contains Hadoop Path.toString, and URI.create(outputPath) throws for spaces or a literal %. The new serde has no enclosing fallback catch; the base catches that conversion error and retains Spark's writer. A local Java 17/Hadoop component check reproduced the exception for both local and HDFS path strings, with ordinary paths as controls; Path.toUri handled all six cases. This supports the existing comment, so I have not added a duplicate inline. This local check exercised path conversion only, not a complete Spark write.
Validation and limits
The inspected CI jobs checked out merge 2e35d315, whose parents are the reviewed base/head and whose entire tree equals the reviewed head. CometParquetWriterSuite passed 41/41 on Spark 4.0 and 41/41 on Spark 4.1; Spark 3.5 passed 33 with eight version-gated cancellations, and 3.4 passed 32 with nine cancellations. Spark 4.0's metrics suite passed 15/15, including native-write output metrics. The Rust job passed 1,184 tests, including exact-path writing, with four skipped. The JVM consumers downloaded the same native artifact digest uploaded after the producer rebuilt this source.
At the reviewed snapshot, 63 checks passed, nine were skipped, and one Iceberg check failed downloading Gradle with HTTP 504 before its tests started. Maintained 3.4, 4.1 and 4.2 source branches were unavailable; CI coverage does not replace those source comparisons. No local Spark/native integration build or benchmark was run. The injected failure test checks abort and a subsequent whole-write retry; concurrent speculative attempts remain untested. The INSERT visibility test verifies readback but does not independently assert the native write plan. I found no additional verified P1/P2 issue in the authored changes.
Performance
The write stays columnar and preserves the existing native Parquet implementation. Resolving NativeWriteTask on the driver avoids capturing the whole execution-plan object in the task closure, and the small writer plan is rebuilt per task because its destination is task-specific. File-size accounting now uses Spark's filesystem-aware tracker, which also supplies the task output counters.
recordRows adds one callback per written row after native writing completes. Keeping the tracker loop outside the row loop limits dispatch overhead, but the work still scales with row count. A focused large-batch, narrow-row write benchmark with BasicWriteTaskStatsTracker would quantify this cost before making a throughput claim. The functional tests establish correctness coverage, not a measured speedup; I have no verified performance blocker.
Design
Using Spark's existing task-write seam gives job commit, SaveMode handling, _SUCCESS, cache refresh and catalog statistics one owner. Passing the exact filename back to native code also removes the old assumption that the committer only cares about a staging directory. The filesystem admission gate still limits this path to local/HDFS output; it does not establish support for object-store committers.
The conservative command/native-child/partition/bucket/rollover guards keep the supported case understandable. The maintained InsertIntoHadoopFsRelationCommand supplies the basic count-only stats tracker, which fits the current implementation. Row-inspecting trackers need a separate compatibility solution before broadening that scope; a warning cannot supply their missing row contents. Experimental opt-in remains appropriate while the disclosed timestamp/footer and Parquet writer-property gaps remain.
Abstraction & complexity
The two small version shims isolate the actual Spark API difference. NativeWriteTask has a clear serialization purpose, and the static task runner keeps executor work separate from driver plan state. Preserving the write node as originalPlan, together with restarting child native-block serialization, also makes the AQE ownership boundary explicit.
The optional work_dir remains the discriminator between the old directory-based path and the new exact-file path without changing protobuf field numbers. Both Scala execution paths are necessary while Spark 3.x is supported; the compatibility branch is localized enough to remove with that support. Beyond the remaining path-parsing correction, I found no actionable abstraction or complexity blocker.
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.
|
Path escaping is fixed in d36ad9f, in both serdes — the 3.x one had the same I also took your point on the INSERT visibility test: it now captures the executed plan and asserts a native write, so a fallback can't make the read-back pass for the wrong reason. On |
sunchao
left a comment
There was a problem hiding this comment.
Correctness
Rechecked d36ad9ff against 7f1e0018, including the three-file increment from the previous review. The original Java URI-parsing finding is fixed: Spark 3.x uses the command's Path.toUri, and Spark 4.0+ reconstructs the tagged Hadoop path before extracting configuration. The new local-path test checks native-plan presence and readback for spaces, literal percent signs, and their combination. The INSERT visibility test now asserts its native write as well.
[P2] Preserve fallback for escaped HDFS destinations
The Spark 3.x change also admits HDFS paths that previously fell back to Spark, exposing a separate native destination mismatch. With a native child reading local input and a cold HDFS object-store cache, a destination containing dir with space reaches create_hdfs_object_store, which uses Path::parse(url.path()). The URL path contains dir%20with%20space, and that constructor preserves the percent encoding. The native writer passes this different directory through OpenDAL and libhdfs. Spark's committer still owns the original directory containing spaces. This can leave data outside the committer's staging tree while job commit reports success. The same destination mismatch affects the newly admitted Spark 4.0+ case.
Please keep these HDFS destinations on Spark during planning until the native path conversion preserves Hadoop's filename semantics. Fixing the native conversion should cover both cold and warm cache paths, which currently use different path constructors. The new local-filesystem tests cannot detect this HDFS branch. This is a downstream issue exposed by the fallback removal, separate from the fixed URI.create exception.
I verified the path flow against the locked URL, object-store, OpenDAL and hdrs sources. A Java 17/Hadoop 3.4.1 component check passed eight planning cases and confirmed that the committer and traced native paths are distinct. A second check used the real Hadoop FileOutputCommitter on local storage with that destination mismatch injected: both algorithms 1 and 2 created _SUCCESS without moving the misplaced data file into the intended output. These are component checks plus source analysis, not a Spark/native/HDFS end-to-end reproduction.
The maintained Spark 4.0 comparison still supports the unchanged write lifecycle: eligibility declines happen during planning. Spark owns insertion and job commit/abort. Native writing finishes before task commit. Task failures abort and rethrow. Partitioned, bucketed and record-rollover writes remain excluded. An execution-time fallback would be too late once overwrite deletion or task output has occurred.
Validation and limits
Current CI's Rust job passed 1,208 tests with five skipped, including the exact-file-path unit test. It executed merge 45074b00 with the reviewed head and newer main 8e684685, not the authoritative base/head tree. Fourteen of the fifteen contributed files are identical to the reviewed head. The differing planner file has an identical Parquet-writer arm. At the 2026-09-08T17:47:28Z refresh, 40 checks passed, seven were skipped, 19 were running and one was queued. Current JVM writer-suite outcomes have not been independently verified in this review. The previous head's passing writer suites and the author's updated test counts are not current independent validation. Maintained Spark 3.4/4.1/4.2 source gaps remain. No local Spark/native build, HDFS cluster run or speculative-attempt test was performed.
Performance
The URI correction adds one Hadoop Path reconstruction during Spark 4.0+ planning and reuses the existing path on 3.x. It adds no per-batch or per-row work. The task writer and its per-row statistics callbacks are unchanged. The author now explicitly acknowledges that their cost has not been measured. No throughput conclusion follows from this update.
Design
The write framework remains Spark-owned, and the corrected local path reaches the existing task writer without changing commit ordering. The remaining HDFS issue belongs at the boundary between Hadoop filenames and native URL paths. A planning-time restriction is sufficient to preserve the prior safe behavior while that boundary is fixed. Catching errors after native writing starts would not recover files written outside the committer's directory.
Abstraction & complexity
Reusing cmd.outputPath.toUri on 3.x avoids an unnecessary round trip. The Spark 4.0+ conversion stays localized where the string tag is consumed. The by-name captureWritePlan overload reuses the listener setup for INSERT and improves the test without adding production machinery. No additional abstraction change is needed beyond correcting or restricting the HDFS path boundary.
| // give the write back to Spark). Going through `Path` escapes them again. | ||
| val objectStoreOptions = | ||
| NativeConfig.extractObjectStoreOptions(hadoopConf, URI.create(outputPath)) | ||
| NativeConfig.extractObjectStoreOptions(hadoopConf, cmd.outputPath.toUri) |
There was a problem hiding this comment.
Correctness
[P2] Keep escaped HDFS destinations on Spark until native path decoding is fixed
This removes the Spark 3.x fallback for HDFS destinations containing spaces, but the native HDFS path is still different from the committer's path. With local native input and a cold HDFS object-store cache, create_hdfs_object_store passes url.path() to object_store::path::Path::parse, retaining dir%20with%20space. ParquetWriter then sends that path through OpenDAL/libhdfs, while Spark commits the original dir with space staging directory. Data can therefore remain outside the committed output even though job commit succeeds. The local-path regression test does not exercise this branch. Please decline such HDFS writes during planning until native path handling preserves Hadoop filenames on both cold and warm cache paths. This is a source-confirmed destination mismatch. A Hadoop committer component probe confirmed the commit consequence, but I did not run an end-to-end HDFS write.
There was a problem hiding this comment.
Fixed in 4095ea6 — declined at planning, as you asked.
You're right that the previous change fixed the wrong half for HDFS. Making URI.create stop throwing removed the accidental fallback, but the native and committer paths still name different directories, so all that did was convert a benign fallback into a silent data-placement bug. A write that commits successfully with the data somewhere else is a worse outcome than not accelerating it.
NativeWriteUtils.escapedHdfsDestination is the new gate, called from getSupportLevel in both serdes — CometWriteFiles for 4.0+ and CometDataWritingCommand for 3.x — since the mismatch is in the native object store and does not care which seam the write came through. It compares uri.getRawPath against uri.getPath, which differ exactly when the path contained something the URI form had to escape, and it is scoped to hdfs: so local writes keep the behaviour the earlier fix gave them.
The fallback reason names the consequence rather than the rule, so the EXPLAIN output is actionable:
HDFS output paths needing URI escaping are not supported: the native writer would write to the escaped path while Spark commits the unescaped one (
/dir with space/output.parquet)
The regression test is HDFS output paths needing URI escaping are declined at planning. It exercises the predicate directly rather than through a write, since I have no HDFS to write to here — it covers a space, a literal %, and a nested escaped segment as declined, and asserts the two things that would make the gate too broad: an ordinary hdfs: path still passes, and file: paths with either shape still pass. The existing local-path test still writes natively.
I have not tried to fix the native path handling here. When that lands, this gate is one call to delete, and the reason string points at what has to change.
sunchao
left a comment
There was a problem hiding this comment.
Correctness
Rechecked 4095ea6f against 7f1e0018, including the four-file update since d36ad9ff. The new Unsupported checks run during planning in both serdes and cannot be bypassed by allowIncompatible. They fix the reported space/percent examples while preserving local native writes. The original Java URI-parsing fix remains intact.
[P2] Also decline Unicode HDFS destinations
The existing destination-mismatch finding remains for paths such as hdfs://ns/café/output. Hadoop's URI returns /café/output from both getRawPath and getPath, so the new predicate returns None. The locked Rust URL parser percent-encodes non-ASCII UTF-8 bytes, producing caf%C3%A9. With a native child reading local input and a cold HDFS object-store cache, Path::parse(url.path()) preserves that encoding and the native writer still creates files outside Spark's Unicode staging directory. This is the same unresolved P2, narrowed to a case the new guard misses.
Please also decline non-ASCII HDFS paths, or use an escape check aligned with the native URL parser, and add Unicode inputs alongside the current space/percent cases. The ordinary-HDFS and local-path controls should continue to pass. A Java getRawPath != getPath comparison alone does not cover the native encoding boundary.
I executed the exact current Scala method, copied byte-for-byte into a small Scala 2.13.17/Hadoop 3.4.1 component: all six existing controls behaved as expected, while accented, CJK, emoji and combining-character paths were admitted. Source inspection of locked url 2.5.8, percent-encoding 2.3.2 and the downstream object-store/HDFS dependencies confirms the different native filename. A real Hadoop FileOutputCommitter check with that Unicode destination mismatch injected produced _SUCCESS without the data file in the intended output under both commit algorithms. These are component checks plus source analysis. I did not run a Spark/native/HDFS write.
The maintained Spark 4.0 comparison confirms that its commit protocol returns Hadoop filenames and Spark owns job commit/abort. Native task output must land under that same staging directory before task commit. Spark 3.5 retains the concrete WriteFilesExec route and this PR's legacy writer selection. The new guard is correctly placed before write side effects, but its incomplete predicate leaves the Spark 4.0+ seam exposed. I am not claiming that Unicode handling newly regresses the pre-existing Spark 3.x writer.
Validation and limits
Current writer-suite logs show 43 passed on Spark 4.0, 4.1 and 4.2. Spark 3.4 has 34 passed/9 canceled and 3.5 has 35 passed/8 canceled. The new guard test passes in all five jobs, but contains no Unicode case. The Rust job passed 1,259 tests with five skipped, including the exact-file-path writer test.
These jobs checked out merge fc17a3b3 with this head and newer main 424c31aa, not the assigned base/head tree. Twelve of sixteen authored files match the head. The guard, both serdes, task writer and writer suite are identical, as are the checked writer regions in differing files. At 2026-09-09T18:21:01.391690+00:00, 51 checks passed, ten were skipped, one failed and one was queued. The failed Spark 4.1 SQL build reports runner shutdown/cancellation. It does not establish a source compilation failure. Maintained Spark 3.4/4.1/4.2 source gaps remain. No full local Comet build, HDFS cluster or speculative-attempt run was performed.
Performance
The helper adds path parsing and comparison during HDFS planning. Local paths return before that work, and the update adds no per-row or per-batch processing. The existing per-row statistics callbacks are unchanged. Their cost remains unmeasured. This update supplies no throughput evidence.
Design
A shared planning restriction is a suitable bounded mitigation while native path conversion remains unfixed. Both write paths consult it before execution, which avoids attempting recovery after overwrite deletion or misplaced output. Complete the predicate for native Unicode escaping. Cold and warm object-store paths still use different constructors and must both be considered when lifting the restriction.
Abstraction & complexity
The shared helper keeps one restriction and one diagnostic across the two serdes without changing their execution lifecycle. No additional abstraction is needed. Its comment currently overstates what Java's raw/decoded comparison detects. Correcting that explanation together with the Unicode guard and tests is part of the same P2.
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.
|
You were right, and the reason the first guard missed it is worth writing down: Rather than special-case non-ASCII, I derived the whole set the native parser rewrites from the locked So nine ASCII characters plus every non-ASCII byte, and notably I kept the Java comparison as a second condition rather than replacing it. The native parser leaves Tests cover accented Latin both precomposed (U+00E9) and as I checked the new condition is load-bearing rather than assuming it. Disabling only With it enabled, all 43 On the lint failure you spotted in the earlier round: the first run of the suite here failed 43 of 43 for an unrelated reason worth recording, a stale |
|
One confirmed P2 correctness issue remains. I would fix it before merging. Reviewed head Finding: HDFS filename prefixes can cause silent data lossThe new guard checks the output directory, but Spark’s generated filename also includes With a basename such as I reproduced this with real Spark/native execution and a local HDFS cluster, using 100 rows across two partitions:
All writes returned successfully and created Suggested fix: extend admission checks to the effective filename prefix and protect the complete committer-returned path, or correct native Hadoop-path conversion. Add a multi-partition HDFS regression test. The older writer’s fixed filename generation did not expose this configuration. Other review areas
Validation
Validation boundary: local native compilation was blocked by the dependency mirror. JVM tests and HDFS reproduction used current-head JVM code with a checksum-verified CI native library from merge |
comphead
left a comment
There was a problem hiding this comment.
Reviewed against the branch-3.5 / branch-4.0 / branch-4.1 Spark sources. The premise checks out: V1WritesUtils.getWriteFilesOpt returns Option[WriteFilesExecBase] on 4.0/4.1 (V1Writes.scala:227) and Option[WriteFilesExec] on 3.5, so the additive split really is the only way in. executeTask is a faithful transcription of FileFormatWriter.executeTask for the parts it keeps, including the literal mapreduce.task.partition = 0, the exact negation of the EmptyDirectoryDataWriter guard, and the closeFile / commitTask / getFinalStats(taskCommitTime) ordering. BasicWriteTaskStatsTracker.newRow is indeed just numRows += 1, and its getFinalStats is what populates outputMetrics, so dropping reportNativeWriteOutputMetrics on 4.0+ loses nothing. TreeNode.withNewChildren calls copyTagsFrom (TreeNode.scala:348), so the WRITE_OUTPUT_PATH tag survives the child conversion.
No blockers. Two things I would want addressed, then some smaller notes.
Major
1. CometWriteFiles.parseCompressionCodec reads write options case-sensitively, Spark does not.
op.options.get("compression").orElse(op.options.get(ParquetOutputFormat.COMPRESSION)) runs against a plain Map. Spark resolves the same two keys through CaseInsensitiveMap (ParquetOptions.scala:33,39-40, reached via prepareWrite(..., caseInsensitiveOptions, ...)), and DataFrameWriter passes optionsWithPath.originalMap (DataFrameWriter.scala:144,273) so the caller's key case reaches WriteFilesExec.options intact.
This mattered less on 3.x because the native writer invented part-00000-00123.parquet with no codec suffix. Now the name comes from Spark (ParquetUtils.scala:533-535, CodecConfig.from(context).getCodec.getExtension) and the content comes from Comet, so they can disagree. df.write.option("Compression", "gzip") gets a file named ...-c000.gz.parquet containing SNAPPY. Worse, option("Compression", "lz4_raw") or "brotli" makes getSupportLevel read the SQLConf default instead, find it in supportedCompressionCodecs, and accept the write, so the unsupported-codec guard is bypassed entirely.
The neighbouring check in the same object already gets this right: CaseInsensitiveMap(op.options).get("maxRecordsPerFile").
Fix is one wrapper, but I would also move parseCompressionCodec, supportedCompressionCodecs, and the string-to-proto mapping into NativeWriteUtils rather than keep the third verbatim copy of the precedence rule. Stronger variant: keep the planning-time check for the fallback decision but re-derive the codec at execution time from CodecConfig.from(taskAttemptContext), which makes the agreement structural.
2. NativeWriteUtils.nativeUrlEscapedAscii mirrors the url crate's encode set with nothing on the Rust side pinning it.
The set is correct today, and the reasoning in the comment holds up against the WHATWG path percent-encode set. The issue is that nothing fails when it stops being correct. The new Scala test asserts the predicate against a hardcoded path list, so it locks in the predicate but not its agreement with the parser it is modelling. A url bump in Cargo.lock, or a change to how prepare_object_store_with_configs derives the object-store path, reintroduces the bug with a green build, and the failure mode is the silent one the guard exists to prevent: commitJob succeeds, _SUCCESS lands, data sits at caf%C3%A9.
Suggest a Rust unit test next to create_hdfs_operator that parses hdfs://ns/pre<c>post/output for every printable ASCII c and asserts exactly that set is rewritten, plus one non-ASCII case, cross-referenced from the Scala comment. Or invert needsNativeUrlEscaping to an allowlist so anything unrecognised is declined rather than admitted.
Minor
- Dead drain. In
executeTask, theelsebranch is reached only whensparkPartitionId != 0 && !batches.hasNext, sobatches.foreach(_.close())closes nothing and the comment describes work that never happens. statsTrackerssits outside thetry, so a throwingnewTaskInstanceskipsabortTask. Spark constructs its trackers inside theFileFormatDataWriterconstructor, which runs insidetryWithSafeFinallyAndFailureCallbacks. Low likelihood, one-line move.DataWritingCommandExecis now never tagged with a fallback reason on 4.0+. The guard is unconditional rather than conditional on the write being native, so a write with noWriteFilesExecchild (plannedWrite.enabled=false,InsertIntoDataSourceCommand, Hive inserts) silently disappears from the fallback report instead of saying why.if isSpark40Plus && d.child.isInstanceOf[CometWriteFilesExec]would keep the explanation for the cases that genuinely fell back.- Duplicate write metrics.
CometWriteFilesExecstill exposesfiles_written/bytes_written/rows_writtenfrom the native side, andbytes_writtenusesstd::fs::metadata, which the PR notes returns 0 on HDFS. On HDFS the UI will showwritten data: 0 Bon the Comet node next to Spark's correctnumOutputBytes. Now thatBasicWriteJobStatsTrackeris authoritative here, consider dropping them. installation.mdnow shows a 4.0+ only example on a version-agnostic page. On 3.4/3.5 the reason is still on the command.
Questions
- Why the
TreeNodeTagpre-pass rather than matching the parent? The 3.x arm right below already matchesDataWritingCommandExec(_, w: WriteFilesExec). MatchingDataWritingCommandExec(cmd: InsertIntoHadoopFsRelationCommand, w: WriteFilesExec)and convertingwthere would drop the dependence on two invariants Comet does not control: thatwithNewChildrencopies tags, and that nothing ever handsCometExecRuleaWriteFilesExecwithout its enclosing command. Both hold today, neither is covered by a test. - Why is no
EliminateRedundantTransitionsarm needed forCometWriteFilesExec? The 3.x node needscase ColumnarToRowExec(nativeWrite: CometNativeWriteExec). The new node is equally columnar andCometExecRuleis apreColumnarTransitionsrule, so a transition would normally be inserted andexecuteWritewould hit "has write support mismatch". It works only because ofColumnar.scala:530-539, which passesoutputsColumnar = write.child.supportsColumnarfor aV1WriteCommandunderplannedWriteEnabled. That is load-bearing and would fail at runtime rather than at planning. Worth a one-line comment next to the existingoriginalPlan/ AQE note.
Test gaps
- Nothing distinguishes the #3426 fix.
DataWritingCommand.logicalPlanOutputWithNamesonly rewrites top-level names, so the fix is precisely about the top-level name diverging from the query's. TheINSERT INTO ... SELECTtest usesSELECT id, nameinto(id, name), and every other write test goes throughDataFrameWriterwhere the names match by construction. Something likeINSERT INTO t SELECT id + 1, upper(name) FROM s, asserting the written file's schema, is what would fail if the change were reverted. - No coverage of a non-lowercase
compressionoption (Major 1). - No partially empty multi-partition write, so the
sparkPartitionId != 0 && !hasNextbranch that must write no file but stillcommitTaskis unexercised. - No coverage of the third-party
WriteTaskStatsTrackerwarning path, which is the documented known limitation.
What is good here
Scoping is right and the additive framing is the right call. Nothing unrelated is refactored, and the two 3.x fixes that came along (URI.create on Path.toString, the HDFS escaping guard) belong here because the defect is shared. The abort test is the best of the new set: injecting through spark.sql.sources.commitProtocolClass both exercises the abort path with a real staging file to clean up and demonstrates the custom-committer claim the 3.x path could not honour, and asserting no surviving _temporary covers abortJob too. nativeWriteAllowIncompatKey and isNativeWriteExec keep the 33 pre-existing tests meaningful on both writers instead of skipping them on one. NativeWriteTask deliberately not capturing this is a real improvement over CometNativeWriteExec, which ships the converted plan tree in every task binary. And asserting that a fully native write leaves no fallback reason on the command is a good guard against exactly the explain regression this design invites.
Two compatibility notes for the record, both pre-existing and both correctly listed as follow-ups: spark.sql.parquet.outputTimestampType still defaults to INT96 while Comet writes INT64 micros (#3425), and the footer keys that drive rebase decisions on read are still missing (#3427). Both stay behind Incompatible plus the opt-in, so no regression, but worth keeping the "highly experimental" wording as loud as it is.
The one remaining per-row JVM loop is recordRows. It is inherent to the WriteTaskStatsTracker API with no bulk alternative, and at roughly a nanosecond per call it should not matter, but it is worth knowing about if a large write ever shows unexplained task time.
sunchao
left a comment
There was a problem hiding this comment.
Rechecked 39e02de3 against 7f1e0018. The Unicode HDFS directory counterexample from the previous review is fixed. The exact guard passed the devbox Scala/Hadoop component checks, including Unicode, ASCII and local-path controls, and the locked Rust URL comparison agrees with the new character check.
Two already-reported [P2] issues remain. The HDFS filename-prefix issue is unaffected because the guard checks only the directory, while the committer's basename can still lose the unique suffix during native URL parsing. The compression-option case mismatch also remains: Comet reads case-sensitive keys where Spark accepts mixed-case options. I have not duplicated either finding inline.
The inspected Spark 4.0 writer job passed 43 tests and the metrics job passed 15. These ran on merge d5bbfa54. The checked writer paths match this head, but other source and dependency changes remain. The new runs were component checks, not Spark/native/HDFS execution or a benchmark. The earlier HDFS reproduction is separate evidence, not my run. No additional verified P1/P2 emerged from this re-review.
| if (isSpark40Plus) { | ||
| plan.foreach { | ||
| case DataWritingCommandExec(cmd: InsertIntoHadoopFsRelationCommand, w: WriteFilesExec) => | ||
| w.setTagValue(CometExecRule.WRITE_OUTPUT_PATH, cmd.outputPath.toString) |
There was a problem hiding this comment.
Is it possible that we match the enclosing DataWritingCommandExec and pass cmd.outputPath explicitly when converting its WriteFilesExec child, avoiding this separate tag set and get?
There was a problem hiding this comment.
Yes, and it is better. Done in 268fdb9: the pre-pass is gone and the conversion happens at the command.
case d @ DataWritingCommandExec(cmd: InsertIntoHadoopFsRelationCommand, w: WriteFilesExec)
if isSpark40Plus =>
w.setTagValue(CometExecRule.WRITE_OUTPUT_PATH, cmd.outputPath.toString)
d.withNewChildren(Seq(convertToComet(w, CometWriteFiles).getOrElse(w)))The tag itself stays, because CometOperatorSerde only ever sees the operator and there is nowhere else to put the path, but it is now set and read inside one expression. That drops both of the invariants @comphead pointed at in the same round: that withNewChildren copies tags, and that nothing hands CometExecRule a WriteFilesExec without its enclosing command. Neither is Comet's to guarantee, and if the second one ever stopped holding the write now just stays on Spark instead of being converted against a missing tag.
WriteFilesExec moves to the unconditional "never tagged as a fallback" list as a result, since on both version paths the conversion decision is made at the command above it.
| // Spark 4.0+ hands over the exact file to write, chosen by the JVM commit protocol. | ||
| // Spark 3.x hands over a working directory instead and expects the writer to name the | ||
| // file; that branch goes away with Spark 3.x support. | ||
| let part_file = match &work_dir { | ||
| None => self.output_path.clone(), | ||
| Some(work_dir) => match task_attempt_id { | ||
| Some(attempt_id) => format!( | ||
| "{}/part-{:05}-{:05}.parquet", | ||
| work_dir, self.partition_id, attempt_id | ||
| ), | ||
| None => format!("{}/part-{:05}.parquet", work_dir, self.partition_id), | ||
| }, |
There was a problem hiding this comment.
nit, want to separate the comment into branches.
| // Spark 4.0+ hands over the exact file to write, chosen by the JVM commit protocol. | |
| // Spark 3.x hands over a working directory instead and expects the writer to name the | |
| // file; that branch goes away with Spark 3.x support. | |
| let part_file = match &work_dir { | |
| None => self.output_path.clone(), | |
| Some(work_dir) => match task_attempt_id { | |
| Some(attempt_id) => format!( | |
| "{}/part-{:05}-{:05}.parquet", | |
| work_dir, self.partition_id, attempt_id | |
| ), | |
| None => format!("{}/part-{:05}.parquet", work_dir, self.partition_id), | |
| }, | |
| let part_file = match &work_dir { | |
| // Spark 4.0+ hands over the exact file to write, chosen by the JVM commit protocol. | |
| None => self.output_path.clone(), | |
| // Spark 3.x hands over a working directory instead and expects the writer to name the | |
| // file; that branch goes away with Spark 3.x support. | |
| Some(work_dir) => match task_attempt_id { | |
| Some(attempt_id) => format!( | |
| "{}/part-{:05}-{:05}.parquet", | |
| work_dir, self.partition_id, attempt_id | |
| ), | |
| None => format!("{}/part-{:05}.parquet", work_dir, self.partition_id), | |
| }, |
| } | ||
| } | ||
|
|
||
| test("a failing task aborts, cleans up its staging file, and the retry succeeds") { |
There was a problem hiding this comment.
this test actually performs a second whole write after an injected commit failure; it does not demonstrate speculative attempts or an automatic task retry.
There was a problem hiding this comment.
You are right, and the name was claiming more than the test does. Renamed to a failing task aborts and cleans up its staging file in 268fdb9, and the comment now says what the second write is:
The second write at the end is a fresh write of the same data, not an automatic task retry and not a speculative attempt: it only shows that the failed job left nothing behind that would break the next one. Task-attempt isolation itself is Spark's
newTaskTempFile, and is still untested here.
| } catch { | ||
| case t: Throwable => | ||
| Utils.tryLogNonFatalError(committer.abortTask(taskAttemptContext)) | ||
| logError(s"Task ${taskAttemptContext.getTaskAttemptID} aborted: ${t.getMessage}", t) | ||
| throw t | ||
| } |
There was a problem hiding this comment.
writeNatively uses a plain finally:
try {
while (execIterator.hasNext) {
execIterator.next().close()
}
} finally {
execIterator.close()
}If execution throws A, and close() throws B, B replaces A. The outer task guard still aborts, but it receives the cleanup error instead of the original execution error.
close() can throw: it propagates teardown failures, including errors from the final native metrics update. The repository already has an injected metrics-failure test demonstrating that behavior. It also attempts every cleanup step and releases the native plan despite teardown errors, so this finding concerns preserving the original failure, rather than a demonstrated skipped plan release.
- Iterator cleanup
), - existing lifecycle test
Spark’s tryWithSafeFinallyAndFailureCallbacks preserves the original throwable and attaches cleanup failures as suppressed exceptions. Using that behavior here would also bring abort-error handling closer to Spark -- Spark helper
There was a problem hiding this comment.
Agreed, and fixed in 268fdb9 using the helper you pointed at, at both levels.
writeNatively now uses Utils.tryWithSafeFinally, so an execution failure survives a throwing close() with the cleanup error attached as suppressed rather than replacing it. executeTask uses Utils.tryWithSafeFinallyAndFailureCallbacks with committer.abortTask as the catch block, which is exactly FileFormatWriter.executeTask's shape — it also means a throwing abortTask is suppressed onto the original instead of being swallowed by the tryLogNonFatalError that was there before, and markTaskFailed now runs so Spark's task failure listeners fire.
While moving that I also took @comphead's related note in the same area: description.statsTrackers.map(_.newTaskInstance()) moved inside the guarded block, so a tracker whose constructor throws still reaches abortTask. Spark gets that for free because its trackers are built by the FileFormatDataWriter constructor, which already runs inside the same block.
sunchao
left a comment
There was a problem hiding this comment.
Rechecked unchanged head 39e02de3 against 7f1e0018 after the new discussion. I agree with Peter's failure-preservation concern [P2]: when execution throws A and iterator cleanup throws B, the plain finally replaces A with B. Cleanup still attempts native-plan release. Please preserve A and attach cleanup failures as suppressed exceptions, following Spark's task-write helper. A nine-case Scala component check using the exact control-flow blocks and injected test doubles confirmed the distinction; it did not execute Spark or JNI.
The earlier [P2] HDFS filename-prefix and mixed-case compression-option findings remain unchanged. I have not duplicated these existing comments inline.
Peter's retry-test clarification is also correct: it exercises abort followed by a second whole write, not automatic task retry or speculation. Explicit parent-path passing is a feasible simplification, while the current tag survives Spark's child replacement; I found no additional correctness defect in that mechanism.
CI remains green. Freshly inspected Spark 4.0 logs show 43 writer tests and 15 metrics tests passing on merge d5bbfa54. That merge uses newer main 424c31aa, and 12 of 16 authored files match this head. It is not full-tree validation of the assigned pair. No new native/HDFS integration run or benchmark was performed.
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.
|
Everything from this round is addressed in 268fdb9. Thanks @sunchao and @comphead — the two P2s were both real, and the second one had a second half I had not looked for. HDFS filename prefixes (#5763 (comment))Fixed, and the fix has two layers because one is not enough. The guard was checking the destination directory when the thing that has to survive intact is the whole committer-returned path. Planning can only decline what it can predict, though, and the path actually comes from I have no HDFS here, so the regression test exercises the predicate directly rather than through a write — space, Mixed-case compression option (@comphead, Major 1)Fixed, and you were right that the second half is the dangerous one. I took the stronger variant as well. Two tests. The load-bearing one is parquet write with a mixed-case unsupported compression codec falls back to Spark: with the case-sensitive read restored it fails, because Pinning the
|
sunchao
left a comment
There was a problem hiding this comment.
Re-reviewed 5f5397f5 against 7f1e0018. One P2 remains in HDFS basename admission, detailed inline.
Correctness
The Spark 4.x integration preserves Spark's job commit, SaveMode handling and catalog refresh while replacing the per-task writer. I rechecked version routing, AQE/logical links, columnar transitions, target schema and field IDs, compression selection, native filename ownership, and cleanup. The Spark 3.x execution path remains separate.
I reran the earlier HDFS filename reproduction using native and JVM code built locally from this exact head. With 100 rows in two input partitions, part?x, part#x and part space now fall back to Spark and preserve all 100 IDs. The ordinary part case still writes natively and preserves all 100 IDs. The previous silent-overwrite reproduction is addressed.
The remaining case is a literal % in the configured basename. Both part%foo and part%25 are accepted during planning, then fail in checkNativeWriteDestination. The Spark-writer controls succeed. This is a predictable configuration that should fall back before task execution, rather than a custom committer path that can only be checked at runtime.
Local validation
- Exact-head
cargo build --lockedsucceeded with the default native features. The native library copied into the JVM resources has the same SHA-256 as the local build. - Root-reactor Maven tests on Spark 4.1.3 / JDK 17 passed 66/66: writer 48, task metrics 15, and iterator lifecycle 3. Formatting/style checks in that invocation passed.
- Native writer tests: 4 passed, 4 ignored because those fixtures require their own HDFS setup. The URL-character test passed separately.
- A separate real MiniDFS integration ran 12 cases: ten successful Spark/native/fallback controls and the two reproduced percent-prefix failures. Successful cases checked every ID, and executed plans confirmed native writing versus fallback.
- Five separate Spark-helper component checks passed for original-error preservation, throwing close/abort callbacks, tracker-construction failure, self-suppression, and successful write/close/commit ordering. These are component checks, not injected native-writer failures.
CI
At the publication check, CI has 53 successful checks and ten skipped, with no failed or pending checks. I inspected the writer-suite logs: Spark 4.0/4.1/4.2 each passed 48/48, Spark 3.5 passed 38 with ten cancellations, and Spark 3.4 passed 36 with twelve cancellations. The Rust job passed 1,442 tests with five skipped.
Those CI jobs checked out merge 8931be12, combining this head with newer main f69c4c81. Its full tree differs from the reviewed head. The local build, focused suites and HDFS reproduction above provide separate exact-head evidence.
Performance and design
The smaller write boundary and explicit per-task state are justified. I found no additional confirmed ownership or performance regression. recordRows still performs one callback per row, and I have not measured its throughput cost. Concurrent speculative attempts were not tested. Existing experimental timestamp/footer and writer-property limitations remain outside the fixes in this PR.
| "HDFS output paths needing URI escaping are not supported: the native writer would " + | ||
| s"write to the escaped path while Spark commits the unescaped one ($shown)") | ||
| .orElse { | ||
| if (needsNativeUrlEscaping(fileNamePrefix)) { |
There was a problem hiding this comment.
[P2] Fall back for percent-bearing HDFS basenames during planning
Could we apply the Java URI-escaping check to fileNamePrefix as well? needsNativeUrlEscaping deliberately excludes %, so mapreduce.output.basename=part%foo or part%25 is accepted here. Once Spark includes it in the task filename, hdfsPathDivergence detects getRawPath != getPath and checkNativeWriteDestination aborts the job instead of falling back.
I reproduced both cases with a native scan over 100 rows in two partitions on Spark 4.1.3 and a real MiniDFS cluster, using native and JVM code built from 5f5397f5. The Spark-writer controls preserve all 100 IDs. With native writing enabled, both plans contain CometWriteFiles and fail with UnsupportedOperationException at the complete-path guard. The ordinary part control writes natively, and the fixed ?/# cases fall back successfully.
The basename is already available during planning. Please reject its Java-escaped forms here too, and add % basename cases to the admission and fallback regression coverage. The existing basename test currently covers ?, #, spaces and Unicode, but no literal percent.
sunchao
left a comment
There was a problem hiding this comment.
Rechecked 5f5397f5 against 7f1e0018. The mixed-case codec, ?/# basename fallback, and primary-error-preservation fixes are in place. The existing P2 for percent-bearing HDFS basenames remains: part%foo and part%25 pass planning, then fail the task guard. A fresh component probe of the exact JVM guard with Hadoop Path reproduced this; ordinary HDFS and local controls passed. Please include these prefixes in the planning fallback before merging.
I found no additional P1/P2. CI passed the 48 writer tests on Spark 4.0 and 4.1 using merge 8931be12; the JVM writer and suite match this head. That is separate from the latest merge preview. I did not rerun the native writer end to end, automatic task retries, or speculation locally.
Conflict: main (apache#5821) extracted withNativeWriter, captureWritePlan and assertNoCometNativeWriteExec from CometParquetWriterSuite into a new CometParquetWriterTestBase, while this branch had changed all three. The branch versions move into the base class so the new CometEmptyRelationParquetWriterSuite picks up the version-aware opt-in key and the CometWriteFilesExec case.
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.
sunchao
left a comment
There was a problem hiding this comment.
Rechecked a69b76c1 against 5705a58a. The remaining P2 about percent-bearing HDFS basenames is fixed. Planning now applies the Java and native escaping checks to the basename. A fresh component probe with the exact old/current helpers and real Hadoop Path reproduced the six old ASCII gaps and found none on this head. Ordinary HDFS names and local percent-bearing names still pass.
The update also covers AQE empty relations through the existing single-partition write path. Current Spark 4.1 CI passed all 49 writer tests and the empty-relation test on merge 5faaf694, whose changed files match this head. The Rust URL-escaping regression passed too.
No new or remaining verified P1/P2 finding. At 00:30 UTC, CI has 22 successful checks, 13 skipped and one still running (Spark 4.1 expressions). Local validation was a JVM helper probe, not an end-to-end native HDFS write. Concurrent speculative attempts and the statistics-callback cost remain unmeasured.
Which issue does this PR close?
Part of #2967 and #1625. Restructures the native write path on Spark 4.0+ 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.
Supersedes #5293, which made the same change but removed the Spark 3.x writer along
the way. That regression is what held #5293 back, so this version is purely additive:
Spark 3.4/3.5 keep the existing native writer, unchanged.
Rationale for this change
Native writes replace the whole
DataWritingCommandExec, which meansInsertIntoHadoopFsRelationCommand.runnever runs. Everything that method does has tobe re-implemented inside
CometNativeWriteExec: a hardcodedSQLHadoopMapReduceCommitProtocol(sospark.sql.sources.commitProtocolClassisignored),
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 4.0 added the right seam.
V1WritesUtils.getWriteFilesOptmatches theWriteFilesExecBasetrait there (introduced in 4.0 precisely for this), so a Cometnode that extends it gets driven through
FileFormatWriter.executeWrite→SparkPlan.executeWrite→doExecuteWrite, and Spark keeps ownership of everythingabove the per-task write.
Why this is additive rather than a replacement. On 3.4/3.5
getWriteFilesOptmatches the concrete
WriteFilesExeccase class. A Comet node there would not befound,
writeFilesOptwould beNone, and Spark would silently takeFileFormatWriter's non-planned, row-based branch, ignoringdoExecuteWriteentirely. The only way in on 3.x is to inherit from a case class, which brings
copy/equalshazards. SoCometDataWritingCommandandCometNativeWriteExecstayexactly as they are and remain the 3.4/3.5 path.
CometExecRulepicks the path bySpark version and the two never both fire. The 3.x path goes away with 3.x support.
What changes are included in this PR?
Spark 4.0+:
Spark 3.4/3.5 is unchanged:
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,CometDataWritingCommand,CometMetricNode.reportNativeWriteOutputMetricsand theEliminateRedundantTransitionsrule for native writes all remain and serve 3.x.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. The 3.x writer keeps
inventing its own names.
Pathbefore being parsed as a URI. Bothserdes receive it as
Path.toString, which decodes percent escapes, so a directorycontaining a space or a literal
%produced a stringURI.createrejects - a queryfailure on 4.0+, and a silent fallback to Spark's writer on 3.x. Covered by a
version-independent test.
planning, in both serdes.
create_hdfs_object_storehands the Rust-escapedurl.path()toobject_store::path::Path::parse, so the native writer would createdir%20with%20spaceorcaf%C3%A9while Spark's committer commits the unescapedname, and job commit would succeed with the data somewhere else. The gate covers the
destination directory and the effective
mapreduce.output.basename, which reachesevery committed file name: a basename holding
?or#is truncated by the nativeURL parser, so every task would write the same name and they would overwrite each
other at commit. The basename is checked by running the same predicate over the path
it produces rather than over the name alone, so it also catches a literal
%and theother characters
java.net.URIescapes while theurlcrate keeps them ([,^,|). A matching check runs again inexecuteTaskagainst the actualnewTaskTempFilepath, which a custom commit protocol owns and planning cannotpredict; that one fails the task rather than falling back. Sharing one predicate is
what keeps planning from admitting a name the task guard then aborts on, and the test
asserts that the two agree for every basename it covers. The character set the JVM
guard mirrors is pinned by a Rust test, so a
urlcrate upgrade cannot reopen thehole with a green build.
WriteJobDescription.dataColumnsrather than the query output. Spark'scastAndRenameQueryOutputnormally makes the two agree, so this is not by itself whatfixes [COMET NATIVE WRITER] INSERT INTO TABLE - complex type but different names #3426 - see the test note below - but
dataColumnsis what Spark treats asauthoritative and the only source that stays correct once partitioned writes land,
since
FileFormatWriterexcludes the partition columns from it.ParquetOptions, and the shared helpers live inNativeWriteUtilsso the Spark 3.xserde gets the same fix. On 4.0+ the writer's codec is additionally re-derived per
task from
CodecConfig.from(taskAttemptContext)- the sameCodecConfigthe fileextension comes from - so a file's name and its contents cannot disagree.
BasicWriteTaskStatsTracker, which stats files through theFileSystemAPI and is therefore correct on HDFS. The native writer'sstd::fs::metadatacall reports0there, soCometWriteFilespublishes nofiles_written/bytes_written/rows_writtenof its own on this path; the numbers onthe enclosing command are the authoritative ones.
executeTaskand the native write loop use Spark'sUtils.tryWithSafeFinallyAndFailureCallbacks/tryWithSafeFinally, asFileFormatWriter.executeTaskdoes, so a failure while aborting the task or closingthe Comet iterator is attached to the original failure as a suppressed exception
rather than replacing it. The stats trackers are constructed inside that guard, so a
throwing
newTaskInstancestill reachesabortTask.ParquetWriter.work_dirbecomes genuinely optional. When it is set (3.x) thenative writer derives the file name from it as before; when it is unset (4.0+),
output_pathis the exact file to write and is used verbatim.output_pathwasalready unused on the 3.x path, so no field changes meaning for an existing plan.
spark.comet.operator.WriteFilesExec.allowIncompatible,with the old
DataWritingCommandExeckey kept as a deprecated alternative.CometConf.isOperatorAllowIncompatnow resolves alternatives; the planner's by-namelookup previously bypassed the
ConfigEntry, so an old key would have readtruefrom the entry while the planner saw
false.WriteFilesExecdeclines dynamic partition overwrite (it is always a partitionedwrite) and
spark.sql.files.maxRecordsPerFile, which Spark's own writer uses to rolla new file every N rows.
Fixed along the way
AQE re-plans the write command's child and re-inserts a
WriteFilesExecabove thenode Comet already converted. On the 3.x path that needs an explicit guard in
CometExecRule(still present). LeavingDataWritingCommandExecin place on 4.0+means the situation cannot arise there.
How are these changes tested?
CometParquetWriterSuite: 49/49 on Spark 4.0, 4.1 and 4.2 - the 33 existing testsplus 16 new ones. Version-gated to 4.0+:
_SUCCESS(Comet writer doesn't create _SUCCESS file #2985), Spark-compatible filenaming, a custom
mapreduce.output.basenamebeing honored on local storage,INSERT INTO ... SELECTvisibility ([Native Writer] INSERT INTO ... SELECT fails due to stale catalog cache after write #3521),INSERT INTO ... SELECTwriting the target'scolumn names, dynamic-overwrite fallback, the
maxRecordsPerFilefallback (both thewrite option and the conf, verifying Spark's writer rolls 10 files), the schema-only
empty-input write (SPARK-23271), an empty non-zero partition writing no file and still
committing, task abort through an injected failing commit protocol, and the deprecated
opt-in key. Version-independent: output paths needing URI escaping, the HDFS
destination and basename guards plus the execution-time check and the assertion that
the two agree, a mixed-case
compressionoption being honored, a mixed-caseunsupported codec falling back, and the third-party
WriteTaskStatsTrackerwarning.CometParquetWriterSuiteon Spark 3.4 (36/36 + 13 cancelled) and 3.5 (38/38 +11 cancelled): every pre-existing test still passes on the 3.x writer, which is the
point of keeping it.
CometEmptyRelationParquetWriterSuite(1/1 on 4.0, 4.1 and 4.2), which landed infeat: support Spark 4 EmptyRelationExec as a native input #5821 while this branch was open. A native empty relation is a zero-partition RDD,
which is exactly why
CometDataWritingCommanddeclines it on 3.x: that writer onlymaps existing partitions, so no task runs and no file carries the schema. On the 4.0+
seam
CometWriteFilesExecswaps in a dummy single-partition RDD, as Spark's ownWriteFilesExecdoes, so partition 0 still writes the schema-only file. The suite'sreadback assertions pass unchanged; it now asserts a native write when the relation is
native and a fallback when it is not.
CometTaskMetricsSuite: 15/15 on both 3.5 and 4.1. The suite's native-write test nowpicks the version-appropriate opt-in key, so it genuinely exercises the native path on
both. It also confirms that dropping the node's own write metrics costs nothing: its
outputMetricscome from Spark's tracker.CometExecSuite(144),CometFallbackInvarianceSuite(6),CometPublicApiSuite(1).parquet_writerunit test asserting the writer uses acommit-protocol-chosen path verbatim (and that a non-zero partition id does not leak
into the name), and
url_path_rewritten_characters, which pins the exact set ofcharacters the
urlcrate rewrites inside a path so the JVM guard that mirrors itcannot silently drift.
cargo test -p datafusion-comet parquet_writer,cargo test -p datafusion-comet url_path_rewritten_charactersandcargo clippy --all-targets --workspace -- -D warningspass.A note on #3426, because the test came out differently from what I expected. The
scenario - Spark's own
INSERT INTO TABLE - complex type but different names, with atop-level rename added - passes on 4.0+ and fails on 3.5, but it fails on 3.5 by
returning no rows at all (#3521), not by writing the wrong names. Reverting
description.dataColumnstochild.outputdoes not change the outcome on 4.0+, becauseSpark's
castAndRenameQueryOutputhas already aliased the query's output to thetarget's names and cast the struct to the target's nested field names before Comet sees
anything. So what closes #3426 is leaving Spark's command in the plan, not the
dataColumnsline; the line is still the correct source to read, for the reasons above.Not covered, and not claimed: an end-to-end HDFS write, speculative concurrent attempts,
and any measurement of
recordRows.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), and the four footer metadata keys (#3427:
legacyINT96andtimeZonedrive rebase decisions on read, so omitting them is a correctness risk).Then partitioned (#3193) → bucketed (#3194) → object stores.