andygrove opened a new pull request, #5138:
URL: https://github.com/apache/datafusion-comet/pull/5138

   ## Which issue does this PR close?
   
   Closes #5137.
   
   ## Rationale for this change
   
   Arrow treats nested field nullability as part of `DataType` identity, so 
`RecordBatch::try_new_with_options` rejects a column whose nested `nullable` 
flags are narrower than the declared type — even though the data is fine, since 
a non-null child is a strict subset of a nullable one. It is a metadata 
assertion failure, not corruption.
   
   Most Comet boundaries already normalize instead of asserting, which is why 
this drift is usually invisible:
   
   - `ScanExec::build_record_batch` casts every imported column to the declared 
type, so the FFI boundary absorbs it.
   - `SchemaAlignExec` casts the shuffle writer's input (added for #4515).
   - `CometLocalTableScanExec` widens its output with `.asNullable` (added for 
#4789).
   
   Two stamp points still asserted, and because both sit inside native plans 
(past `ScanExec`'s cast) nothing corrected the drift before them. The task 
aborted deterministically for the partition's data shape, so retries did not 
help:
   
   ```
   Invalid argument error: column types must match schema types,
   expected List(Struct("id": Int64, "flag": Boolean))
   but found List(Struct("id": Int64, "flag": non-null Boolean))
   at column index 0
   ```
   
   - `ShuffleScanExec::poll_next` stamped the catalyst schema onto the decoded 
shuffle block with no cast. This is the direct-read shuffle path, enabled by 
default (`spark.comet.shuffle.directRead.enabled`).
   - `ExpandStream::expand` derived its schema from `projections[0]` only and 
stamped it onto the output of *every* projection, so it failed if any two 
projections disagreed on a nested nullable flag for the same output column.
   
   ## What changes are included in this PR?
   
   A new `cast_and_stamp_schema` helper in `datafusion-comet-common`, used at 
both stamp points, which casts any column whose Arrow type differs from the 
declared field type before stamping. Arrow's cast rewrites nested fields from 
the target (`cast_struct_to_struct` builds with `to_fields`, `cast_list_values` 
with `to.clone()`), which is exactly how `ScanExec` already absorbs this.
   
   `ExpandExec::build_schema` now derives the output schema by widening nested 
nullability across **all** projections instead of reading it off 
`projections[0]`, so the declared schema is valid for every projection. The 
runtime cast is a second, independent line of defence: each is tested on its 
own, so neither silently carries the other.
   
   Error messages now name the operator and the dotted path of the offending 
column:
   
   ```
   CometExpandExec cannot reconcile col[0] with its declared schema at
   col_0.element.flag: expected nullable Boolean, found non-null Boolean
   ```
   
   instead of only `at column index N`, which for a wide schema of deeply 
nested structs was very hard to act on — the two printed types could differ by 
one flag hundreds of characters in.
   
   A narrowing cast (declared non-null child, actual data contains nulls) is 
not silently accepted: `StructArray::try_new` rejects unmasked nulls for a 
non-nullable field, so that case still errors loudly, now with the column path 
named.
   
   This PR is about the boundaries. The upstream nested-nullability drift 
itself remains tracked by #4515, with concrete kernel instances in #4789 and 
#4528.
   
   ## How are these changes tested?
   
   New Rust unit tests, modelled on the existing `SchemaAlignExec` tests as the 
issue suggested:
   
   - `native/common/src/schema.rs` — 9 tests over `cast_and_stamp_schema`, 
`describe_type_mismatch`, and `widen_nested_nullability`. 
`stamps_nested_nullability_drift` first asserts that the plain stamp *does* 
reject the drift, then that the helper absorbs it with values intact. That 
assertion pins why the helper exists: if arrow ever relaxes nested nullability 
comparison, it flips and the call sites can go back to a plain stamp.
   - `native/core/src/execution/operators/expand.rs` — 5 tests: `build_schema` 
widens across projections (driven from the narrow side, so it cannot pass by 
echoing `projections[0]`), rejects projections of differing width, expands 
divergent projections end-to-end, and reconciles even when handed the narrow 
`projections[0]`-derived schema the planner used to build.
   - `native/core/src/execution/operators/shuffle_scan.rs` — 2 tests: a decoded 
block with a non-null nested child against a nullable declared type is 
reconciled with values preserved, and an unreconcilable column names the 
operator and column.
   
   I confirmed these are genuine regression tests rather than tests that pass 
either way: without the fix, `RecordBatch::try_new_with_options` rejects the 
drift, which the first assertion in `stamps_nested_nullability_drift` now pins 
permanently.
   
   Also run and passing: full `cargo test --workspace` (135 + 33 native tests; 
the 3 pre-existing `datafusion-comet-jni-bridge` `errors::tests` panic-handling 
failures reproduce on a clean checkout of this branch point and are unrelated), 
`cargo clippy --all-targets --workspace` clean, `cargo fmt`, and on Spark 3.5: 
`CometExecSuite "expand operator"`, `CometNativeShuffleSuite` (27), 
`CometShuffleSuite` (44), `CometAggregateSuite` (85, 2 canceled).
   
   I did not reduce this to a Spark-level SQL test. Both sites need a native 
plan where a kernel actually produces a nested type narrower than catalyst 
declared, and those kernels are the subject of #4515/#4789/#4528 — so a SQL 
repro would be pinned to whichever kernel currently drifts and would evaporate 
when that kernel is fixed, which is the wrong thing to encode. The tests above 
pin the boundaries directly, independent of which kernel drifts.
   
   ## Are these changes tested?
   
   Yes.
   
   ## Are there any user-facing changes?
   
   No API changes. Queries that previously aborted with `column types must 
match schema types` in the direct-read shuffle path or in Expand (grouping sets 
/ rollup / multiple distinct aggregates over nested columns) now complete. 
Errors that remain genuinely unreconcilable are reported with the operator and 
column path named.
   
   ## Follow-up
   
   `ScanExec::build_record_batch` does the same cast-then-stamp but keeps its 
own copy of the logic and its own `cast_time` metric, so its errors still 
report only `at column index N`. Switching it to the shared helper would need 
the helper to accept a timer; I left it out to keep this diff focused on the 
two boundaries the issue names.


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