Skip to content

fix: match Spark's duplicate field and field id semantics in parquet field lookup - #5654

Open
dwsmith1983 wants to merge 4 commits into
apache:mainfrom
dwsmith1983:fix/parquet-field-id-semantics
Open

fix: match Spark's duplicate field and field id semantics in parquet field lookup#5654
dwsmith1983 wants to merge 4 commits into
apache:mainfrom
dwsmith1983:fix/parquet-field-id-semantics

Conversation

@dwsmith1983

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Part of the restructuring of #5365 requested in review: this extracts the duplicate field and field id matching semantics that previously traveled with the Delta contrib work, re-derived on top of the folding that #5602 added.

Rationale for this change

Three places where the native parquet field lookup diverges from Spark:

  1. remap_physical_schema only shields id-bearing logical fields whose id is missing from the file. When a logical field's id matches one physical field but a stray physical column carries that logical field's name, the stray column can still name-match through the expression adapter fallback and hijack the read.
  2. parquet_convert_struct_to_struct silently resolves a requested field id that matches more than one physical field to the first match. Spark raises the duplicate field error in field id lookup mode.
  3. Case sensitive exact name lookup on duplicate names resolves to the first field, while Spark builds its name map with .toMap, where the last entry wins.

What changes are included in this PR?

  • All id-bearing logical fields are shielded from name matching, the shield runs after the name match pass so a successful match claims the field first, and fake placeholder names skip a reserved set built from both schemas so they can never collide with real columns.
  • A requested field id resolving to more than one physical field raises SparkError::DuplicateFieldByFieldId (_LEGACY_ERROR_TEMP_2094). Duplicate ids that no requested field references remain harmless.
  • Exact name lookup on duplicate names resolves to the last field.

How are these changes tested?

Six tests written first; four failed on unmodified main (stray column hijacking the remap, case insensitive sibling null-filled, duplicate id silently reading the first match, first-wins name resolution), two pass on main and pin behavior that must not change. Full native suite 246 passed, clippy with -D warnings and fmt clean, and CometNativeReaderSuite on Spark 3.5 (58 succeeded) against the rebuilt native library.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@dwsmith1983
dwsmith1983 force-pushed the fix/parquet-field-id-semantics branch from 3d68f22 to e1d9eb3 Compare September 3, 2026 09:37
@dwsmith1983
dwsmith1983 requested a review from sunchao September 3, 2026 09:40
@dwsmith1983
dwsmith1983 force-pushed the fix/parquet-field-id-semantics branch from e1d9eb3 to 58e67fe Compare September 4, 2026 03:24
@sunchao

sunchao commented Sep 4, 2026

Copy link
Copy Markdown
Member

Reviewed head 58e67fee against base 55ae4f20. The PR is focused, but I found one new regression, an incomplete validation fix, and avoidable batch-processing overhead.

  1. [P2] Placeholder collisions can suppress column defaults.
    schema_adapter.rs:177 reserves exact names, while missing-column detection uses case-folded names. A generated __comet_unmatched_field_id_1 therefore collides with requested __COMET_UNMATCHED_FIELD_ID_1. In my reproduction, the base returns the configured default 7; this PR returns NULL.

    Reserve names using the existing folded schema names. That change passed the reproduction. Constructing the reservation set only when shielding needs it would also avoid extra hashing on ordinary reads without field IDs.

  2. [P2] Duplicate-ID validation still depends on whether a cast occurs.
    The new guard in cast_column.rs:292 misses identical physical/requested schemas. Such reads can omit the cast entirely or return before the guard.

    A real native Parquet scan of identical s<x: long id=1, y: long id=1> schemas returned [42, 43]; Spark 4.1.3’s schema-clipping check rejected the duplicate ID. This also occurs on the base, so it is an incomplete fix, not a new regression. Validation needs to cover reads that require no conversion.

  3. Avoid allocating a vector for every unique ID on every struct conversion.
    parquet_support.rs:270 changes the index to HashMap<i32, Vec<usize>>. An allocation probe using the old and new construction loops measured 8 → 264 allocations for 256 unique IDs.

    A compact unique/duplicate entry would preserve the behavior. Collect matching field names only when reporting an ambiguity. The new contains_field_id_metadata predicate also depends on immutable expression state and can be computed once.

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.

@dwsmith1983

Copy link
Copy Markdown
Contributor Author

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 FieldMapping tree (struct sources, list and large list elements, map key and value, leaf) for every logical field that holds a struct, mirroring Spark's clipParquetSchema at each nesting level. Duplicate requested ids and ambiguous case-insensitive names are detected there, stored per logical field, and raised when the column is referenced, whether or not a cast is later emitted. The per-batch conversion receives the resolved mapping and applies it positionally, so there is no hashing or per-id allocation on the batch path; the index type is a compact entry that records an index and an ambiguity flag, and matching names are collected only when the error is built. The contains_field_id_metadata predicate is gone; the relabel shortcut is gated on the mapping being positional instead.

Your repros: the identical s<x: long id=1, y: long id=1> schema now raises the duplicate id error with no cast in the plan, pinned in Rust through the exec path and in ParquetReadV1Suite (the Scala case fails against the previous native library and passes now). The folded placeholder collision returns the configured default again, pinned in the adapter tests with Spark-style key-value metadata on the file.

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.

When a logical field carries a Parquet field id, Spark's matchIdField
resolves it strictly by id and never falls back to a name match. The
remap previously only shielded id-bearing logical fields whose id was
missing from the file, so a stray physical column sharing such a
field's name could still name-match through the
DefaultPhysicalExprAdapter fallback and hijack the read. Shield every
id-bearing logical field name, run the shield after the name-match
pass so a legitimate name match claims the field first, and pick fake
names that skip real column names from either schema.
…lookup

A requested field id resolving to more than one physical field now
raises the same _LEGACY_ERROR_TEMP_2094 error as Spark's
foundDuplicateFieldInFieldIdLookupModeError instead of silently
reading the first match; unrequested duplicate ids stay harmless. The
case-sensitive exact-name lookup now resolves duplicate names to the
last field, matching Spark's caseSensitiveParquetFieldMap built with
.toMap where later entries overwrite earlier ones.
CometCastColumnExpr relabeled structs whose types differ only in field
metadata, skipping spark_parquet_convert and its duplicate field id check.
Guard the shortcut so id-based reads with field id metadata in the target
type always take the validating conversion path.
…here

The schema adapter now resolves how every requested nested field reads
from the file struct once per file, mirroring Spark's clipParquetSchema,
and raises a duplicate field id or ambiguous name for any referenced
column whether or not a cast is emitted. Identical file and requested
schemas with a duplicated id are rejected as Spark rejects them.

The resolved mapping is handed to CometCastColumnExpr and applied
positionally per batch; the relabel shortcut runs only when the mapping
is positional. Per id and per name lookups use a small Copy entry and
gather matching names only when reporting an ambiguity, so a wide struct
allocates nothing per field id.

Placeholder names generated for shielded file columns are reserved
against the folded logical and physical names that downstream lookups
compare, so a requested column differing only by case keeps its default.
The reservation set is built on the first placeholder only.
@dwsmith1983
dwsmith1983 force-pushed the fix/parquet-field-id-semantics branch from a69b5f5 to 56a5785 Compare September 4, 2026 18:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants