fix: match Spark's duplicate field and field id semantics in parquet field lookup - #5654
dwsmith1983 wants to merge 41 commits into
Conversation
sunchao
left a comment
There was a problem hiding this comment.
This fixes stray-name matching and placeholder collisions, and adds nested duplicate-ID rejection and last-wins exact-name lookup. One gap remains: metadata-only struct relabeling bypasses the duplicate-ID check, as detailed inline.
I compared the code with maintained Spark 3.5 and 4.0 sources. Eight component-check groups passed using extracted Comet helpers with Arrow/Parquet 58.4.0 and DataFusion 54.1.0. A separate probe reproduced the cast bypass and verified a renamed-child control. These probes use limited scaffolding and are not full Comet scan, JNI or Spark query tests. The reported 246 native and 58 Spark 3.5 tests are the author's results.
At 04:51 UTC, current-head CI had 29 successful, 32 running and 7 skipped checks. Full CI validation was still pending.
| // Mirror Spark's `foundDuplicateFieldInFieldIdLookupModeError` | ||
| // (`_LEGACY_ERROR_TEMP_2094`): a requested ID resolving to more | ||
| // than one file field is ambiguous. | ||
| Some(indices) => { |
There was a problem hiding this comment.
[P2] Run duplicate-ID validation before metadata-only struct relabeling
Could you route metadata-only struct adaptations through this validation too? For file struct s<x: int id=1, y: int id=1, z: int id=2> and requested s<x: int id=1, y: int id=3, z: int id=2>, Spark rejects requested ID 1 as ambiguous. DataFusion emits a struct cast, but CometCastColumnExpr::evaluate takes types_differ_only_in_field_names and calls relabel_array, because that predicate ignores field-ID metadata. The new lookup never runs and leaves all three physical values in place. A focused probe using the current cast expression and a real Arrow/Parquet round trip returned [42, 43, 44], while renaming requested x made the same input reach the duplicate-ID error. Could you guard the relabel shortcut for ID-based reads and add a cast-expression or scan regression with unchanged child names?
There was a problem hiding this comment.
Good catch, the shortcut sailed right past the new validation. Fixed in 3d68f22: the relabel arm is now guarded so that when use_field_id is set and the requested type carries field id metadata, evaluation falls through to the struct conversion where the duplicate id lookup runs. Chose the guard at the call site rather than inside types_differ_only_in_field_names since that predicate is a pure structural comparison with no access to the parquet options. Your exact probe is now a regression test (unchanged child names, duplicate id 1, asserts the 2094 error) plus a companion pinning that the fast path survives for name only differences without ids and for the flag alone.
3d68f22 to
e1d9eb3
Compare
e1d9eb3 to
58e67fe
Compare
|
Reviewed head
The strongest design improvement is to resolve and validate requested fields once per file schema, then reuse the mapping across batches. That addresses the validation bypasses and repeated lookup work together. Metadata-only relabeling remains safe when the resolved mapping is positional. A small mapping object is a useful abstraction here. Validation: 100 native Parquet tests passed, with default HDFS features disabled. Additional head/base probes confirmed both correctness cases. Performance evidence measures component allocations, not overall scan speed. CI snapshot: 57 passed, 7 running, 7 skipped. Nothing was posted to GitHub. |
|
Thanks, all three are addressed in a69b5f5, following the once-per-file design you suggested. The physical expression adapter factory already runs once per file schema, so it now resolves a small Your repros: the identical One residual worth naming: DataFusion's opener skips the adapter entirely when the logical and physical schemas compare equal and no predicate exists. Spark-written files always carry key-value metadata that arrow-rs folds into the physical schema, so they always go through the adapter, but a file with no metadata at all and duplicated ids inside a struct would still read positionally. Happy to cover that in a follow-up if you think it matters. |
96aa08d to
cc2ffa1
Compare
4dba460 to
13c4afc
Compare
|
@sunchao the once-per-file mapping round covering your three findings is pushed. Ready for another look. |
andygrove
left a comment
There was a problem hiding this comment.
convert_struct in native/core/src/parquet/parquet_support.rs around line 563 now calls array.column(from_index) with an index resolved at planning time against adapted_physical_schema, where the old code derived it from the runtime array's own DataType. The only guard is sources.len() != to_fields.len(), which checks the target side. What guarantees the struct array the reader hands back always carries the same children in the same order as the physical field the mapping was resolved against? If that can drift at all, this is an index panic in the executor rather than a DataFusionError. Checking from_index against array.num_columns() would bound the worst case.
The description lists last-wins exact-name resolution as one of the three fixes and resolve_struct_mapping does it for struct children. At the top level in case-sensitive mode with no field ids, needs_remap is false in schema_adapter.rs around line 520, so resolution falls to DefaultPhysicalExprAdapter, which goes through Schema::index_of and returns the first match. Spark builds caseSensitiveParquetFieldMap at the root message level with the same .toMap it uses for nested groups. Was the top level deliberately left out of scope?
The field-id ambiguity path is covered from several angles now. The case-insensitive name ambiguity that resolve_struct_mapping raises around line 388 does not appear to have a companion test in the new struct_field_matching module. It might be worth pinning that half too, since it is the branch that decides between an error and a silently wrong column.
On the residual you named where DataFusion skips the adapter when the two schemas compare equal and there is no predicate, I confirmed that short circuit in the 55.0.0 opener. Could you open a tracking issue and link it here so it does not get lost? The branch also conflicts with main right now and needs a rebase before anything meaningful runs against it.
|
Rebased onto main and the three points are in the head (b438dce). Bounds: Root last-wins: not deliberate, the top level had simply fallen to the default adapter. With duplicate exact names at the root in case-sensitive mode the remap path now runs, the shadowed earlier fields get a placeholder name so the default adapter's The case-insensitive ambiguity now has its companion test in The opener short circuit is tracked in #5801. |
|
@comphead would you like me to cut it down? |
…solution Planning now declines a requested schema that repeats a field id, so the check that ran while loading each footer no longer has a case to catch. The reader factory and the scan setup go back to main, with the tests that drove the check, and the error conversion in the JNI bridge goes back too, since every kept error is raised at the top level and reaches the JVM as before. What stays is the resolver raising Spark's duplicate-id error for an ambiguous requested id at any depth, exact names bound the way Spark binds them, the id shield against a stray same-name column, one list definition across the mapping paths, and the field mapping resolved once per file.
Cut down in 24030e5, as offered above. The footer-time validator is gone with its tests, so the reader factory and the scan setup are back to main and the Rust test bulk with them. What stays is what #5786 does not cover: the resolver raising Spark's duplicate-id error for an ambiguous requested id at any nesting level, exact-name lookups that bind the way Spark's do, the id shield against a stray same-name column, one list definition across the mapping paths, and the field mapping resolved once per file instead of per batch. The JNI cause-chain walk went too: restoring main's error conversion left every kept test green, because the adapter raises its errors at the top level, so that piece only served the footer check. The description is rewritten to match, with the remaining gap stated in full: a metadata-free file whose schema equals the requested one is still read positionally because the opener skips the adapter, #6004 covers the case where the requested schema itself repeats an id, and nested duplicate names in such a file are tracked in #6136. Against main the diff is now 6 files changed, 2102 insertions(+), 356 deletions(-), down from 9 files with 2898 insertions and 384 deletions; the reader factory, the scan setup and the JNI error conversion are back to main byte for byte. |
|
@comphead could you take another look? |
|
Thanks @dwsmith1983 @andygrove |
…semantics Main now rejects duplicate Parquet field names before decoding, so the root first-wins binding, its shadow rename and their tests are dropped and main's checks stand. The resolver keeps Spark's case-insensitive duplicate error for byte-identical siblings, and an opaque Variant decode runs main's physical name check first. A test records the one divergence left from main: a requested id that matches exactly one field is still rejected when its root name repeats.
What this PR still fixes is field ids, which #5786 does not touch. A requested id that two file fields carry inside a struct. File Swapped ids inside a struct. File The id shield in The rest is the once-per-file In the merge, main's One divergence from Spark remains after the merge, and it comes from #5786 rather than from this branch. |
| // Relabeling only swaps metadata, so it is right when every requested field reads | ||
| // the file field at its own position. A mapping that reorders fields (ids resolved | ||
| // to other positions) has to go through the nested conversion below. | ||
| let positional = self |
There was a problem hiding this comment.
This gate fixes a silent wrong-result bug on main, and I filed #6192 for it. Your summary covers swapped ids, but the shape I'd expect users to actually hit is a nested column dropped and added back under the same name. A file with s struct<x (id 1), y (id 2)> read as s struct<x (id 3), y (id 2)> returns the old x values on main, where Spark returns null. The same happens inside a list element and a map value. All of those match Spark 4.1.3 on this branch, with the native scan in the plan.
Could you add Closes #6192 to the description, plus a ParquetReadSuite test that runs the drop-and-re-add read through checkSparkAnswerAndOperator for a struct, a list element and a map value? At the moment only test_swapped_field_ids_bypass_relabel_shortcut covers this, at the expression level.
There was a problem hiding this comment.
Could you add
Closes #6192to the description, plus a ParquetReadSuite test that runs the drop-and-re-add read through checkSparkAnswerAndOperator for a struct, a list element and a map value?
Added Closes #6192 to the description and three ParquetReadSuite tests, nested column dropped and re-added under the same name with a new field id: struct, : list element and : map value. They share one file, written with field ids on, holding struct<x (id 1), y (id 2)> as a top level column, as a list element and as a map value, and each reads its column with x carrying id 3 and y keeping id 2. Each runs through checkSparkAnswerAndOperator, asserts CometNativeScanExec is in the plan, and pins the expected rows explicitly: x is null and y keeps its values, including a null struct inside the list and the map, empty containers, and a null column.
One thing the run turned up: Spark 3.5's own vectorized reader raises on the list and map reads (ParquetColumnVector rejects the _fake_name_ field that clipParquetSchema generates for the unmatched id below a list or map), and Spark 4.0.4 still has that check, so for those two the comparison with Spark runs from 4.1 on and the pinned rows carry the check on 3.5 and 4.0. The struct case compares with Spark on every version.
With the gate in cast_column.rs forced to always take the relabel shortcut, all three fail with the old x values: the struct test returns [1,[1,10]] and [2,[5,50]] where Spark returns [1,[null,10]] and [2,[null,50]], the list test returns [2,20], [3,30] and the map test k -> [4,40] in place of nulls. With the gate as pushed all three pass, on Spark 3.5 and 4.1.3.
| /// fields. The validation must therefore run when the file schema is mapped, not | ||
| /// only inside a cast. | ||
| #[tokio::test] | ||
| async fn parquet_duplicate_struct_field_id_rejected_without_cast() { |
There was a problem hiding this comment.
#6004 now declines a requested schema that repeats an id at planning, so this test covers a read Spark can no longer hand to the native scan, and the reasoning in its doc comment no longer applies. The reachable version is @sunchao's shape from the first round, s<x (id 1), y (id 1), z (id 2)> read as s<x (id 1), y (id 3), z (id 2)>. main returns all three values there, Spark raises, and this branch raises too. Its test (test_field_id_read_rejects_duplicate_ids_despite_matching_names) was dropped in b4549c9d2. Could this one use that shape instead?
There was a problem hiding this comment.
Could this one use that shape instead?
Yes. The test is now parquet_duplicate_file_field_id_rejected_when_requested and uses the shape from the first round: the file holds s<x (id 1), y (id 1), z (id 2)> and the read asks for s<x (id 1), y (id 3), z (id 2)>, so the duplicate sits only in the file and a planner that declines repeated ids in the requested schema still hands this read to the native scan. The scan raises the duplicate id error for requested id 1 matching x and y, where a positional read would hand back all three values. The doc comment says why the identical-schema shape is no longer reachable and what this one proves instead.
| } | ||
|
|
||
| /// Comma-joined names of the fields carrying `id`, for the duplicate-id error message. | ||
| pub(crate) fn field_names_with_id(fields: &Fields, id: i32) -> String { |
There was a problem hiding this comment.
Spark brackets this list in matchIdField with mkString("[", ", ", "]"), so its message reads Found duplicate field(s) "1": [x, y] in id mapping mode and ours reads "1": x, y. Now that this helper formats the list for root and nested fields alike, could it add the brackets? The Scala test could then compare the whole message with Spark's instead of just the prefix.
There was a problem hiding this comment.
Now that this helper formats the list for root and nested fields alike, could it add the brackets?
Done. field_names_with_id now returns the list bracketed and comma joined the way matchIdField renders it, so the message Comet hands to foundDuplicateFieldInFieldIdLookupModeError reads Found duplicate field(s) "1": [x, y] in id mapping mode on both sides. The Rust display string dropped its own brackets so the list is not wrapped twice, and the Rust assertions pin id=1 matches [x, y] rather than only the id.
On the Scala side, multiple id matches and duplicate field id inside a struct is rejected when a requested id matches two fields run the same read with Comet off, take the duplicate id message Spark raises, check that it lists the expected fields ("1": [a, rand2] for the root case, "1": [x, y] for the struct), and assert that Comet's message equals it in full.
… id message A nested column dropped and added back under the same name gets a new field id, so the requested struct matches the file's by name and type but not by id. The relabel shortcut used to hand back the old values there. Three reads through the native scan now pin Spark's answer for a struct, a list element and a map value, and fail with the old values when the gate is forced open. Before Spark 4.1 the vectorized reader cannot read the list and map shapes at all, so those compare with Spark from 4.1 on and check the pinned rows everywhere. The duplicate field id message brackets the field list the way Spark's matchIdField does, and the Scala tests compare the whole message with Spark's. The adapter test for a repeated id now uses a shape a planner that declines repeated requested ids still hands to the native scan: the duplicate sits in the file and the read asks for the repeated id once.
andygrove
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround on today's comments. The brackets, the drop-and-re-add tests and sunchao's shape all look right, and I checked the 3.5 and 4.0 gating against ParquetColumnVector in 3.5.9 and 4.0.4, which do have that check. Since your runs were on 3.5, I ran everything on the default Spark 4.1 profile, and it's all green. I also disabled each of the new guards in turn, the relabel gate, the nested and root duplicate-id errors, and the nested raise in rewrite, and every one of them has a test that fails without it.
On narrowing id_duplicate_roots, I'd keep it as it is. I measured the shape on a file with roots a (id 1) = 1, a (id 2) = 2 and b (id 3) = 3, in both case modes. With the guard removed, Comet returns [1, 2] for a read of id 1 or id 2, which is two rows from a one-row file. Spark doesn't give a usable answer either. Its vectorized reader returns [1] for both ids, so id 2 comes back with id 1's value, and the parquet-mr reader fails outright. That's the same name-based leaf lookup #5964 describes. So the rejection protects the decoder rather than diverging from a Spark behavior we could match. Could the doc comment on known_divergence_repeated_root_name_rejects_unambiguous_field_id say that, and link #5964? As written it reads as a gap waiting to be closed, and closing it would bring #5783 back at the root.
| .map(|(_, f)| f.name().as_str()) | ||
| .collect(); | ||
| if parquet_options.case_sensitive { | ||
| return Err(SparkError::Internal(duplicate_parquet_field_message( |
There was a problem hiding this comment.
This turns main's error into an INTERNAL_ERROR. SparkError::Internal goes through SparkErrorConverter as SparkException.internalError, so a requested nested duplicate now reaches the user as [INTERNAL_ERROR] Found duplicate Parquet field name 'dup' SQLSTATE: XX000. That tells them they've hit a bug in the engine, for a limitation scans.md documents. On main the same read raises CometNativeException with the plain message. On this branch the root check and the non-pruning path still do, so the same limitation surfaces as two exception classes depending on nesting. In the test log, all ten duplicate Parquet field names fail clearly cases, the schema-merge test and the fixture test take the INTERNAL_ERROR path. The tests don't notice because they only match the message text. Could the resolver raise the same error main does here? And would you add an assertion on the exception class in one of those tests, so the paths can't drift apart again?
| } | ||
| } | ||
|
|
||
| test("duplicate exact nested names are refused when requested and skipped otherwise") { |
There was a problem hiding this comment.
Is there something about duplicate-nested-names.parquet that a Spark-written file can't reproduce? It was written by pyarrow 25.0.1, and nothing in the repo says how to regenerate it. The two reads it covers, s.other succeeding and s.dup refusing, are the pair CometNativeReaderSuite already covers on main with a file written from named_struct('dup', id, 'dup', id + 100, 'other', id + 900). If the pyarrow writer is the point, could the test say why? Otherwise I'd drop the test and the binary, which also takes a little off the diff comphead was concerned about.
Which issue does this PR close?
Closes #6192: the native scan read a nested struct by position when the requested fields matched the file's by name and type but not by id, so a nested column dropped and added back under the same name came back with its old values where Spark returns null. The relabel shortcut is now taken only when the resolved mapping is positional.
Relates to #5884, the sibling-name half of duplicate field resolution, and to #5936, whose gate is addressed in #6116. #5801, which an earlier revision closed, was closed by #6004.
Rationale for this change
Spark's
ParquetReadSupport.clipParquetGroupFieldsresolves requested fields by id at every nesting level and raises_LEGACY_ERROR_TEMP_2094when one requested id matches more than one file field. The native scan raised it only for root columns and silently read the first match below the root.Spark resolves exact names through a map whose last entry wins inside a struct, while its reader binds the first file column at the root. The native scan did neither consistently.
An id-bearing requested field must never fall back to a name match, and the field mapping between the requested and file schemas is a per-file decision, not a per-batch one.
What changes are included in this PR?
resolve_field_mappingandresolve_struct_mappingraise Spark's duplicate-id error for an ambiguous requested id at any depth, andremap_physical_schemadefers a root ambiguity until a read references that column, so an unrequested duplicate stays harmless._LEGACY_ERROR_TEMP_2093for case variants and byte-identical siblings alike, while an exact match still reads. One divergence from Spark is kept from main and recorded by a test: a requested id that matches exactly one field is still rejected when its root name repeats, where Spark'smatchIdFieldreads it.remap_physical_schemaruns after the name match, so a plain name match claims its column and only a genuine collision with an id-bearing request is renamed away; placeholder names skip every name either schema holds.list_element_fielddecides what a list is for the resolver, the converter and the adapter's struct walk, so aList<Struct>read as aLargeList<Struct>resolves its element fields the same way.CometCastColumnExpr::with_parquet_options; a mapping that reorders fields disables the metadata-only relabel shortcut.What this PR does not do: a Parquet file without key-value metadata whose schema equals the requested schema is still read positionally, because DataFusion's opener skips the expression adapter for it, so none of the resolution above runs for that file. #6004 covers the case where the requested schema itself repeats an id. Nested duplicate names in such a file, including two names that collide case-insensitively, still read by position where Spark raises; that is tracked in #6136.
How are these changes tested?
Rust, in
parquet_support.rsandschema_adapter.rs, each written before the change it pins: an ambiguous requested id inside a struct, inside a list element and at the root raises, with the unrequested duplicate as the control; exact duplicate names are refused when requested and skipped otherwise; case-insensitive ambiguity raises for case variants and for byte-identical siblings while an exact match reads; an opaque Variant decode with duplicate physical children is refused by the physical name check; the id shield beats a stray same-name column and its placeholder never folds onto a requested name; list representations resolve by id acrossListandLargeList; a reordering mapping bypasses the relabel shortcut. Two scans throughDataSourceExecexercise the adapter end to end.Scala, in
ParquetReadSuite: the Spark portmultiple id matches, a duplicate id inside a struct on a Spark-written file asserting the native scan and Spark's error, and a differential read over the checked-induplicate-nested-names.parquetfile comparing against Spark.Locally on Spark 3.5: the parquet modules of the core crate, the JNI bridge crate, clippy, fmt,
ParquetReadV1SuiteandCometNativeReaderSuitethrough the full reactor, test-compile on 3.4, 4.0 and 4.1, spotless and the semantic scalafix check. Spark's ownParquetFieldIdIOSuiteruns in the Spark SQL job and needsrun-spark-4.1-testsfrom a maintainer.