fix: preserve Catalyst nullability and field IDs in native Parquet writes - #5369
Open
sunchao wants to merge 1 commit into
Open
fix: preserve Catalyst nullability and field IDs in native Parquet writes#5369sunchao wants to merge 1 commit into
sunchao wants to merge 1 commit into
Conversation
sunchao
marked this pull request as ready for review
August 15, 2026 20:44
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why are the changes needed?
Closes #5305.
A Parquet write has two different schemas: the schema of the Arrow batches arriving at the native writer, and the schema Spark intends to persist in the Parquet file. The first describes how data is transported through execution. The second is the durable contract every future reader sees in the file footer. Comet currently treats them as the same schema, even though they are not.
The native writer receives data through a placeholder scan that marks top-level fields nullable and does not retain their Catalyst field metadata. It then builds the Parquet schema by renaming those incoming Arrow fields. The values are written successfully, but Spark's intended nullability and Parquet field IDs are already gone.
For example, suppose the Catalyst write schema is:
Today, Comet writes a footer equivalent to:
Spark expects:
The file may still look correct when read back immediately by column name, which makes the problem easy to miss. However,
required_numberhas silently become nullable, and both columns have lost the stable identities needed for schema evolution. For example, an evolved read schema should still identify the same columns after both a rename and a reorder:Without field IDs in the footer, Spark cannot recover that mapping. Its field-ID-aware reader fails by default with:
Setting
spark.sql.parquet.fieldId.read.ignoreMissing=trueavoids the exception but can return nulls instead, turning a visible compatibility failure into incorrect results.Nested collections introduce another, less obvious version of the same problem. A schema using Delta-style column-mapping metadata can assign separate IDs to the synthetic fields inside lists and maps:
Because
element,key, andvalueare not CatalystStructFields, their IDs are stored on the nearest parent field underparquet.field.nested.ids. Copying only ordinary struct-field IDs would still produce incomplete Parquet schemas for these nested cases.What changes were proposed in this PR?
This PR makes the Catalyst write schema, rather than the intermediate Arrow input schema, the source of truth for the Parquet file. At the Spark/native boundary, the existing writer operator now carries the complete target schema alongside the incoming data. The native side reconstructs that schema and uses it to initialize the Parquet writer, so the footer receives Spark's actual column names, required/optional annotations, and field IDs.
The conceptual change is:
The schema travels recursively rather than stopping at top-level columns. Ordinary nested struct fields retain their own IDs and nullability, while list-element and map-key/value IDs are recovered from the nearest parent field's Delta-style metadata. Relative collection paths are preserved through nesting and restart at each nested struct, matching how those IDs are represented in Catalyst.
Incoming Arrow batches can still use their existing execution-oriented field names and metadata. They are aligned with the target write schema without weakening validation of physical data types or nullability. Spark's
spark.sql.parquet.fieldId.write.enabledsetting remains authoritative: its default-enabled behavior preserves IDs, and explicitly disabling it suppresses IDs consistently at every nesting level. Older serialized plans without a target write schema retain their existing behavior.The change applies directly to Comet's existing Spark 3.5/4.0 native Parquet writer. It preserves compatibility with Delta-shaped and Iceberg-style field-ID schemas, but does not claim to replace or accelerate Delta's separate transactional writer. It is independent of the writer-seam refactor in #5293.
How was this PR tested?
The new Scala regressions execute the actual
CometNativeWriteExecpath and inspect the footer of every generated Parquet part file. They verify required versus optional fields, top-level and nested IDs, list/map synthetic IDs, the default/explicitly enabled/disabled field-ID settings, and a real Spark read with both renamed and reordered columns. The read-back test disables Comet so the resulting file is interpreted by Spark's own field-ID reader.The nullability regression was first reproduced against the unfixed writer, where it failed with
OPTIONAL did not equal REQUIRED.The complete Spark 3.5 native writer suite passes, including all three new regressions and existing coverage for complex types, compression, and save modes:
./mvnw -o -Pspark-3.5 test -Dtest=none \ -Dsuites=org.apache.comet.parquet.CometParquetWriterSuiteResult: 33 tests passed.
The focused native Parquet regressions also pass under Spark 4.0:
Result: 3 tests passed.
Rust coverage validates absent versus zero-valued field IDs, nested list/map metadata, preserved nullability, and a real written Parquet footer:
Results: 3 serde tests passed and 2 writer tests passed; four pre-existing HDFS integration tests remain ignored because they require a running HDFS cluster. Maven Spotless and Scalastyle checks also passed under both Spark profiles.