fix(parquet): correct nested clipping when one column has two cast targets, plus coverage - #1
Merged
Conversation
adriangb
force-pushed
the
claude/datafusion-pr-24090-review-m2xw4u
branch
from
August 5, 2026 17:18
b41a4e3 to
3fff36e
Compare
`build_read_plan_with_cast_clipping` skipped every cast access on a root
it had already clipped:
if whole_roots.contains(&root)
|| fallback_roots.contains(&root)
|| clipped_by_root.contains_key(&root) // <-- second cast: dropped
{ continue; }
When a projection consumes one column through two *different* narrowing
casts, only the first target's leaves reach the mask. The second cast
then evaluates against a struct missing the children it names: for
overlapping targets `cast_column` null-fills them (silently wrong
results), and for disjoint targets `validate_struct_compatibility`
rejects the cast so the query fails where it would have succeeded
without pruning.
This is reachable from plain SQL, not only through a custom
`PhysicalExprAdapter`: `ProjectionExec` is merged into the scan via
`ParquetSource::try_pushdown_projection`, so a query-level
`CAST(col AS STRUCT<...>)` lands in the scan's projection and reaches
the same analysis as an adapter-inserted one.
A second cast with a *different* target now demotes the root to a full
read. Identical repeated targets -- the shape the expression adapter
produces when one column is referenced several times -- still clip.
The `clipped_by_root` test in the `get_field` filter becomes a
`debug_assert`: a root carrying a `get_field` access is put into
`fallback_roots` before any clip is attempted, so it can never also be
clipped, and asserting that catches a future reordering instead of
silently changing which leaves are read.
Covered entirely in `parquet_nested_schema_pruning.slt` -- the query-level
cast path is reachable from SQL and the assertions are exact result
comparisons, so there is no reason to also carry Rust copies of them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defensive fixes in the same area.
`clip_type` could return `Struct([])` for a name-matched child whose own
children shared no name with their target, while still keeping that child
in the emitted type. arrow-rs *drops* a struct child whose leaves are all
masked out (`parquet/src/arrow/schema/complex.rs`,
`if children.is_empty() { return Ok(None) }`), so `projected_schema`
promised a field the decoder does not produce.
This is not reachable through the default stack:
`validate_struct_compatibility` rejects such a cast at planning time and
the logical planner rejects the user-written equivalent. But the module
doc's safety argument depends on a *caller* invariant, and the motivating
use case is a custom `PhysicalExprAdapter`, so `clip_for_cast` now
detects the empty level and declines to clip. A test pins the arrow-rs
behaviour the argument rests on, and the module doc is updated to say
what is now enforced here rather than assumed.
Separately, `leaves_by_root[root]` is a panicking `BTreeMap` index on a
path where the same function already guards the "root with no parquet
leaves" case forty lines earlier (`.map_or(&[][..], ...)` plus the
`count_leaves` guard) before routing the fallback root straight into the
index. I could not construct a file that reaches it, so it is latent, but
the surrounding code already treats the case as possible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three steps in the projection analysis were not proportional to the projected columns. `clip_type` matched struct fields with a linear `find` per physical child -- Theta(N*M) `String` comparisons per struct level, about 375k for a 1000 -> 500 subfield struct. Build a name map above a small width, which is what Spark's `ParquetReadSupport.clipParquetGroupFields` does (unconditionally; the threshold here avoids the allocation for the narrow structs that dominate in practice). The secondary fast-path gate scanned every field of the *file* schema. Widening it from `matches!(.., Struct(_))` to `contains_struct` is the right fix for the `List<Struct>` hole, but it also made `Map`, `Dictionary<_, Struct>` and every array-of-records column send the whole projection down the `PushdownChecker` path, whose `Schema::index_of` is a linear name scan per column node -- O(|exprs| * |schema|), paid once per file opened. Gate on the *projected* roots instead: O(projected), and the `List<Struct>` fix is preserved. Columns whose `index` does not line up with the file schema fall through to the name-resolving path, so stale `Column` indices are still handled. The `get_field` root loop re-looked-up each root by name in the schema `build_filter_schema` had just built, O(G^2). That schema emits one field per accessed root in ascending root order, which is the order the root set iterates in, so pair them positionally instead. This also removes a latent wrong-field pick when two roots share a name. Measured with a 1000-column / 1000-subfield benchmark over 32 small files, so per-file planning dominates: -13.6% for a wide struct present but not projected, -7.2% for a 1000 -> 500 subfield clip, and no change on two untouched-path controls. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Test-only; no production code changes. ## Rust e2e tests -> SLT The whole `nested_projection_pruning` module in `datafusion/core/tests/parquet/expr_adapter.rs` is reachable from SQL, so it moves into `parquet_nested_schema_pruning.slt` and the Rust copies go away (-668 lines). Every test keeps an equal or stronger assertion: | Rust test | SLT replacement | | --- | --- | | `prunes_list_of_struct` | `select events from narrow` values + 172 vs 312 bytes | | `prunes_top_level_struct` | `select s from narrow` values + 146 vs 219 bytes | | `preserves_struct_nullability` | `s IS NULL` per row, incl. a NULL struct row | | `prunes_get_field_on_narrowed_struct` | `select s['x']` values + 146 vs the full-leaf 219 baseline | | `prunes_mixed_struct_and_subfield_access` | `select s, s['y']` values + 146 vs 219 bytes | | `prunes_with_filter_pushdown` | `pushdown_filters = true` section, 219 vs 292 bytes | | `mixed_files_narrow_and_wide` | narrow+wide files in one scan (values; the Rust test asserted no bytes either) | | `comet_4859_two_level_nested_list_regression` | the same two-level shape + 381 vs 1.05 K bytes | Two things get better in the move. The comet#4859 fixture gives its dropped siblings (`feature_map`, `diagnostics`, `latency_parts`, `pad`, and the dropped top-level columns) real data instead of NULLs, so the byte gap is attributable to the clip rather than to NULL columns being cheap; the resulting 381 vs 1.05 K is a wider margin than the Rust `narrow * 2 < full` ratio it replaces. And the surviving struct fields at both nesting levels are now asserted by printing the values, rather than by `assert_eq!(fields().len(), 3)`. The tradeoff: a literal `bytes_scanned` is more sensitive to encoding changes in arrow-rs than a ratio. That is deliberate here -- the file's existing assertions already work this way, and a silent widening of a clipped read should fail loudly. ## Mutation-testing gaps `cargo-mutants` over `nested_schema_pruning.rs` and `projection_read_plan.rs`: 121 mutants, 82 viable, 16 survivors. Eight were real gaps; each now has a test, verified by re-applying the mutation by hand: * `start + o` -> `start - o` when rebasing clip offsets onto absolute leaf indices. Every existing clip test cast to the struct's *first* field, where the only offset is 0 and the two are identical. * The entire `get_field_accesses` branch of `build_read_plan_with_cast_clipping` was dead in tests: nothing combined a cast on one root with a field access on another. * Dropping `!whole_roots` from the `get_field` filter needs a root referenced both as a whole column and via `get_field` while another root is clipped. * `contains_struct` had no direct test; `count_leaves`'s dictionary assertion was vacuous (`Dictionary(Int32, Utf8)` counts 1 with or without the arm) and `RunEndEncoded` was untested. Both now use struct values, where dropping the arm misaligns every later leaf index. * Duplicate physical field names under one target field. Three survivors around `LINEAR_FIELD_SCAN_MAX` are equivalent by construction: the map and linear matching paths agree, which is what their survival demonstrates. One correction to an existing comment: `full_schema` says no cast is inserted. One *is* -- `VARCHAR` maps to `Utf8View` in the SLT context while the file holds `Utf8` -- it is just not a narrowing cast, so every leaf is still read and the bytes baseline is valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
adriangb
force-pushed
the
claude/datafusion-pr-24090-review-m2xw4u
branch
from
August 5, 2026 17:51
3fff36e to
3a44040
Compare
adriangb
commented
Aug 5, 2026
Comment on lines
-1208
to
-1220
| // --------------------------------------------------------------------------- | ||
| // Nested projection pruning: when the table schema declares a nested column | ||
| // narrower than the physical Parquet file, the scan should only read the | ||
| // leaves the declared schema names, instead of reading the whole column and | ||
| // discarding the extra subfields in the adapter-inserted cast. | ||
| // | ||
| // Each test registers two tables against the *same* physical file: `t_narrow` | ||
| // (the declared schema under test) and `t_full` (the file's own physical | ||
| // schema, so no cast is inserted and the scan always reads every leaf). That | ||
| // gives a same-context upper bound to compare `bytes_scanned` against, | ||
| // without needing a config flag to disable pruning. | ||
| // --------------------------------------------------------------------------- | ||
|
|
Author
There was a problem hiding this comment.
Moved these to SLT tests (faster builds, easier to match to user reachable behavior).
Author
|
@mbutrovich could you run CI please? |
Owner
On it, thanks! |
mbutrovich
merged commit Aug 5, 2026
c8e3f99
into
mbutrovich:comet-4859-nested-projection-pruning
33 of 34 checks passed
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.
Which issue does this PR close?
comet-4859-nested-projection-pruningbranch behind feat: prune unread Parquet leaves when a nested column is cast to a narrower type apache/datafusion#24090 rather thanmain.Rationale for this change
Review follow-up for apache#24090: one correctness bug, two smaller fixes, and the coverage to keep them fixed. Meant to be folded into your branch — squash it, cherry-pick individual commits, or take just the tests.
Take commit 1 if you take nothing else. The rest is optional and each commit stands alone:
fix(parquet)two cast targetsfix(parquet)zero-overlap struct levelperf(parquet)O(projected columns)test(parquet)coverage1. Two cast targets on one column read too few leaves
build_read_plan_with_cast_clippingskipped every cast access on a root it had already clipped:When a projection consumes one column through two different narrowing casts, only the first target's leaves end up in the mask. The second cast then evaluates against a struct missing the children it names:
cast_columnnull-fills them → silently wrong resultsvalidate_struct_compatibilityrejects the cast → the query fails where it succeeds without pruningThis is reachable from plain SQL, not only from a custom adapter:
ProjectionExecis merged into the scan viaParquetSource::try_pushdown_projection, so a query-levelCAST(col AS STRUCT<...>)lands in the scan's projection and reaches the same analysis as an adapter-inserted one.Against a table whose schema is inferred from the file (so no adapter cast is interposed):
Both are regressions: with
with_cast_collectiondisabled they return correct data.Fix: a second cast with a different target demotes the root to a full read. Identical repeated targets — the shape the expression adapter actually produces when one column is referenced several times — still clip.
2. A zero-overlap nested struct level predicted a type the reader never emits
clip_typecould returnStruct([])for a name-matched child whose own children shared no name with their target, while still keeping that child in the emitted type. arrow-rs drops a struct child whose leaves are all masked out (parquet/src/arrow/schema/complex.rs,if children.is_empty() { return Ok(None) }), soprojected_schemapromised a field the decoder does not produce.I could not reach this through the default stack —
validate_struct_compatibilityrejects such a cast at planning time, and the logical planner rejects the user-written equivalent — so this is not a live bug today. But the module doc's safety argument depends on a caller invariant, and the Comet use case is precisely a customPhysicalExprAdapter.clip_for_castnow detects the empty level and declines to clip, so the module is self-sufficient; a test pins the arrow-rs behaviour the argument rests on.Same commit:
leaves_by_root[root]is a panickingBTreeMapindex on a path where the same function already guards the "root with no parquet leaves" case forty lines earlier. Latent — I could not construct a file that reaches it — but a one-line fix.3. Performance, for the "is everything
O(projected_columns)" questionThree things were not:
clip_typematched struct fields with a linearfindper physical child — Θ(N·M)Stringcomparisons per struct level, ~375k for a 1000→500 subfield struct. Now a name map above a small width, which is what Spark'sParquetReadSupport.clipParquetGroupFieldsdoes.matches!(.., Struct(_))tocontains_structis the right fix for theList<Struct>hole, but it also madeMap,Dictionary<_, Struct>and every array-of-records column send the projection down thePushdownCheckerpath, whoseSchema::index_ofis a linear name scan per column node — O(|exprs| · |schema|), paid once per file. The gate now looks only at the projected roots, which is both O(projected) and keeps theList<Struct>fix. Columns whoseindexdoes not line up with the file schema fall through to the name-resolving path, so staleColumnindices are still handled.field_with_namere-lookup forget_fieldroots, replaced by a positional zip (build_filter_schemaalready emits fields in ascending root order). This also removes a latent wrong-field pick under duplicate root names.Measured with a criterion benchmark over 1000-column schemas and 1000-subfield structs across 32 files of 8 rows each, so per-file planning dominates. Before → after, back to back on the same box:
flat_wide/column_projection(control, untouched path)wide_struct/full_schema(control, untouched path)wide_struct/unrelated_column— wide struct in the schema, not projectedwide_struct/narrowed_schema— 1000 → 500 subfield clipThe feature itself also holds up at that width: the narrowed schema runs 90.8 ms against 166.2 ms for the full one, ~45% less wall time.
The benchmark is deliberately not in this PR — it is a one-off measurement harness, not something worth carrying in-tree. It is on
claude/pr-24090-review-full-harnessif you want to reproduce the numbers; happy to submit it separately if you'd rather have it.What changes are included in this PR?
Four commits; each builds and tests green on its own.
fix(parquet): read the whole root when one column has two cast targetsfix(parquet): decline to clip a struct level with no field-name overlapperf(parquet): keep nested read-plan analysis O(projected columns)test(parquet): cover the gaps found by mutation testing and SLT— test-onlyAre these changes tested?
Yes, two ways.
SLT.
parquet_nested_schema_pruning.sltgoes from 110 to ~380 lines, moving most of what was Rust-only into SQL:SELECT *, mixed whole-column and field access, aggregation and filtering over a clipped column, a declared field order differing from the file's, struct-in-struct narrowing, the two-levelARRAY<STRUCT<..., ARRAY<STRUCT<..>>>>shape from the Comet issue, aMAPsibling that is never clipped, a scan mixing a physically narrow and a wide file, the zero-overlap declaration that must be rejected, and the query-level cast cases from item 1 (which fail loudly with the fix reverted).One small correction while I was in there: the comment on
full_schemasays no cast is inserted. One is —VARCHARmaps toUtf8Viewin the SLT context while the file holdsUtf8— it just is not a narrowing cast, so every leaf is still read and the baseline is valid. Comment updated to say that.Mutation testing, used to find the gaps rather than to gate CI.
cargo-mutantsovernested_schema_pruning.rsandprojection_read_plan.rs: 121 mutants, 82 viable, 16 survivors. Eight were real gaps and commit 4 adds a test for each, verified by re-applying the mutation by hand:start + o→start - owhen rebasing clip offsets onto absolute leaf indices — every existing clip test cast to the struct's first field, where the only offset is 0 and the two are identical.get_field_accessesbranch ofbuild_read_plan_with_cast_clippingwas dead in tests: nothing combined a cast on one root with a field access on another.!whole_rootsin theget_fieldfilter needs a root referenced both as a whole column and viaget_fieldwhile another root is clipped.contains_structhad no direct test;count_leaves's dictionary assertion was vacuous (Dictionary(Int32, Utf8)counts 1 with or without the arm) andRunEndEncodedwas untested. Both now use struct values.Three survivors around
LINEAR_FIELD_SCAN_MAXare equivalent by construction — the map and linear matching paths agree, which is what their survival demonstrates. One redundantclipped_by_roottest in theget_fieldfilter is replaced by adebug_assertfor the invariant that makes it redundant.I also ran two randomised differential harnesses while developing this — one checking
clip_for_cast's predicted type and kept leaves against the real arrow-rs reader, one comparing a clipped scan againstcast_columnover an unclipped one end to end — for ~63k and ~25k cases respectively. They found nothing beyond the bugs above. They are not in this PR either: the specific shapes they turned up are now covered by the deterministic tests, and a permanent seeded fuzzer is a maintenance cost the fixes do not need. Also onclaude/pr-24090-review-full-harnessif you disagree and want them in-tree.Verified green:
cargo fmt --check,cargo clippy -p datafusion-datasource-parquet -p datafusion --all-targets --all-features -- -D warnings, 194 lib tests, 230parquet_integrationtests, and the Parquet SLT files.Are there any user-facing changes?
No public API changes. Behaviour changes are bug fixes: a projection consuming one column through two different nested casts now returns correct results, and a column that cannot be clipped safely falls back to reading in full.
Generated by Claude Code