Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0c5a549
Enable filters for range-partitioned joins
peterxcli Jul 23, 2026
0b71b56
Merge branch 'main' of https://github.com/apache/datafusion into feat…
peterxcli Jul 23, 2026
a9167cb
revert header change
peterxcli Jul 23, 2026
4206b36
remove float zero test
peterxcli Jul 24, 2026
1a141f3
address review batch 1
peterxcli Jul 24, 2026
d6b843b
Allow dynamic filters for range-partitioned joins
peterxcli Jul 31, 2026
bf4e6a1
Merge remote-tracking branch 'upstream/main' into feat/hash-join-dyna…
peterxcli Jul 31, 2026
7d2589f
Merge branch 'main' into feat/hash-join-dynamic-filter-with-range-par…
peterxcli Jul 31, 2026
95ad3ca
jay's review
peterxcli Aug 4, 2026
dd7fefa
add diagram
peterxcli Aug 4, 2026
10ee4c7
jay's 2nd review
peterxcli Aug 5, 2026
18f9db3
Merge branch 'main' into feat/hash-join-dynamic-filter-with-range-par…
peterxcli Aug 5, 2026
3a9d253
dedup filter pushdown test
peterxcli Aug 8, 2026
6dae145
review for exec.rs
peterxcli Aug 8, 2026
7f84831
replace csv with pq and add slt
peterxcli Aug 8, 2026
fdb292f
Merge branch 'main' into feat/hash-join-dynamic-filter-with-range-par…
peterxcli Aug 9, 2026
4990a57
cargo fmt
peterxcli Aug 9, 2026
ee07f7b
remove duplicate imports
peterxcli Aug 10, 2026
8903354
CASE WHEN over range_split[...] -> range_partition PhysicalExpr
peterxcli Aug 10, 2026
37fb97b
Merge branch 'main' into feat/hash-join-dynamic-filter-with-range-par…
peterxcli Aug 10, 2026
eaf7f86
codegen
peterxcli Aug 10, 2026
01210bd
revert topk filter builder
peterxcli Aug 11, 2026
00ef9f0
assert_or_internal_err, top import and on_columns accessor
peterxcli Aug 11, 2026
f9f1f0d
check the range and sort properties
peterxcli Aug 11, 2026
7274de3
Merge branch 'main' into feat/hash-join-dynamic-filter-with-range-par…
peterxcli Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 252 additions & 1 deletion datafusion/core/tests/physical_optimizer/filter_pushdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ use datafusion_physical_expr::{
LexOrdering, PhysicalSortExpr, expressions::col, utils::conjunction,
};
use datafusion_physical_expr::{
Partitioning, ScalarFunctionExpr, aggregate::AggregateExprBuilder,
Partitioning, RangePartitioning, ScalarFunctionExpr, SplitPoint,
aggregate::AggregateExprBuilder,
};
use datafusion_physical_optimizer::{
PhysicalOptimizerRule, filter_pushdown::FilterPushdown,
Expand Down Expand Up @@ -1191,6 +1192,256 @@ async fn test_hashjoin_dynamic_filter_pushdown_partitioned() {
);
}

#[tokio::test]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you need this comment. It's totally fine to construct the plan manually. The Hash partitioned test in this file does the same.

For the record, I think there should be a way to get range partitioning working in sqllogictest? See here:

# Licensed to the Apache Software Foundation (ASF) under one

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the pointer, but seems like the slt is using csv as data source, which cant do filter pushdown.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, ya it is using a csv backed table but we could either:

  • update it to be parquet backed and reuse them for your testing (I would prefer this is the code changes are small)
  • or could introduce a ParquetSource in the PR and add coverage there

For range partitioning I would really prefer there to be coverage for all features in this file 🙇 .

NOTE: I would still keep the existing tests you have, they are great

async fn test_hashjoin_dynamic_filter_pushdown_range_partitioned() {
use datafusion_common::JoinType;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could we keep these at the module level?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I followed how test_hashjoin_hash_table_pushdown_partitioned's pattern

use datafusion_common::JoinType;
use datafusion_physical_plan::joins::{HashJoinExec, PartitionMode};

it also import the similar things at the top of test function.

should I also move their use to module level, or just follow their convention?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since it seems we are repeating imports, would probably be good to add these to module level, thaknk you 🙇

use datafusion_physical_plan::joins::{HashJoinExec, PartitionMode};

// Rough sketch of the Range-partitioned MRE we're trying to recreate. The
// test hand-wires identical Range repartitioning:
//
// EXPLAIN
// SELECT *
// FROM build
// JOIN probe
// ON build.a = probe.a AND build.b = probe.b;
//
// +---------------+------------------------------------------------------------+
// | plan_type | plan |
// +---------------+------------------------------------------------------------+
// | physical_plan | ┌───────────────────────────┐ |
// | | │ HashJoinExec │ |
// | | │ -------------------- ├──────────────┐ |
// | | │ on: (a = a), (b = b) │ │ |
// | | └─────────────┬─────────────┘ │ |
// | | ┌─────────────┴─────────────┐┌─────────────┴─────────────┐ |
// | | │ RepartitionExec ││ RepartitionExec │ |
// | | │ -------------------- ││ -------------------- │ |
// | | │ partition_count(in->out): ││ partition_count(in->out): │ |
// | | │ 1 -> 2 ││ 1 -> 2 │ |
// | | │ ││ │ |
// | | │ partitioning_scheme: ││ partitioning_scheme: │ |
// | | │ Range([a ASC, b ASC], 2) ││ Range([a ASC, b ASC], 2) │ |
// | | │ split: (aa, bb) ││ split: (aa, bb) │ |
// | | └─────────────┬─────────────┘└─────────────┬─────────────┘ |
// | | ┌─────────────┴─────────────┐┌─────────────┴─────────────┐ |
// | | │ DataSourceExec (build) ││ DataSourceExec (probe) │ |
// | | │ -------------------- ││ -------------------- │ |
// | | │ rows: (aa,ba), (ab,bb) ││ rows: (aa,ba) ... (ad,bd) │ |
// | | │ ││ predicate: DynamicFilter │ |
// | | │ ││ range CASE -> filter_0/1 │ |
// | | └───────────────────────────┘└───────────────────────────┘ |
// | | |
// +---------------+------------------------------------------------------------+

// Create build side with limited values
let build_batches = vec![
Comment thread
peterxcli marked this conversation as resolved.
Outdated
record_batch!(
("a", Utf8, ["aa", "ab"]),
("b", Utf8, ["ba", "bb"]),
("c", Float64, [1.0, 2.0]) // Extra column not used in join
)
.unwrap(),
];
let build_side_schema = Arc::new(Schema::new(vec![
Field::new("a", DataType::Utf8, false),
Field::new("b", DataType::Utf8, false),
Field::new("c", DataType::Float64, false),
]));
let build_scan = TestScanBuilder::new(Arc::clone(&build_side_schema))
.with_support(true)
.with_batches(build_batches)
.build();

// Create probe side with more values
let probe_batches = vec![
record_batch!(
("a", Utf8, ["aa", "ab", "ac", "ad"]),
("b", Utf8, ["ba", "bb", "bc", "bd"]),
("e", Float64, [1.0, 2.0, 3.0, 4.0]) // Extra column not used in join
)
.unwrap(),
];
let probe_side_schema = Arc::new(Schema::new(vec![
Field::new("a", DataType::Utf8, false),
Field::new("b", DataType::Utf8, false),
Field::new("e", DataType::Float64, false),
]));
let probe_scan = TestScanBuilder::new(Arc::clone(&probe_side_schema))
.with_support(true)
.with_batches(probe_batches)
.build();

let split_points = vec![SplitPoint::new(vec![
ScalarValue::Utf8(Some("aa".to_string())),
ScalarValue::Utf8(Some("bb".to_string())),
])];

// Build side: DataSource -> RepartitionExec (Range)
let build_range_ordering = LexOrdering::new(vec![
PhysicalSortExpr::new(
col("a", &build_side_schema).unwrap(),
SortOptions::default(),
),
PhysicalSortExpr::new(
col("b", &build_side_schema).unwrap(),
SortOptions::default(),
),
])
.unwrap();
let build_repartition = Arc::new(
RepartitionExec::try_new(
build_scan,
Partitioning::Range(
RangePartitioning::try_new(build_range_ordering, split_points.clone())
.unwrap(),
),
)
.unwrap(),
);

// Probe side: DataSource -> RepartitionExec (Range)
let probe_range_ordering = LexOrdering::new(vec![
PhysicalSortExpr::new(
col("a", &probe_side_schema).unwrap(),
SortOptions::default(),
),
PhysicalSortExpr::new(
col("b", &probe_side_schema).unwrap(),
SortOptions::default(),
),
])
.unwrap();
let probe_repartition = Arc::new(
RepartitionExec::try_new(
Arc::clone(&probe_scan),
Partitioning::Range(
RangePartitioning::try_new(probe_range_ordering, split_points).unwrap(),
),
)
.unwrap(),
);

// Create HashJoinExec with partitioned inputs
let on = vec![
(
col("a", &build_side_schema).unwrap(),
col("a", &probe_side_schema).unwrap(),
),
(
col("b", &build_side_schema).unwrap(),
col("b", &probe_side_schema).unwrap(),
),
];
let hash_join = Arc::new(
HashJoinExec::try_new(
build_repartition,
probe_repartition,
on,
None,
&JoinType::Inner,
None,
PartitionMode::Partitioned,
datafusion_common::NullEquality::NullEqualsNothing,
false,
)
.unwrap(),
);

// Top-level CoalescePartitionsExec
let cp = Arc::new(CoalescePartitionsExec::new(hash_join)) as Arc<dyn ExecutionPlan>;
// Add a sort for deterministic output
let plan = Arc::new(SortExec::new(
LexOrdering::new(vec![PhysicalSortExpr::new(
col("a", &probe_side_schema).unwrap(),
SortOptions::new(true, false), // descending, nulls_first
)])
.unwrap(),
cp,
)) as Arc<dyn ExecutionPlan>;

// expect the predicate to be pushed down into the probe side DataSource
insta::assert_snapshot!(
OptimizationTest::new(Arc::clone(&plan), FilterPushdown::new_post_optimization(), true),
@r"
OptimizationTest:
input:
- SortExec: expr=[a@0 DESC NULLS LAST], preserve_partitioning=[false]
- CoalescePartitionsExec
- HashJoinExec: mode=Partitioned, join_type=Inner, on=[(a@0, a@0), (b@1, b@1)]
- RepartitionExec: partitioning=Range([a@0 ASC, b@1 ASC], [(aa, bb)], 2), input_partitions=1
- DataSourceExec: file_groups={1 group: [[test.parquet]]}, projection=[a, b, c], file_type=test, pushdown_supported=true
- RepartitionExec: partitioning=Range([a@0 ASC, b@1 ASC], [(aa, bb)], 2), input_partitions=1
- DataSourceExec: file_groups={1 group: [[test.parquet]]}, projection=[a, b, e], file_type=test, pushdown_supported=true
output:
Ok:
- SortExec: expr=[a@0 DESC NULLS LAST], preserve_partitioning=[false]
- CoalescePartitionsExec
- HashJoinExec: mode=Partitioned, join_type=Inner, on=[(a@0, a@0), (b@1, b@1)]
- RepartitionExec: partitioning=Range([a@0 ASC, b@1 ASC], [(aa, bb)], 2), input_partitions=1
- DataSourceExec: file_groups={1 group: [[test.parquet]]}, projection=[a, b, c], file_type=test, pushdown_supported=true
- RepartitionExec: partitioning=Range([a@0 ASC, b@1 ASC], [(aa, bb)], 2), input_partitions=1
- DataSourceExec: file_groups={1 group: [[test.parquet]]}, projection=[a, b, e], file_type=test, pushdown_supported=true, predicate=DynamicFilter [ empty ]
"
);

// Actually apply the optimization to the plan and execute to see the filter in action
let mut config = ConfigOptions::default();
config.execution.parquet.pushdown_filters = true;
config.optimizer.enable_dynamic_filter_pushdown = true;
config.optimizer.preserve_file_partitions = 1;
let plan = FilterPushdown::new_post_optimization()
.optimize(plan, &config)
.unwrap();

let config = SessionConfig::from(config).with_batch_size(10);
let session_ctx = SessionContext::new_with_config(config);
session_ctx.register_object_store(
ObjectStoreUrl::parse("test://").unwrap().as_ref(),
Arc::new(InMemory::new()),
);
let state = session_ctx.state();
let task_ctx = state.task_ctx();
let batches = collect(Arc::clone(&plan), Arc::clone(&task_ctx))
.await
.unwrap();

// Now check what our filter looks like
insta::assert_snapshot!(
format!("{}", format_plan_for_test(&plan)),
@r"
- SortExec: expr=[a@0 DESC NULLS LAST], preserve_partitioning=[false]
- CoalescePartitionsExec
- HashJoinExec: mode=Partitioned, join_type=Inner, on=[(a@0, a@0), (b@1, b@1)]
- RepartitionExec: partitioning=Range([a@0 ASC, b@1 ASC], [(aa, bb)], 2), input_partitions=1
- DataSourceExec: file_groups={1 group: [[test.parquet]]}, projection=[a, b, c], file_type=test, pushdown_supported=true
- RepartitionExec: partitioning=Range([a@0 ASC, b@1 ASC], [(aa, bb)], 2), input_partitions=1
- DataSourceExec: file_groups={1 group: [[test.parquet]]}, projection=[a, b, e], file_type=test, pushdown_supported=true, predicate=DynamicFilter [ CASE WHEN a@0 IS NULL OR a@0 < aa OR a@0 = aa AND (b@1 IS NULL OR b@1 < bb) THEN a@0 >= aa AND a@0 <= aa AND b@1 >= ba AND b@1 <= ba AND struct(a@0, b@1) IN (SET) ([{c0:aa,c1:ba}]) ELSE a@0 >= ab AND a@0 <= ab AND b@1 >= bb AND b@1 <= bb AND struct(a@0, b@1) IN (SET) ([{c0:ab,c1:bb}]) END ]
Comment thread
peterxcli marked this conversation as resolved.
Outdated
"
);

let result = format!("{}", pretty_format_batches(&batches).unwrap());

let probe_scan_metrics = probe_scan.metrics().unwrap();

// The probe side had 4 rows, but after applying the dynamic filter only 2 rows should remain.
// The number of output rows from the probe side scan should stay consistent across executions.
// Issue: https://github.com/apache/datafusion/issues/17451
assert_eq!(probe_scan_metrics.output_rows().unwrap(), 2);

insta::assert_snapshot!(
result,
@r"
+----+----+-----+----+----+-----+
| a | b | c | a | b | e |
+----+----+-----+----+----+-----+
| ab | bb | 2.0 | ab | bb | 2.0 |
| aa | ba | 1.0 | aa | ba | 1.0 |
+----+----+-----+----+----+-----+
",
);
}

// Not portable to sqllogictest: this test specifically pins a
// `RepartitionExec(Hash, 12)` between `HashJoinExec(CollectLeft)` and the
// probe-side scan to verify the dynamic filter link survives that boundary
Expand Down
78 changes: 70 additions & 8 deletions datafusion/physical-plan/src/joins/hash_join/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -891,16 +891,20 @@ impl HashJoinExec {
// https://github.com/apache/datafusion/issues/20195
if config.optimizer.preserve_file_partitions > 0
&& self.mode == PartitionMode::Partitioned
&& !matches!(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this might read easier if we chcked if it is Partitioning::Hash instead, what do you think?

Could we also update the comment explaining what we allow and what we do not as of now and the reasons why (partition / filter alignment)

(
self.left.output_partitioning(),
self.right.output_partitioning()
),
(Partitioning::Range(_), Partitioning::Range(_))
)
{
return false;
}

if self.mode == PartitionMode::Partitioned
&& !self.has_partitioned_dynamic_filter_routing()
{
// TODO: support partition-routed dynamic filters for compatible
// range co-partitioned joins.
// <https://github.com/apache/datafusion/issues/23376>.
return false;
}

Expand All @@ -916,6 +920,14 @@ impl HashJoinExec {
Partitioning::Hash(_, left_partition_count),
Partitioning::Hash(_, right_partition_count),
) => left_partition_count == right_partition_count,
(Partitioning::Range(_), Partitioning::Range(_)) => {
let children = [self.left.as_ref(), self.right.as_ref()];
matches!(
self.input_distribution_requirements()
.unsatisfied_co_partitioned_children(self.name(), &children),
Ok(unsatisfied) if unsatisfied.is_empty()
)
}
(left_partitioning, right_partitioning) => {
left_partitioning.partition_count() == 1
&& right_partitioning.partition_count() == 1
Expand Down Expand Up @@ -7029,8 +7041,7 @@ mod tests {
}

#[test]
fn test_partitioned_dynamic_filter_pushdown_rejects_range_partitioning() -> Result<()>
{
fn test_partitioned_dynamic_filter_pushdown_eligibility() -> Result<()> {
let (left_schema, right_schema, on) = build_schema_and_on()?;
let left_partitioning = Partitioning::Range(RangePartitioning::try_new(
[PhysicalSortExpr {
Expand All @@ -7053,7 +7064,7 @@ mod tests {
left_partitioning,
)?);
let right = Arc::new(PartitionedTestExec::try_new(
right_schema,
Arc::clone(&right_schema),
right_partitioning,
)?);

Expand All @@ -7064,8 +7075,57 @@ mod tests {
.enable_join_dynamic_filter_pushdown = true;

let join = HashJoinExec::try_new(
left,
Arc::clone(&left) as Arc<dyn ExecutionPlan>,
right,
on.clone(),
None,
&JoinType::Inner,
None,
PartitionMode::Partitioned,
NullEquality::NullEqualsNothing,
false,
)?;

assert!(join.allow_join_dynamic_filter_pushdown(session_config.options()));
Comment thread
peterxcli marked this conversation as resolved.
Outdated

let hash_join = join
Comment thread
peterxcli marked this conversation as resolved.
Outdated
.builder()
.with_new_children(vec![
Arc::new(PartitionedTestExec::try_new(
join.left().schema(),
Partitioning::Hash(vec![Arc::clone(&on[0].0)], 2),
)?),
Arc::new(PartitionedTestExec::try_new(
join.right().schema(),
Partitioning::Hash(vec![Arc::clone(&on[0].1)], 2),
)?),
])?
.build()?;
assert!(hash_join.allow_join_dynamic_filter_pushdown(session_config.options()));

session_config
.options_mut()
.optimizer
.preserve_file_partitions = 1;
assert!(join.allow_join_dynamic_filter_pushdown(session_config.options()));
assert!(!hash_join.allow_join_dynamic_filter_pushdown(session_config.options()));

let mismatched_right_partitioning =
Partitioning::Range(RangePartitioning::try_new(
[PhysicalSortExpr {
expr: Arc::clone(&on[0].1),
options: Default::default(),
}]
.into(),
vec![SplitPoint::new(vec![ScalarValue::Int32(Some(11))])],
)?);
let mismatched_right = Arc::new(PartitionedTestExec::try_new(
right_schema,
mismatched_right_partitioning,
)?);
let mismatched_join = HashJoinExec::try_new(
left,
mismatched_right,
on,
None,
&JoinType::Inner,
Expand All @@ -7075,7 +7135,9 @@ mod tests {
false,
)?;

assert!(!join.allow_join_dynamic_filter_pushdown(session_config.options()));
assert!(
!mismatched_join.allow_join_dynamic_filter_pushdown(session_config.options())
);

Ok(())
}
Expand Down
Loading
Loading