This is an automated email from the ASF dual-hosted git repository.
andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-ballista.git
The following commit(s) were added to refs/heads/main by this push:
new ce115795f fix: preserve null_equality so distributed INTERSECT/EXCEPT
match NULLs (#2064)
ce115795f is described below
commit ce115795f153f4056c3c35eda5ade7b12c881213
Author: Andy Grove <[email protected]>
AuthorDate: Wed Jul 22 12:31:05 2026 -0600
fix: preserve null_equality so distributed INTERSECT/EXCEPT match NULLs
(#2064)
`INTERSECT` and `EXCEPT` lower to semi/anti joins carrying
`NullEquality::NullEqualsNull` so that NULL matches NULL. Ballista sends the
client's logical plan to the scheduler as protobuf, and datafusion-proto's
logical `JoinNode` decode rebuilt the join through `LogicalPlanBuilder`,
whose
`join_with_expr_keys` / `join_using` hardcode `NullEqualsNothing`. The
encoded
`null_equality` was therefore dropped on decode and the scheduler planned a
different query than the client asked for: NULL stopped matching itself, so
INTERSECT dropped NULL rows and EXCEPT retained them.
This reproduces with a single partition, so it is not a shuffle or
join-strategy artifact, and it affects both hash and sort-merge joins:
select count(*) from (select distinct c_last_name from customer
intersect
select distinct c_last_name from customer);
-- 4972, expected 4973
Fixed upstream by apache/datafusion#22104 and backported to `branch-54` as
apache/datafusion#22785, which is not in the released 54.0.0 tag. Pin
DataFusion to the `branch-54` tip to pick it up; the pin can be dropped
once a
54.x patch release containing it is published.
TPC-DS q38 (INTERSECT) and q87 (EXCEPT) now match single-process DataFusion
and
join the correctness gate, taking it from 86 to 88 queries. q4 and q78
remain
skipped: neither uses a set operation and both are unaffected by this fix,
so
they have a separate root cause.
---
ballista/core/src/serde/mod.rs | 56 ++++++++++++++++++++++++++++++++++++++++++
benchmarks/src/bin/tpcds.rs | 11 ---------
2 files changed, 56 insertions(+), 11 deletions(-)
diff --git a/ballista/core/src/serde/mod.rs b/ballista/core/src/serde/mod.rs
index 40df51bd4..9b826ee2f 100644
--- a/ballista/core/src/serde/mod.rs
+++ b/ballista/core/src/serde/mod.rs
@@ -1432,6 +1432,62 @@ mod test {
assert_eq!(decoded_exec.partition.len(), 1);
}
+ /// `INTERSECT` / `EXCEPT` lower to semi/anti joins with
+ /// `NullEquality::NullEqualsNull` so that NULL matches NULL. Ballista
ships
+ /// the client's *logical* plan to the scheduler as protobuf, so that flag
+ /// has to survive the roundtrip: if it decodes back as `NullEqualsNothing`
+ /// the scheduler silently plans a different query than the client asked
for
+ /// (NULL stops matching itself, so INTERSECT drops rows and EXCEPT keeps
+ /// them). See https://github.com/apache/datafusion-ballista/issues/2046
+ ///
+ /// `null_equality` is not part of the rendered plan, so this inspects the
+ /// decoded `Join` directly rather than asserting on a plan string.
+ #[tokio::test]
+ async fn logical_join_roundtrip_preserves_null_equality() {
+ use datafusion::common::NullEquality;
+ use datafusion::logical_expr::LogicalPlan;
+
+ fn join_null_equality(plan: &LogicalPlan) -> Option<NullEquality> {
+ if let LogicalPlan::Join(join) = plan {
+ return Some(join.null_equality);
+ }
+ plan.inputs().into_iter().find_map(join_null_equality)
+ }
+
+ let ctx = SessionContext::new();
+ ctx.register_parquet(
+ "t",
+ "../../examples/testdata/alltypes_plain.parquet",
+ datafusion::prelude::ParquetReadOptions::default(),
+ )
+ .await
+ .unwrap();
+
+ let plan = ctx
+ .state()
+ .create_logical_plan(
+ "select string_col from t intersect select string_col from t",
+ )
+ .await
+ .unwrap();
+ let plan = ctx.state().optimize(&plan).unwrap();
+ assert_eq!(
+ join_null_equality(&plan),
+ Some(NullEquality::NullEqualsNull),
+ "precondition: INTERSECT should plan a NullEqualsNull join"
+ );
+
+ let codec = BallistaLogicalExtensionCodec::default();
+ let node = LogicalPlanNode::try_from_logical_plan(&plan,
&codec).unwrap();
+ let roundtripped = node.try_into_logical_plan(&ctx.task_ctx(),
&codec).unwrap();
+
+ assert_eq!(
+ join_null_equality(&roundtripped),
+ Some(NullEquality::NullEqualsNull),
+ "null_equality must survive the logical plan protobuf roundtrip"
+ );
+ }
+
/// `RuntimeStatsExec` in sketching mode round-trips its ORDER BY
/// and keeps the sketch accessor available. Row-count accessor is
/// always available (and empty on a fresh operator).
diff --git a/benchmarks/src/bin/tpcds.rs b/benchmarks/src/bin/tpcds.rs
index b55cdfeac..630e6f2b4 100644
--- a/benchmarks/src/bin/tpcds.rs
+++ b/benchmarks/src/bin/tpcds.rs
@@ -62,17 +62,6 @@ const TABLES: &[&str] = &[
/// under the default (static) planner; this list reflects that configuration.
/// Remove an entry as the underlying cause is fixed.
const SKIP: &[(usize, &str)] = &[
- // Distributed execution diverges from single-process DataFusion on the
same
- // data (confirmed reproducible; DataFusion is correct under both join
modes).
- // See https://github.com/apache/datafusion-ballista/issues/2046
- (
- 38,
- "distributed INTERSECT diverges from DataFusion (issue #2046)",
- ),
- (
- 87,
- "distributed EXCEPT diverges from DataFusion (issue #2046)",
- ),
// Non-deterministic: LIMIT/ORDER BY ties without a total order make the
// result vary run-to-run in both engines, so a row-by-row diff is
unstable.
(31, "non-deterministic (ORDER BY ties; varies run-to-run)"),
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]