Repository navigation
fix(spark): array_contains float equality matches Spark - #26011
mrhard9090 wants to merge 2 commits into
Conversation
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main apache/datafusion#26011 +/- ##
==========================================
+ Coverage 82.64% 82.65% +0.01%
==========================================
Files 1147 1147
Lines 445502 446108 +606
Branches 445502 446108 +606
==========================================
+ Hits 368179 368726 +547
- Misses 54969 54990 +21
- Partials 22354 22392 +38 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Thanks for the fix and for calling out the remaining struct scope. I cross-validated that follow-up at Two repros for the struct portion of apache/datafusion-spark#34: SELECT array_contains(
array(named_struct('v', -CAST('NaN' AS DOUBLE))),
named_struct('v', CAST('NaN' AS DOUBLE)));
-- Current PR: false; Spark 4.2.0: true
SELECT array_contains(
array(named_struct('v', -CAST('NaN' AS DOUBLE)), NULL),
named_struct('v', CAST('NaN' AS DOUBLE)));
-- Current PR: NULL; Spark 4.2.0: trueI observed these and the other new SQL cases in a live Spark 4.2.0 session (UTC), with ANSI both on and off. Signed zeros within structs already match through the shared nested comparison path; the failing struct cases here are NaN representations. The scoped extension adds these two match arms (and imports // In canonicalize_floats's ScalarValue match:
ScalarValue::Struct(struct_array) => {
ColumnarValue::Scalar(ScalarValue::Struct(
canonicalize_float_array(&(Arc::clone(struct_array) as ArrayRef))
.as_struct()
.clone()
.into(),
))
}
// In canonicalize_float_array's DataType match:
DataType::Struct(fields) if !fields.is_empty() => {
let struct_array = array.as_struct();
Arc::new(StructArray::new(
fields.clone(),
struct_array.columns().iter()
.map(canonicalize_float_array)
.collect(),
struct_array.nulls().cloned(),
))
}Both paths matter: a struct needle can be scalar or columnar. Recursing through fields also covers struct/list combinations. The empty-fields guard avoids the length-inferring constructor for empty structs, preserving their row count. Validation of the local extension:
Would you prefer incorporating this small extension and the regression tests here, or handling structs in a follow-up after this PR? I can share the full test diff if helpful. Surfaced by the audit-datafusion-spark-expression skill. |
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
|
Thanks, @txwyy123. I took the struct extension into this PR (both the scalar and the array path, with the empty-struct guard) and added unit tests for the scalar/array needle, |
|
#25971 also canonicalizes signed zeros and NaNs |
kosiew
left a comment
There was a problem hiding this comment.
Thanks for the fix. Canonicalizing signed zeros and NaNs before Spark's array_contains delegates to array_has is an appropriate approach, and the added regression tests cover the intended equality behavior, including nested structs.
Keeping this normalization within the Spark adapter is the right architectural boundary. Generic DataFusion comparison semantics remain unchanged, while the recursive adapter handles Spark compatibility. That adapter must also preserve array types, row counts, validity, and nested layout during reconstruction.
I'm requesting changes because reconstructing zero-width fixed-size lists can silently drop rows or panic when nested, including for non-float inputs. This is a correctness regression that needs a length-preserving fix and UDF regression coverage. The API simplification and potential reconstruction optimizations are optional follow-ups and should not block this fix.
| } | ||
| DataType::FixedSizeList(field, size) => { | ||
| let list = array.as_fixed_size_list(); | ||
| Arc::new(FixedSizeListArray::new( |
There was a problem hiding this comment.
A zero-width FixedSizeListArray can contain one valid row, but FixedSizeListArray::new infers length zero, silently dropping that row or panicking when nested inside a List with offsets [0, 1]. This also affects non-float children. Please return the original array when size == 0 or use a length-preserving constructor, and add UDF tests verifying one valid false result for a non-null scalar needle against an array-valued haystack containing one zero-width list, plus preserved row counts without panic for a zero-width list nested inside a List.
| /// `-0.0` becomes `0.0` and every NaN becomes the canonical NaN. Applies to a | ||
| /// float argument and to the elements of a list argument; other values are | ||
| /// returned as they are. | ||
| fn canonicalize_floats(value: &ColumnarValue) -> Result<ColumnarValue> { |
There was a problem hiding this comment.
Optional simplification: could canonicalize_floats return ColumnarValue directly? Neither it nor its callees returns a recoverable error, so removing Result, the outer Ok, and the caller's ? would clarify the failure boundary. This does not address the reconstruction panic, which Result cannot catch.
| array.as_primitive::<Float64Type>(), | ||
| canonical_f64, | ||
| )), | ||
| DataType::List(field) => { |
There was a problem hiding this comment.
Optional performance follow-up: consider reusing list/struct arrays when their children are unchanged. Currently even List is reconstructed, while a small slice of a large float list normalizes the entire retained child buffer. Please benchmark these cases before introducing a type gate or visible-range optimization; no typical-workload slowdown is established yet.
|
Likely AI spam -- no response from prolific author. Closing |
## Which issue does this PR close? - Addresses the float equality difference in #34. The untyped `NULL` argument difference mentioned in the comments there is not changed by this PR, so I left the issue open. ## Rationale for this change `array_contains` delegates the element comparison to `array_has`, which compares floating point values by their bits. Spark compares them with `-0.0 == 0.0` and `NaN == NaN`: `CodegenContext.genEqual` in Spark 4.2.0 generates `(java.lang.Double.isNaN(c1) && java.lang.Double.isNaN(c2)) || c1 == c2` (and the same for `Float`). So `array_contains(array(-0.0D), 0.0D)` returned `false` here and `true` in Spark, and two NaNs with different sign bits or payloads did not match, at any depth of list and struct elements. ## What changes are included in this PR? - `array_contains.rs` rewrites the float values of both arguments before calling `array_has`: `-0.0` becomes `0.0` and every NaN becomes the canonical NaN. It applies to scalar and array arguments, and to `Float32`/`Float64` at any depth of `List`, `LargeList`, `FixedSizeList` and `Struct`. Arrays that hold no floats are returned as they are. The existing null handling is unchanged. - `FixedSizeList` is rebuilt with `try_new_with_length`, so a zero-width list keeps its rows. - `array/array_contains.slt`: new queries for both zeros in both directions (`double` and `float`), NaN with a flipped sign bit, `LargeList` and `FixedSizeList` haystacks, nested lists and structs, null semantics, and a table with column arguments. - Unit tests in `array_contains.rs` for the normalization (scalar and nested shapes, zero-width lists, reuse of children without floats). An earlier attempt at this issue, apache/datafusion#26011, was closed before the move. Its review asked for a length-preserving rebuild of zero-width `FixedSizeList` and tests for it, which this PR includes. ## What is the testing strategy for this PR? - `cargo fmt --all -- --check`, `cargo clippy --all-targets --all-features -- -D warnings`, `cargo test -p datafusion-spark` (331 passed) and `cargo test -p datafusion-spark-sqllogictest` (245 of 245 files) pass. - With `array_contains.rs` from `main`, the new float queries fail. Run one by one, 9 of 14 probe queries fail on `main` (top-level `-0.0`, NaN with a flipped sign bit at every depth, `LargeList`/`FixedSizeList`); nested `-0.0` already compares equal on `main` and is kept as a guard. - Each branch of the new code is covered by at least one test that fails when the branch is removed (checked by hand: no normalization, NaN branch, zero branch for `f32` and `f64`, struct, list, scalar path, zero-width length, early return). ## Unknowns and assumptions - I could not run Spark here. The expected values come from Spark's source (`genEqual`, 4.2.0) and from the report in #34, not from a live PySpark session. - `arrays_overlap` (mentioned in #34) is not touched. ## Are there any user-facing changes? `array_contains` on float or double elements now matches Spark for `-0.0` and NaN. No API change.
Which issue does this PR close?
Rationale for this change
SparkArrayContainsdelegates the comparison toarray_has, which compares floats by their bits. Spark'sarray_containstreats-0.0as equal to0.0and all NaNs as equal, soarray_contains(array(-0.0D), 0.0D)returnedfalsehere (Spark:true), and two NaNs with different bit patterns did not match.What changes are included in this PR?
array_contains.rsrewrites the float values before callingarray_has:-0.0becomes0.0and every NaN becomes the canonical NaN. It applies to a float needle (scalar or array) and to the elements of the list haystack, includingList,LargeListandFixedSizeList, lists nested in lists, and float fields of structs (scalar or array needle). The existing null handling is unchanged.spark/array/array_contains.slt:-0.0vs0.0in both directions fordouble,-0.0forfloat, NaN vs-NaN, NaN next to another value, a nested list, struct fields with NaN payloads, and plain misses that stayfalse. Unit tests inarray_contains.rscover scalar and array needles,f32, the three list types and structs.Not covered: I did not look at
arrays_overlap, which the issue says probably has the same equality.What is the testing strategy for this PR?
cargo test -p datafusion-sqllogictest --test sqllogictests -- array_contains.sltpasses. Witharray_contains.rsfrommain, the new float queries fail.cargo fmtandcargo clippy -p datafusion-spark --all-targets --all-features --no-deps -- -D warningspass.Are there any user-facing changes?
Spark
array_containson float or double elements now matches Spark for-0.0and NaN. No API change.AI assistance: written with Claude Code; I reviewed the diff and ran the tests above.