adriangb commented on PR #24090:
URL: https://github.com/apache/datafusion/pull/24090#issuecomment-5194655874

   I spent some time stress-testing this (mutation testing, randomised 
differential testing against the arrow-rs reader, and benchmarks on wide 
schemas) and found one correctness bug plus a few smaller things. I've opened 
mbutrovich/datafusion#1 against your branch with fixes and tests — fold it in 
however you like.
   
   ### Two cast targets on one column read too few leaves
   
   `build_read_plan_with_cast_clipping` skips every cast access on a root it 
has already clipped:
   
   ```rust
   if whole_roots.contains(&root)
       || fallback_roots.contains(&root)
       || clipped_by_root.contains_key(&root)   // <-- second cast on this 
root: dropped
   { continue; }
   ```
   
   If a projection consumes one column through two *different* narrowing casts, 
only the first target's leaves reach the mask, and the second cast then 
evaluates against a struct missing the children it names:
   
   * overlapping targets → `cast_column` null-fills them → **silently wrong 
results**
   * disjoint targets → `validate_struct_compatibility` rejects it → the query 
**fails** where it succeeds without pruning
   
   I originally assumed this needed a custom adapter, but it's reachable from 
plain SQL: `ProjectionExec` is merged into the scan via 
`ParquetSource::try_pushdown_projection`, so a query-level `CAST(col AS 
STRUCT<...>)` 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):
   
   ```sql
   -- 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;
   -- {x: 100} {x: 100, y: NULL}   <- y should be 's1'
   
   SELECT CAST(s AS STRUCT<x BIGINT>), CAST(s AS STRUCT<pad VARCHAR>) FROM t;
   -- Error: Cannot cast struct with 1 fields to 1 fields because there is no 
field name overlap
   ```
   
   Both return correct results with `with_cast_collection` disabled, so they're 
regressions rather than pre-existing behaviour. The fix in the linked PR 
demotes the root to a full read when a second cast has a different target; 
identical repeated targets (what the expression adapter actually produces) 
still clip.
   
   ### Smaller things
   
   * **A zero-overlap nested struct level predicts a type the reader never 
emits.** `clip_type` can return `Struct([])` for a name-matched child whose own 
children share no name with their target, while keeping that child in the 
emitted type — but arrow-rs drops a struct child whose leaves are all masked 
out (`complex.rs`, `if children.is_empty() { return Ok(None) }`). I couldn't 
reach it through the default stack (`validate_struct_compatibility` and the 
logical planner both reject the cast first), so it isn't live today. But the 
module doc's safety argument rests on a *caller* invariant and the motivating 
use case is a custom `PhysicalExprAdapter`, so I'd rather `clip_for_cast` 
enforce it itself. Test added pinning the arrow-rs behaviour the argument 
depends on.
   * **`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 
twenty lines earlier. Latent — I couldn't build a file that reaches it — but a 
one-line fix.
   
   ### Performance
   
   Three things weren't `O(projected_columns)`:
   
   * `clip_type` matches struct fields with a linear `find` per physical child 
— Θ(N·M) string comparisons per struct level, ~375k for a 1000→500 subfield 
struct. Spark's `ParquetReadSupport.clipParquetGroupFields` uses a name→field 
map for exactly this; the PR does the same above a small width.
   * Widening the fast-path gate to `contains_struct` correctly fixes the 
`List<Struct>` hole, but it also routes `Map`, `Dictionary<_, Struct>` and 
every array-of-records column through `PushdownChecker`, whose 
`Schema::index_of` is a linear name scan per column node — O(|exprs| · 
|schema|), paid once per file opened. Gating on the *projected* roots instead 
keeps the `List<Struct>` fix and is O(projected).
   * An O(G²) `field_with_name` re-lookup for `get_field` roots (also picks the 
wrong field under duplicate root names).
   
   New benchmark `parquet_wide_schema_read_plan` — 1000-column schemas and 
1000-subfield structs over 32 small files, so per-file planning dominates. 
Before → after, back to back, with two untouched-path controls so the numbers 
mean something:
   
   | case | change |
   | --- | --- |
   | `flat_wide/column_projection` (control) | −0.1% (p = 0.93) |
   | `wide_struct/full_schema` (control) | +1.1% (p = 0.63) |
   | `wide_struct/unrelated_column` — wide struct present but not projected | 
**−13.6%** (p = 0.01) |
   | `wide_struct/narrowed_schema` — 1000 → 500 subfield clip | **−7.2%** (p = 
0.01) |
   
   The feature holds up well at that width: 90.8 ms narrowed vs 166.2 ms full, 
~45% less wall time.
   
   ### Coverage
   
   `cargo-mutants` over the two files: 121 mutants, 82 viable, 16 survivors, of 
which 8 were real gaps — including that `start + o` (rebasing clip offsets onto 
absolute leaf indices) survives `start - o`, because every clip test casts to 
the struct's *first* field where the two are identical; and that the whole 
`get_field_accesses` branch of `build_read_plan_with_cast_clipping` is never 
executed by any test. All eight now have tests, each verified by re-applying 
the mutation.
   
   I also added two seeded randomised differential harnesses — one checking 
`clip_for_cast`'s predicted type and kept leaves against the real reader, one 
comparing a clipped scan against `cast_column` over an unclipped one end to 
end. ~63k and ~25k cases respectively, no further discrepancies. They're cheap 
by default (~3.5 s / ~1.2 s) and widen by env var.
   
   SLT coverage went from 110 to ~380 lines, moving most of the Rust-only 
assertions 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, the two-level `ARRAY<STRUCT<..., 
ARRAY<STRUCT<..>>>>` shape from the Comet issue, a `MAP` sibling, a scan mixing 
narrow and wide files, and the zero-overlap rejection.
   
   ### One thing that isn't yours
   
   While writing the SLT tests I hit a pre-existing soundness bug: with 
`pushdown_filters = true`, a `get_field` predicate is silently dropped whenever 
the file needs schema adaptation, so the query returns every row. It's not 
caused by this PR (`row_filter.rs` is untouched), but every table this feature 
targets carries exactly the cast that triggers it, so it's worth knowing about 
before this lands. Filed separately as #24109.
   
   ---
   _Generated by [Claude Code](https://claude.ai/code)_
   


-- 
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]

Reply via email to