Skip to content

fix(parquet): correct nested clipping when one column has two cast targets, plus coverage - #1

Merged
mbutrovich merged 4 commits into
mbutrovich:comet-4859-nested-projection-pruningfrom
pydantic:claude/datafusion-pr-24090-review-m2xw4u
Aug 5, 2026
Merged

fix(parquet): correct nested clipping when one column has two cast targets, plus coverage#1
mbutrovich merged 4 commits into
mbutrovich:comet-4859-nested-projection-pruningfrom
pydantic:claude/datafusion-pr-24090-review-m2xw4u

Conversation

@adriangb

@adriangb adriangb commented Aug 5, 2026

Copy link
Copy Markdown

Which issue does this PR close?

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:

# commit what it is drop it?
1 fix(parquet) two cast targets wrong results / spurious failure, reachable from plain SQL no
2 fix(parquet) zero-overlap struct level not reachable today; makes the module enforce its own precondition. Also a one-line panic guard fine to drop
3 perf(parquet) O(projected columns) three non-linear steps in the planning path fine to defer
4 test(parquet) coverage test-only, no production changes fine to drop

1. Two cast targets on one column read too few leaves

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 on this root: dropped
{ continue; }

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:

  • overlapping targetscast_column null-fills them → silently wrong results
  • disjoint targetsvalidate_struct_compatibility rejects the cast → the query fails where it succeeds without pruning

This is reachable from plain SQL, not only from a custom adapter: 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.

Against a table whose schema is inferred from the file (so no adapter cast is interposed):

-- file: s Struct<x Int64, y Utf8, pad Utf8>
SELECT CAST(s AS STRUCT<x BIGINT>), CAST(s AS STRUCT<x BIGINT, y VARCHAR>) FROM t;
-- before: {x: 100} {x: 100, y: NULL}      <- y should be 's1'
-- after:  {x: 100} {x: 100, y: s1}

SELECT CAST(s AS STRUCT<x BIGINT>), CAST(s AS STRUCT<pad VARCHAR>) FROM t;
-- before: Error: Cannot cast struct with 1 fields to 1 fields because there is
--         no field name overlap
-- after:  {x: 100} {pad: sp1}

Both are regressions: with with_cast_collection disabled 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_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.

I could not reach this through the default stack — validate_struct_compatibility rejects 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 custom PhysicalExprAdapter. clip_for_cast now 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 panicking BTreeMap index 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)" question

Three things were not:

  • clip_type matched struct fields with a linear find per physical child — Θ(N·M) String comparisons per struct level, ~375k for a 1000→500 subfield struct. Now a name map above a small width, which is what Spark's ParquetReadSupport.clipParquetGroupFields does.
  • The 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 projection down the PushdownChecker path, whose Schema::index_of is 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 the List<Struct> fix. 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.
  • An O(G²) field_with_name re-lookup for get_field roots, replaced by a positional zip (build_filter_schema already 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:

case change
flat_wide/column_projection (control, untouched path) −0.1% (p = 0.93)
wide_struct/full_schema (control, untouched path) +1.1% (p = 0.63)
wide_struct/unrelated_column — wide struct in the schema, not projected −13.6% (p = 0.01)
wide_struct/narrowed_schema — 1000 → 500 subfield clip −7.2% (p = 0.01)

The 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-harness if 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.

  1. fix(parquet): read the whole root when one column has two cast targets
  2. fix(parquet): decline to clip a struct level with no field-name overlap
  3. perf(parquet): keep nested read-plan analysis O(projected columns)
  4. test(parquet): cover the gaps found by mutation testing and SLT — test-only

Are these changes tested?

Yes, two ways.

SLT. parquet_nested_schema_pruning.slt goes 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-level ARRAY<STRUCT<..., ARRAY<STRUCT<..>>>> shape from the Comet issue, a MAP sibling 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_schema says no cast is inserted. One isVARCHAR maps to Utf8View in the SLT context while the file holds Utf8 — 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-mutants over nested_schema_pruning.rs and projection_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 + ostart - 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 in 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.
  • 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 redundant clipped_by_root test in the get_field filter is replaced by a debug_assert for 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 against cast_column over 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 on claude/pr-24090-review-full-harness if 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, 230 parquet_integration tests, 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

adriangb and others added 4 commits August 5, 2026 12:28
`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
adriangb force-pushed the claude/datafusion-pr-24090-review-m2xw4u branch from 3fff36e to 3a44040 Compare August 5, 2026 17:51
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.
// ---------------------------------------------------------------------------

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Moved these to SLT tests (faster builds, easier to match to user reachable behavior).

@adriangb

adriangb commented Aug 5, 2026

Copy link
Copy Markdown
Author

@mbutrovich could you run CI please?

@mbutrovich

Copy link
Copy Markdown
Owner

@mbutrovich could you run CI please?

On it, thanks!

@mbutrovich
mbutrovich merged commit c8e3f99 into mbutrovich:comet-4859-nested-projection-pruning Aug 5, 2026
33 of 34 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants