comphead commented on PR #5786:
URL:
https://github.com/apache/datafusion-comet/pull/5786#issuecomment-5721458036
## Review: reject duplicate Parquet field names before decoding
Reviewed the full diff plus `schema_adapter.rs`, `parquet_support.rs`,
`name_fold.rs`, parquet-rs 59.2 and datafusion-datasource-parquet 55.0/55.1.
Grouped by severity, then simplification and test dedup.
### Blockers
**1. Does not compile against current `main`: `fold_name` and
`is_pure_structural_narrowing` are now fallible.**
The head here is 60 commits behind. On `main`, `name_fold.rs:144` is
`fold_name(..) -> DataFusionResult<String>` and `schema_adapter.rs:110` is
`is_pure_structural_narrowing(..) -> DataFusionResult<bool>` (#5845, merged).
Both are called infallibly in `validate_field_names`, `validate_field_type`,
and the `selected` builder.
The `?` is not the hard part. `find(|candidate| fold_name(a, cs) ==
fold_name(b, cs))` and `map_or_else(|| .., |source| if
is_pure_structural_narrowing(..) { .. })` are infallible-closure positions, so
this needs restructuring — which is the same restructuring finding 2 asks for.
**2. Name matching is O(physical x projected) with two `String` allocations
per comparison, against this module's explicit bulk-fold contract.**
In `validate_field_names`, `fold_name(field.name(), case_sensitive)` is
recomputed inside the inner `find` for every candidate, though it is
loop-invariant. `fold_name` allocates even in case-sensitive mode
(`name.to_string()`), and in case-insensitive mode with non-ASCII names it
takes an `RwLock` read and can cross into the JVM per call. The same nested
`find` repeats in the top-level `selected` builder. This runs per file open, at
every nesting level.
The neighbouring code deliberately does the opposite:
- `schema_adapter.rs:125` — *"Fold the source field names once (O(sources),
not O(targets x sources)), matching this file's bulk-fold convention."*
- `parquet_support.rs::match_struct_fields` — folds `from` + `to` in one
`fold_names` call, `split_at`s, then builds `folded_to_indices: HashMap<&str,
Vec<usize>>` and uses `indices.len() > 1` as the ambiguity check.
- `name_fold.rs` module docs — keeping the policy in one module *"is what
stops those copies from drifting apart, which is the drift that produced #5495
in the first place."*
This adds a fourth copy of the resolution policy, with a raw-name `HashSet`
for dedup instead.
Direction: reuse `match_struct_fields`, or at minimum fold each group's
names once with `fold_names` and build the `folded -> Vec<index>` map.
`indices.len() > 1` becomes the duplicate check, the `HashSet` goes away, and
the `?` from finding 1 lands naturally.
### Major
**3. Field-ID reads validate the entire file, turning working queries into
errors.**
`with_required_schema` drops the projection whenever `options.use_field_id`
is set — not only when the requested schema actually carries IDs. Reading
column `b` by ID from a file whose unrelated column `a` is duplicated now
fails, though no `a` leaf is ever decoded. The `renamed_b` / `parquet.field.id
= 3` block in `CometNativeReaderSuite` asserts exactly this new failure.
`schema_adapter.rs` already has `schema_has_field_ids()` and
`remap_physical_schema`'s `id_to_phys_names`. Gate on those and resolve the
selected roots by ID, or at minimum use `!use_field_id ||
!schema_has_field_ids(required)`.
**4. No evidence the repro still fails on current `main`. Please re-verify
per shape.**
The published head predates DF 55.1 (#5865) and #5845, and the follow-up
numbers in the thread are for an unpublished branch. Two things make a fresh
check worth the time:
- DataFusion's cast-clipping already pins duplicate handling:
`nested_schema_pruning.rs:611 clip_keeps_duplicate_physical_field_names` shows
`struct<a, pad, a>` with target `struct<a>` keeping **both** leaves and
emitting `struct<a, a>`. Present identically in 55.0 and 55.1. So the
multiplication is not produced by the clipping path, and it would help the PR
to name the path that does produce it.
- The `missing`-field case looks like it may be a new failure rather than a
fixed one. For `s struct<other: bigint, missing: bigint>` over file
`struct<dup, dup, other>`: the target names no duplicated field,
`is_pure_structural_narrowing` is false so nothing is clipped, and the opener
hands the reader the *physical* schema (`opener/mod.rs:959`, `with_schema` is
only ever given `physical_file_schema`), i.e. 3 distinct leaves to 3 children.
If that reads correctly on unpatched `main`, this PR converts a working read
into a hard error, and the `"Missing fields require Comet's cast, which decodes
the complete physical struct"` assertion locks the regression in.
Running all five parameterized shapes plus the `missing` case against
unpatched `main` and stating which actually fail would settle both.
**5. Rejection pre-empts #5654's last-wins resolution.**
Already raised by @andygrove, so just the compatibility framing: Spark's
`ParquetReadSupport` builds the map with `.toMap`, so last-wins is the
Spark-compatible answer and the issue accepts an error only as a
strictly-better-than-wrong-results fallback. Worth stating in the PR body which
shapes this claims permanently and which are a placeholder until #5654.
### Simplification
**6. Stray `ponytail:` marker** in the `// Validate cache hits too` comment.
No other occurrence in the tree; reads like a leftover internal tag. Make it
`TODO(#5884)` or drop it.
**7. Use the hint-aware physical schema instead of the `schema_hints` escape
hatch.**
`parquet_to_arrow_schema(descr, None)` deliberately discards `ARROW:schema`,
which then forces pruning off for every file that carries one.
`ArrowReaderMetadata::try_new(Arc::clone(&metadata),
ArrowReaderOptions::new())` (parquet 59.2, `arrow_reader/mod.rs:942`) returns
`.schema()` with the hint applied — the schema the decoder will actually use.
Feed that to `is_pure_structural_narrowing` and the `schema_hints` flag, its
conservative branch, and the dictionary regression test all disappear. Worth
doing: `ARROW:schema` is on essentially all pyarrow output, so the conservative
branch is the common path, not the rare one.
**8. Walk Arrow `Fields` rather than the raw Parquet `Type` tree.**
The LIST arm re-implements parquet-rs's 2-level/3-level heuristic
(`complex.rs:596`) while omitting its `!repeated_field.is_list()` and
`!has_single_repeated_child()` guards. It is conservative today (the omissions
only route more shapes to full validation), but it is a hand-copy of an
internal rule that will drift. `physical_schema` is already computed and
`parquet_to_arrow_schema` preserves duplicate sibling names — the `ArrowWriter`
test in this PR relies on that. Walking two Arrow trees removes both layout
heuristics, the per-list-field `format!("{}_tuple", ..)` allocation, and three
unit tests.
**9. Dead and no-op branches in `validate_field_type`.**
- `LargeList` / `FixedSizeList` are unreachable. Spark `ArrayType` maps to
`DataType::List` (`execution/serde.rs:131`) and `parquet_to_arrow_schema` only
emits `List`.
- The `Map` arm never prunes anything: `is_pure_structural_narrowing`
returns `false` for any `Map` (`schema_adapter.rs:175`), so `selected` always
carries the physical Map type and the arm pays an O(n x m) match to reach the
same answer as full validation. Drop it and treat Map as validate-in-full.
**10. When `schema_hints` holds, the whole `selected` construction is wasted
work.** Every matched root gets its physical type back, so the result is just
"physical types, restricted to selected roots". Build that directly by
filtering `physical.fields()` against a set of folded required names in one
pass. That also avoids paying `parquet_to_arrow_schema` twice on the Variant
path, where `with_spark_arrow_schema` -> `spark_enum_schema` calls it again.
**11. The error carries no file path.** On a scan over many files the user
cannot tell which one is bad. `object_meta.location` is in scope — the `Failed
to fetch metadata for file {}` error two blocks up already formats it. Consider
a typed error alongside the existing
`SparkError::duplicate_field_case_insensitive` / `DuplicateFieldByFieldId` so
the JVM sees a Spark-shaped message rather than a generic
`ParquetError::General`.
**12. `projection: Option<(SchemaRef, SparkParquetOptions)>` is cloned in
`create_reader` and again in `get_metadata`.** `SparkParquetOptions` owns a
`String timezone`. `Arc<(SchemaRef, SparkParquetOptions)>` removes both clones.
**13. `case_sensitive` is used asymmetrically in `validate_field_names`:**
the projection match folds, the dedup `HashSet` inserts raw names. So
`struct<a, A>` under `caseSensitive=false` is not reported here (it is caught
later by `match_struct_fields`). Defensible, since the PR targets
byte-identical names — but the doc sentence *"The check applies in both
case-sensitivity modes"* reads as though collisions are caught in both, and the
parameter name invites the same misreading. Folding into the set as part of
finding 2 resolves it; otherwise one comment.
**14. Two spellings of one type:** `parquet::errors::Result<()>` on
`validate_field_names` vs `ParquetResult<()>` on `validate_field_type`;
`Type::GroupType { fields, .. }` in one vs `schema.is_group()` in the other.
### Test dedup
- `duplicate_root_names_are_rejected` is the final third of
`projected_fields_skip_unselected_roots` — same schema string, same assertion.
Merge.
- `duplicate_names_in_list_element_are_rejected` and
`duplicate_names_in_map_key_value_are_rejected` are already covered by the
`assert!(validate_field_names(&schema, None, cs).is_err())` inside
`projected_fields_skip_unselected_nested_duplicates`'s loop, for both LIST and
MAP. Keep the group-name assertion in one and drop the other two.
- `duplicate_names_in_map_key_value_are_rejected` also tests an unreachable
shape: a `key_value` with three children fails in `parquet_to_arrow_schema`
first (*"Child of map field must have two children, found 3"*, parquet 59.2
`complex.rs:410`), which in the projected path runs before the validator.
- The Map row of `projected_fields_skip_unselected_nested_duplicates`
asserts pruning the real pipeline never performs (finding 9) — it hand-builds a
`selected` that `get_metadata` cannot produce. Either drop the row or drive it
through `get_metadata` so it tests the actual decision.
- Scala: the parameterized `"distinct sibling"` case and the `unpruned` half
of `duplicate Parquet field names outside a nested projection remain readable`
assert the same full-subtree rejection. Merge.
- Scala `duplicate Parquet field names - unprojected fields and repeated
reads` runs 2 case modes x 2 repeats x 3 queries = 12 executions for one
behavior. Keep the second read only if the metadata-cache hit is the point, and
say so in a comment; drop the rest.
- Scala `duplicate Parquet field names - distinct siblings and repeated
names in separate groups` uses `dup`/`Dup` under `caseSensitive=true`, i.e.
distinct names, so it exercises the same no-collision path as the Rust
`repeated_names_in_separate_groups_are_valid`. Pick one.
- On moving these to `.sql` fixtures: not possible. Every case needs a file
written via `named_struct('dup', .., 'dup', ..)` or `writeDirect` and then read
back with an explicit `spark.read.schema(...)` that differs from the file
schema, which the SQL fixture runner cannot express. They belong in Scala.
### Assessments
**Scope.** Right layer — the footer is the only place that sees the
duplicate, and #5783 is not reachable from any JVM planning rule. Slightly too
broad in two places: the whole-file walk under `use_field_id` (finding 3) and
the whole-subtree walk for every `ARROW:schema` file (finding 7). Both widen
the rejection past what the decoder can actually break on.
**Spark compatibility.** Spark resolves duplicates last-wins via `.toMap`,
so erroring is a deliberate divergence. Acceptable as an interim per #5783, but
it needs the shape-by-shape split with #5654 agreed before merge, and the docs
paragraph should say that reads Spark handles will now fail rather than only
that ambiguous reads are rejected.
**Tests.** The end-to-end Scala coverage of the reported shapes is the right
mechanism and the case-sensitivity matrix is good. The gap is the "would this
fail if the fix were reverted, for the right reason?" check on the
`missing`-field and field-ID cases (findings 3 and 4). Rust unit tests calling
`validate_field_names` directly are fine for the walker, but three of them
assert behavior `get_metadata` cannot produce.
**Performance.** Per file open, on the metadata path, including cache hits:
one full `parquet_to_arrow_schema` over the whole file schema (two on the
Variant path), plus O(physical x projected) folded-name comparisons with two
`String` allocations each, plus a `format!` per list field, plus two
`SparkParquetOptions` clones. Small per file, but it scales with schema width x
file count and every piece of it is avoidable. Findings 2, 7, 10 and 12 remove
essentially all of it; no benchmark needed if they are applied, one wide-schema
open-rate measurement if they are not.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]