Skip to content

Commit 3721aa9

Browse files
committed
Re-enabled null-equal join dynamic filters via an IS NULL predicate.
apache#22965 disabled dynamic filter pushdown for null-equal joins because the build-side predicate prunes a probe-side NULL that can null-match a build-side NULL. Push the filter with `OR key IS NULL` over the nullable probe keys instead, the way apache#23104 does for null-aware anti joins. A NOT NULL key never widens the filter, so an all-NOT-NULL join keeps full selectivity.
1 parent f30d9bc commit 3721aa9

3 files changed

Lines changed: 133 additions & 33 deletions

File tree

datafusion/physical-plan/src/joins/hash_join/exec.rs

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -862,14 +862,6 @@ impl HashJoinExec {
862862
return false;
863863
}
864864

865-
// Bounds and membership filters derived from the build side do not
866-
// account for null-equal matching: a probe-side NULL key evaluates
867-
// such predicates to NULL and would be pruned, even though it can
868-
// match a build-side NULL when nulls compare equal.
869-
if self.null_equality == NullEquality::NullEqualsNull {
870-
return false;
871-
}
872-
873865
// `preserve_file_partitions` can report Hash partitioning for Hive-style
874866
// file groups, but those partitions are not actually hash-distributed.
875867
// Partitioned dynamic filters rely on hash routing, so disable them in
@@ -1363,6 +1355,7 @@ impl ExecutionPlan for HashJoinExec {
13631355
filter,
13641356
on_right,
13651357
repartition_random_state,
1358+
self.null_equality,
13661359
self.null_aware,
13671360
))
13681361
})))
@@ -6629,7 +6622,7 @@ mod tests {
66296622
}
66306623

66316624
#[test]
6632-
fn test_dynamic_filter_pushdown_rejects_null_equal_join() -> Result<()> {
6625+
fn test_dynamic_filter_pushdown_allowed_for_null_equal_join() -> Result<()> {
66336626
let (_, _, on) = build_schema_and_on()?;
66346627
let left = build_table(("a1", &vec![1]), ("b1", &vec![1]), ("c1", &vec![1]));
66356628
let right = build_table(("a2", &vec![1]), ("b1", &vec![1]), ("c2", &vec![1]));
@@ -6652,7 +6645,9 @@ mod tests {
66526645
false,
66536646
)?;
66546647

6655-
assert!(!join.allow_join_dynamic_filter_pushdown(session_config.options()));
6648+
// Null-equal joins keep dynamic filter pushdown: the pushed predicate carries an
6649+
// `IS NULL` disjunct so a probe-side NULL still reaches the join.
6650+
assert!(join.allow_join_dynamic_filter_pushdown(session_config.options()));
66566651

66576652
Ok(())
66586653
}

datafusion/physical-plan/src/joins/hash_join/shared_bounds.rs

Lines changed: 95 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ use crate::joins::hash_join::partitioned_hash_eval::{
3333
use arrow::array::ArrayRef;
3434
use arrow::datatypes::{DataType, Field, Schema};
3535
use datafusion_common::config::ConfigOptions;
36-
use datafusion_common::{DataFusionError, Result, ScalarValue, SharedResult};
36+
use datafusion_common::{
37+
DataFusionError, NullEquality, Result, ScalarValue, SharedResult,
38+
};
3739
use datafusion_expr::Operator;
3840
use datafusion_functions::core::r#struct as struct_func;
3941
use datafusion_physical_expr::expressions::{
@@ -255,6 +257,9 @@ pub(crate) struct SharedBuildAccumulator {
255257
repartition_random_state: SeededRandomState,
256258
/// Schema of the probe (right) side for evaluating filter expressions
257259
probe_schema: Arc<Schema>,
260+
/// Null equality of the join. Under `NullEqualsNull` a probe-side NULL can match a
261+
/// build-side NULL, so the pushed filter must keep NULL rows here too.
262+
null_equality: NullEquality,
258263
/// Null-aware anti join (`NOT IN`). A probe-side NULL must reach the join so its
259264
/// three-valued logic can collapse the result, so the pushed filter keeps NULL rows.
260265
null_aware: bool,
@@ -361,6 +366,7 @@ impl SharedBuildAccumulator {
361366
dynamic_filter: Arc<DynamicFilterPhysicalExpr>,
362367
on_right: Vec<PhysicalExprRef>,
363368
repartition_random_state: SeededRandomState,
369+
null_equality: NullEquality,
364370
null_aware: bool,
365371
) -> Self {
366372
// Troubleshooting: If partition counts are incorrect, verify this logic matches
@@ -408,6 +414,7 @@ impl SharedBuildAccumulator {
408414
on_right,
409415
repartition_random_state,
410416
probe_schema: right_child.schema(),
417+
null_equality,
411418
null_aware,
412419
}
413420
}
@@ -585,7 +592,7 @@ impl SharedBuildAccumulator {
585592
combine_membership_and_bounds(membership_expr, bounds_expr)
586593
{
587594
self.dynamic_filter
588-
.update(self.null_aware_filter(filter_expr))?;
595+
.update(self.preserve_probe_nulls(filter_expr))?;
589596
}
590597
}
591598
PartitionStatus::Pending => {
@@ -692,33 +699,47 @@ impl SharedBuildAccumulator {
692699
};
693700

694701
self.dynamic_filter
695-
.update(self.null_aware_filter(filter_expr))?;
702+
.update(self.preserve_probe_nulls(filter_expr))?;
696703
}
697704
}
698705

699706
Ok(())
700707
}
701708

702-
/// Wraps a pushdown filter so a null-aware anti join keeps its probe-side NULL rows.
709+
/// Keeps probe rows with a NULL key when the join semantics need them.
703710
///
704-
/// The build-side predicate drops probe rows whose key is NULL, but `NOT IN` three-valued
705-
/// logic needs that NULL to reach the join. OR-ing `probe_key IS NULL` preserves the dynamic
706-
/// filter's selectivity for non-NULL rows while letting the NULL through.
707-
fn null_aware_filter(
711+
/// The build-side predicate drops probe rows whose key is NULL. A null-aware anti join
712+
/// (`NOT IN`) needs that NULL to reach the join so three-valued logic can collapse the
713+
/// result, and a null-equal join needs it to match a build-side NULL. OR-ing `key IS NULL`
714+
/// keeps those rows while preserving the filter's selectivity for the rest; the join refines
715+
/// whatever the widened filter lets through.
716+
fn preserve_probe_nulls(
708717
&self,
709718
filter_expr: Arc<dyn PhysicalExpr>,
710719
) -> Arc<dyn PhysicalExpr> {
711-
if !self.null_aware {
720+
if self.null_equality != NullEquality::NullEqualsNull && !self.null_aware {
712721
return filter_expr;
713722
}
714-
// A null-aware anti join is validated to a single probe key.
715-
let probe_key_is_null: Arc<dyn PhysicalExpr> =
716-
Arc::new(IsNullExpr::new(Arc::clone(&self.on_right[0])));
717-
Arc::new(BinaryExpr::new(
718-
filter_expr,
719-
Operator::Or,
720-
probe_key_is_null,
721-
))
723+
// Only a key that can actually be NULL needs the disjunct; a NOT NULL key never widens.
724+
// Null-aware joins are single-key; null-equal joins can be multi-key, so OR every nullable
725+
// key. If every key is NOT NULL the filter is left untouched, at full selectivity.
726+
let any_key_is_null = self
727+
.on_right
728+
.iter()
729+
.filter(|key| key.nullable(&self.probe_schema).unwrap_or(true))
730+
.map(|key| {
731+
Arc::new(IsNullExpr::new(Arc::clone(key))) as Arc<dyn PhysicalExpr>
732+
})
733+
.reduce(|acc, is_null| {
734+
Arc::new(BinaryExpr::new(acc, Operator::Or, is_null))
735+
as Arc<dyn PhysicalExpr>
736+
});
737+
match any_key_is_null {
738+
Some(any_key_is_null) => {
739+
Arc::new(BinaryExpr::new(filter_expr, Operator::Or, any_key_is_null))
740+
}
741+
None => filter_expr,
742+
}
722743
}
723744
}
724745

@@ -751,6 +772,7 @@ pub(super) fn make_partitioned_accumulator_for_test(
751772
on_right: vec![],
752773
repartition_random_state: SeededRandomState::with_seed(1),
753774
probe_schema,
775+
null_equality: NullEquality::NullEqualsNothing,
754776
null_aware: false,
755777
}
756778
}
@@ -771,6 +793,7 @@ pub(super) fn completed_partitions_for_test(acc: &SharedBuildAccumulator) -> usi
771793
#[cfg(test)]
772794
mod tests {
773795
use super::*;
796+
use datafusion_physical_expr::expressions::Column;
774797

775798
fn partitioned_state(acc: &SharedBuildAccumulator) -> (Vec<PartitionStatus>, usize) {
776799
let guard = acc.inner.lock();
@@ -840,4 +863,59 @@ mod tests {
840863
assert!(matches!(partitions[0], PartitionStatus::CanceledUnknown));
841864
assert_eq!(completed, 1);
842865
}
866+
867+
fn null_equal_accumulator(
868+
probe_schema: Arc<Schema>,
869+
on_right: Vec<PhysicalExprRef>,
870+
) -> SharedBuildAccumulator {
871+
SharedBuildAccumulator {
872+
inner: Mutex::new(AccumulatorState {
873+
data: AccumulatedBuildData::Partitioned {
874+
partitions: vec![PartitionStatus::Pending; 1],
875+
completed_partitions: 0,
876+
},
877+
completion: CompletionState::Pending,
878+
}),
879+
completion_notify: Notify::new(),
880+
dynamic_filter: Arc::new(DynamicFilterPhysicalExpr::new(vec![], lit(true))),
881+
on_right,
882+
repartition_random_state: SeededRandomState::with_seed(1),
883+
probe_schema,
884+
null_equality: NullEquality::NullEqualsNull,
885+
null_aware: false,
886+
}
887+
}
888+
889+
#[test]
890+
fn preserve_probe_nulls_only_widens_nullable_keys() {
891+
let probe_schema = Arc::new(Schema::new(vec![
892+
Field::new("k_nullable", DataType::Int32, true),
893+
Field::new("k_not_null", DataType::Int32, false),
894+
]));
895+
let on_right: Vec<PhysicalExprRef> = vec![
896+
Arc::new(Column::new("k_nullable", 0)),
897+
Arc::new(Column::new("k_not_null", 1)),
898+
];
899+
let acc = null_equal_accumulator(probe_schema, on_right);
900+
901+
// Only the nullable key earns an IS NULL disjunct; the NOT NULL key is left out.
902+
let widened = acc.preserve_probe_nulls(lit(true));
903+
assert_eq!(format!("{widened}").matches("IS NULL").count(), 1);
904+
}
905+
906+
#[test]
907+
fn preserve_probe_nulls_leaves_all_not_null_keys_untouched() {
908+
let probe_schema = Arc::new(Schema::new(vec![
909+
Field::new("a", DataType::Int32, false),
910+
Field::new("b", DataType::Int32, false),
911+
]));
912+
let on_right: Vec<PhysicalExprRef> =
913+
vec![Arc::new(Column::new("a", 0)), Arc::new(Column::new("b", 1))];
914+
let acc = null_equal_accumulator(probe_schema, on_right);
915+
916+
// Every key is NOT NULL, so there is nothing to OR in and the filter is returned as-is.
917+
let filter = lit(true);
918+
let result = acc.preserve_probe_nulls(Arc::clone(&filter));
919+
assert_eq!(format!("{result}"), format!("{filter}"));
920+
}
843921
}

datafusion/sqllogictest/test_files/push_down_filter_parquet.slt

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,10 +1024,10 @@ drop table int_probe;
10241024

10251025

10261026
########
1027-
# Dynamic filters must not be created for null-equal joins (IS NOT DISTINCT
1028-
# FROM, INTERSECT): min/max bounds and membership filters derived from the
1029-
# build side evaluate to NULL for probe-side NULL keys and would prune rows
1030-
# that can null-match a build-side NULL.
1027+
# Null-equal joins (IS NOT DISTINCT FROM, INTERSECT) keep dynamic filter pushdown.
1028+
# Min/max bounds and membership filters derived from the build side evaluate to NULL
1029+
# for a probe-side NULL key, so the pushed predicate carries an `IS NULL` disjunct that
1030+
# lets the probe NULL reach the join and null-match a build-side NULL.
10311031
########
10321032

10331033
statement ok
@@ -1049,14 +1049,14 @@ SELECT nej_build.id, nej_probe.id FROM nej_build JOIN nej_probe ON nej_build.id
10491049
11 11
10501050
NULL NULL
10511051

1052-
# No DynamicFilter predicate may appear on the probe side of a null-equal join
1052+
# The probe side now carries a DynamicFilter for a null-equal join (widened with IS NULL at runtime)
10531053
query TT
10541054
EXPLAIN SELECT nej_build.id, nej_probe.id FROM nej_build JOIN nej_probe ON nej_build.id IS NOT DISTINCT FROM nej_probe.id
10551055
----
10561056
physical_plan
10571057
01)HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(id@0, id@0)], NullsEqual: true
10581058
02)--DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch/push_down_filter_parquet/nej_build.parquet]]}, projection=[id], file_type=parquet
1059-
03)--DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch/push_down_filter_parquet/nej_probe.parquet]]}, projection=[id], file_type=parquet
1059+
03)--DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch/push_down_filter_parquet/nej_probe.parquet]]}, projection=[id], file_type=parquet, predicate=DynamicFilter [ empty ], dynamic_rg_pruning=eligible
10601060

10611061
statement ok
10621062
drop table nej_build;
@@ -1065,6 +1065,33 @@ statement ok
10651065
drop table nej_probe;
10661066

10671067

1068+
# Multi-key null-equal join: the IS NULL disjunct covers every nullable key, so a probe row with a
1069+
# NULL in either key still reaches the join and null-matches the build side.
1070+
statement ok
1071+
COPY (SELECT * FROM (VALUES (1, 10), (2, NULL), (NULL, 30)) v(a, b)) TO 'test_files/scratch/push_down_filter_parquet/mnej_probe.parquet' STORED AS PARQUET;
1072+
1073+
statement ok
1074+
COPY (SELECT * FROM (VALUES (1, 10), (2, NULL)) v(a, b)) TO 'test_files/scratch/push_down_filter_parquet/mnej_build.parquet' STORED AS PARQUET;
1075+
1076+
statement ok
1077+
CREATE EXTERNAL TABLE mnej_probe STORED AS PARQUET LOCATION 'test_files/scratch/push_down_filter_parquet/mnej_probe.parquet';
1078+
1079+
statement ok
1080+
CREATE EXTERNAL TABLE mnej_build STORED AS PARQUET LOCATION 'test_files/scratch/push_down_filter_parquet/mnej_build.parquet';
1081+
1082+
query IIII rowsort
1083+
SELECT mnej_build.a, mnej_build.b, mnej_probe.a, mnej_probe.b FROM mnej_build JOIN mnej_probe ON (mnej_build.a IS NOT DISTINCT FROM mnej_probe.a) AND (mnej_build.b IS NOT DISTINCT FROM mnej_probe.b)
1084+
----
1085+
1 10 1 10
1086+
2 NULL 2 NULL
1087+
1088+
statement ok
1089+
drop table mnej_build;
1090+
1091+
statement ok
1092+
drop table mnej_probe;
1093+
1094+
10681095
# Config reset
10691096
statement ok
10701097
RESET datafusion.explain.physical_plan_only;

0 commit comments

Comments
 (0)