Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 34 additions & 0 deletions datafusion/physical-expr/src/expressions/dynamic_filters/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ use datafusion_common::{
tree_node::{Transformed, TransformedResult, TreeNode},
};

use crate::expressions::lit;
use datafusion_expr::ColumnarValue;
use datafusion_expr_common::dyn_eq::DynHash;

Expand Down Expand Up @@ -343,6 +344,24 @@ impl DynamicFilterPhysicalExpr {
Ok(())
}

/// Reset this dynamic filter so its producer can publish a new expression
/// during a later execution of the same plan.
pub fn reset(&self) {
let mut current = self.inner.write();
let new_generation = current.generation + 1;
*current = Inner {
expression_id: current.expression_id,
generation: new_generation,
expr: lit(true),
is_complete: false,
};
drop(current);

let _ = self.state_watch.send(FilterState::InProgress {
generation: new_generation,
});
}

/// Mark this dynamic filter as complete and broadcast to all waiters.
///
/// This signals that all expected updates have been received.
Expand Down Expand Up @@ -1032,6 +1051,21 @@ mod test {
dynamic_filter.wait_complete().await;
}

#[test]
fn test_reset_reopens_completed_filter() {
let dynamic_filter =
DynamicFilterPhysicalExpr::new(vec![], lit(false) as Arc<dyn PhysicalExpr>);
dynamic_filter.mark_complete();
let generation_before_reset = dynamic_filter.inner.read().generation;

dynamic_filter.reset();

let inner = dynamic_filter.inner.read();
assert!(!inner.is_complete);
assert!(inner.generation > generation_before_reset);
assert_eq!(inner.expr.to_string(), "true");
}

#[test]
fn test_with_new_children_independence() {
// Create a schema with columns a, b, c, d
Expand Down
67 changes: 67 additions & 0 deletions datafusion/physical-plan/src/aggregates/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2617,6 +2617,19 @@ impl ExecutionPlan for AggregateExec {
)
}

fn reset_state(self: Arc<Self>) -> Result<Arc<dyn ExecutionPlan>> {
if let Some(dynamic_filter) = &self.dynamic_filter {
dynamic_filter.filter.reset();
for accumulator_filter in &dynamic_filter.accumulator_dyn_filter_info {
*accumulator_filter.shared_bound.lock() = ScalarValue::Null;
}
}

let mut aggregate = (*self).clone();
aggregate.metrics = ExecutionPlanMetricsSet::new();
Ok(Arc::new(aggregate))
}

fn execute(
&self,
partition: usize,
Expand Down Expand Up @@ -9814,4 +9827,58 @@ mod tests {
assert!(agg.set_dynamic_filter(df).is_err());
Ok(())
}

#[test]
fn test_aggregate_exec_reset_state() -> Result<()> {
let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int64, false)]));
let child = Arc::new(EmptyExec::new(Arc::clone(&schema)));

let agg = AggregateExec::try_new(
AggregateMode::Partial,
PhysicalGroupBy::new_single(vec![]),
vec![Arc::new(
AggregateExprBuilder::new(min_udaf(), vec![col("a", &schema)?])
.schema(Arc::clone(&schema))
.alias("min_a")
.build()?,
)],
vec![None],
child,
Arc::clone(&schema),
)?;

let df = Arc::new(DynamicFilterPhysicalExpr::new(
vec![col("a", &schema)?],
lit(false),
));
let agg = Arc::new(agg.set_dynamic_filter(Arc::clone(&df))?);

// Simulate an iteration setting a shared bound and completing the filter
if let Some(dyn_filter) = &agg.dynamic_filter {
for info in &dyn_filter.accumulator_dyn_filter_info {
*info.shared_bound.lock() = ScalarValue::Int64(Some(42));
}
dyn_filter.filter.mark_complete();
}

// Call reset_state
let reset_plan = agg.reset_state()?;
let reset_agg = reset_plan
.downcast_ref::<AggregateExec>()
.expect("should downcast back to AggregateExec");

let dyn_filter = reset_agg
.dynamic_filter
.as_ref()
.expect("dynamic filter should be retained");

for info in &dyn_filter.accumulator_dyn_filter_info {
assert_eq!(*info.shared_bound.lock(), ScalarValue::Null);
}

let current_expr = dyn_filter.filter.current()?;
assert_eq!(format!("{current_expr}"), format!("{}", lit(true)));

Ok(())
}
}
4 changes: 3 additions & 1 deletion datafusion/physical-plan/src/execution_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1996,7 +1996,9 @@ pub fn check_not_null_constraints(
///
/// While this function enables plan reuse, it does not allow the same plan to be executed if it (OR):
///
/// * uses dynamic filters,
/// * uses dynamic filters and the original plan (or another reset copy) is still
/// executing, because operators such as `HashJoinExec` and `AggregateExec`
/// reset their shared dynamic filter in place,
/// * represents a recursive query.
///
pub fn reset_plan_states(plan: Arc<dyn ExecutionPlan>) -> Result<Arc<dyn ExecutionPlan>> {
Expand Down
8 changes: 7 additions & 1 deletion datafusion/physical-plan/src/joins/hash_join/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -568,8 +568,14 @@ impl HashJoinExecBuilder {
/// Reset task-local runtime state while retaining the immutable prepared build.
pub fn reset_state(mut self) -> Self {
self.exec.left_fut = Default::default();
self.exec.dynamic_filter = None;

if let Some(dynamic_filter) = &mut self.exec.dynamic_filter {
dynamic_filter.filter.reset();
dynamic_filter.build_accumulator = OnceLock::new();
}

self.exec.metrics = ExecutionPlanMetricsSet::new();

self
}

Expand Down
105 changes: 105 additions & 0 deletions datafusion/sqllogictest/test_files/cte.slt
Original file line number Diff line number Diff line change
Expand Up @@ -1727,3 +1727,108 @@ order by 1, 2;

statement ok
drop table test;

# Dynamic filters produced by a recursive term must be reset between
# iterations. Otherwise the second execution of this uncorrelated aggregate
# reuses the first iteration's MAX bound and scans no rows.
statement ok
COPY (SELECT CAST(value AS DOUBLE) AS r FROM generate_series(0, 128)) TO 'test_files/scratch/cte/dynamic_filter_recursive.parquet' STORED AS PARQUET;

statement ok
CREATE EXTERNAL TABLE dynamic_filter_recursive STORED AS PARQUET LOCATION 'test_files/scratch/cte/dynamic_filter_recursive.parquet';

query IR rowsort
WITH RECURSIVE w(n, m) AS (
SELECT 0, CAST(NULL AS DOUBLE)
UNION ALL
SELECT w.n + 1, x.m
FROM w CROSS JOIN (
SELECT MAX(r) AS m
FROM dynamic_filter_recursive
WHERE r % 2 = 0
) x
WHERE w.n < 3
)
SELECT n, m FROM w;
----
0 NULL
1 128
2 128
3 128

statement ok
DROP TABLE dynamic_filter_recursive;

# Hash join dynamic filters must remain shared with the probe scan across
# recursive iterations (#26641). Each iteration looks up a different key; the
# EXPLAIN ANALYZE below checks that the filter is reset and republished, so the
# probe scan prunes rows on every iteration.
statement ok
SET datafusion.execution.target_partitions = 1;

statement ok
SET datafusion.execution.parquet.pushdown_filters = true;

statement ok
COPY (SELECT value AS k FROM generate_series(1, 100))
TO 'test_files/scratch/cte/hash_join_dynamic_filter.parquet' STORED AS PARQUET;

statement ok
CREATE EXTERNAL TABLE hash_join_dynamic_filter STORED AS PARQUET
LOCATION 'test_files/scratch/cte/hash_join_dynamic_filter.parquet';

query I
WITH RECURSIVE w(n) AS (
SELECT 1
UNION ALL
SELECT t.k
FROM w JOIN hash_join_dynamic_filter t ON t.k = w.n + 1
WHERE w.n < 5
)
SELECT * FROM w ORDER BY n;
----
1
2
3
4
5

statement ok
SET datafusion.explain.analyze_categories = 'rows';

# The probe scan should output four rows and prune the other 396, rather than
# outputting all 400 rows across the four iterations.
query TT
EXPLAIN ANALYZE
WITH RECURSIVE w(n) AS (
SELECT 1
UNION ALL
SELECT t.k
FROM w JOIN hash_join_dynamic_filter t ON t.k = w.n + 1
WHERE w.n < 5
)
SELECT * FROM w ORDER BY n;
----
Plan with Metrics
01)SortExec: expr=[n@0 ASC NULLS LAST], preserve_partitioning=[false], metrics=[output_rows=5, output_batches=1, spill_count=0, spilled_rows=0]
02)--RecursiveQueryExec: name=w, is_distinct=false, metrics=[output_rows=5, output_batches=5]
03)----ProjectionExec: expr=[1 as n], metrics=[output_rows=1, output_batches=1]
04)------PlaceholderRowExec, metrics=[]
05)----ProjectionExec: expr=[k@0 as n], metrics=[]
06)------HashJoinExec: mode=CollectLeft, join_type=Inner, on=[(w.n + Int64(1)@1, k@0)], projection=[k@2], metrics=[]
07)--------ProjectionExec: expr=[n@0 as n, n@0 + 1 as w.n + Int64(1)], metrics=[]
08)----------FilterExec: n@0 < 5, metrics=[]
09)------------WorkTableExec: name=w, metrics=[]
10)--------DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch/cte/hash_join_dynamic_filter.parquet]]}, projection=[k], output_ordering=[k@0 ASC NULLS LAST], file_type=parquet, predicate=DynamicFilter [ true ], dynamic_rg_pruning=eligible, metrics=[output_rows=4, output_batches=4, files_ranges_pruned_statistics=4 total → 4 matched, row_groups_pruned_statistics=4 total → 4 matched, row_groups_pruned_bloom_filter=4 total → 4 matched, page_index_pages_pruned=4 total → 4 matched, page_index_rows_pruned=400 total → 400 matched, limit_pruned_row_groups=0 total → 0 matched, batches_split=0, file_open_errors=0, file_scan_errors=0, files_opened=4, files_processed=4, num_predicate_creation_errors=0, predicate_evaluation_errors=0, pushdown_rows_matched=4, pushdown_rows_pruned=396, predicate_cache_inner_records=400, predicate_cache_records=4, scan_efficiency_ratio=157.36% (1.14 K/727)]

statement ok
DROP TABLE hash_join_dynamic_filter;

statement ok
RESET datafusion.explain.analyze_categories;

statement ok
SET datafusion.execution.target_partitions = 4;

statement ok
RESET datafusion.execution.parquet.pushdown_filters;
Loading