andygrove opened a new issue, #5158:
URL: https://github.com/apache/datafusion-comet/issues/5158

   ### Describe the bug
   
   `collect_list` / `collect_set` over a nested argument can fail at run time 
with:
   
   ```
   Invalid argument error: column types must match schema types,
   expected List(Struct("flag": Boolean, "items": List(Struct("n": Int32))))
   but found  List(Struct("flag": non-null Boolean, "items": List(Struct("n": 
non-null Int32))))
   at column index 1
   ```
   
   The two types are identical apart from the **nullability of nested fields**. 
Arrow treats that as fatal: `RecordBatch::try_new` compares with 
`DataType::equals_datatype`, which does compare nested field nullability.
   
   There are two independent sources of truth for the element type, and nothing 
forces them to agree:
   
   * **Declared** — `SparkCollectSet::state_fields` / `return_type` copy the 
aggregate argument's *declared* type verbatim (`datafusion-spark`, 
`function/aggregate/collect.rs`).
   * **Produced** — `DistinctArrayAggAccumulator` stores 
`ScalarValue::try_from_array(...)` slices of the *actual* argument array, and 
`ScalarValue::new_list` honors its `data_type` parameter **only when the value 
set is empty** (`datafusion/common/src/scalar/mod.rs`); otherwise it just 
concatenates the stored scalars. So the emitted list's element type is whatever 
the runtime array carried.
   
   If the declared argument type and the runtime array type disagree in nested 
nullability, `GroupedHashAggregateStream::emit` fails when it validates its 
output batch. This surfaces on both the normal emit path (validated against 
`AggregateExec`'s schema, derived from `return_type`) and the spill path 
(validated against `spill_state.spill_schema`, built from `state_fields()`).
   
   ### Why the existing guard does not cover this
   
   `PhysicalPlanner::coerce_collect_child_nullability` (added in #4720) casts 
the argument to the all-nullable variant of its type, but returns early when 
the declared type is *already* all-nullable:
   
   ```rust
   let child_type = child.data_type(schema.as_ref())?;
   let nullable_type = make_all_fields_nullable(&child_type);
   if child_type.equals_datatype(&nullable_type) {
       Ok(child)          // no cast inserted
   } else {
       Ok(Arc::new(CastExpr::new(child, nullable_type, None)))
   }
   ```
   
   That is exactly the failing case: the declared type is all-nullable, the 
runtime array is not, no cast is inserted, and the drift reaches the 
accumulator. The cast only helps in the opposite direction.
   
   Separately, the rationale comment on that function is stale for DataFusion 
54.1.0. It says the accumulator "build[s] their result list with all element 
fields marked nullable, regardless of the input's nullability" — that is no 
longer true; the accumulator preserves the input array's type, so declared and 
produced already agree when there is no drift. The coercion is currently a 
normalization, not a defence.
   
   ### To Reproduce
   
   Nullability drift is hard to produce from Comet's own operators today, 
because `ProjectionExec` and `ScanExec` both validate/cast their output batches 
against their declared schemas. The failure is reachable when an argument 
expression's `evaluate()` output type disagrees with its `data_type(schema)` — 
for example `CreateNamedStruct` derives its fields from `batch.schema()` in 
`evaluate()` but from the plan-time schema in `data_type()` 
(`native/spark-expr/src/struct_funcs/create_named_struct.rs`), so the two 
diverge for any batch whose schema object differs from the plan-time schema.
   
   A unit test that builds `collect_set` against a schema with nullable leaves 
and feeds it a batch whose array has non-nullable leaves reproduces the error 
message above exactly.
   
   ### Expected behavior
   
   The array handed to the `collect_list` / `collect_set` accumulator should 
have exactly the type the plan declared for it, so the aggregate cannot fail 
validating its own output.
   
   ### Additional context
   
   Two fixes, not mutually exclusive:
   
   1. Make the coercion an unconditional normalization barrier for nested 
argument types, so the cast forces the runtime array to the declared type 
regardless of what the argument produced. This is metadata-only — 
`cast_with_options` shallow-clones when the source and target types already 
match, and otherwise `cast_struct_to_struct` / `cast_list_values` stamp the 
target fields and clone child buffers — so it does not copy data. The declared 
output type is unchanged in every case, so it is not observable from Spark.
   2. Harden `CreateNamedStruct` so it cannot drift: resolve its `Fields` once 
from the plan-time schema at construction instead of recomputing them from 
`batch.schema()` on every `evaluate()`.
   
   I have a PR ready for (1); (2) is a behaviour-changing signature change and 
is better as a follow-up.
   
   The underlying defect is upstream: `ScalarValue::new_list` accepts a 
`data_type` it then discards for non-empty input, so no accumulator built on it 
can guarantee the type its `state_fields()` advertises. Filing that separately 
against DataFusion.
   


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