datafusion_pruning/file_pruner.rs
1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements. See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership. The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License. You may obtain a copy of the License at
8//
9// http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied. See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! File-level pruning based on partition values and file-level statistics
19
20use std::sync::Arc;
21
22use arrow::datatypes::{FieldRef, SchemaRef};
23use datafusion_common::{Result, internal_datafusion_err, pruning::PrunableStatistics};
24use datafusion_datasource::PartitionedFile;
25use datafusion_physical_expr::DynamicFilterTracking;
26use datafusion_physical_expr_common::physical_expr::PhysicalExpr;
27use datafusion_physical_plan::metrics::Count;
28use log::debug;
29
30use crate::build_pruning_predicate;
31
32/// Prune based on file-level statistics.
33///
34/// Note: Partition column pruning is handled earlier via `replace_columns_with_literals`
35/// which substitutes partition column references with their literal values before
36/// the predicate reaches this pruner.
37pub struct FilePruner {
38 predicate: Arc<dyn PhysicalExpr>,
39 /// Tracks the dynamic filters inside `predicate` so we only rebuild the
40 /// pruning predicate when one of them has actually moved.
41 tracking: DynamicFilterTracking,
42 /// Whether [`Self::should_prune`] has built+evaluated the pruning predicate
43 /// at least once. The first check always runs; subsequent checks only run
44 /// when a watched dynamic filter changed.
45 checked_once: bool,
46 /// Schema used for pruning (the logical file schema).
47 file_schema: SchemaRef,
48 file_stats_pruning: PrunableStatistics,
49 predicate_creation_errors: Count,
50}
51
52impl FilePruner {
53 #[deprecated(
54 since = "52.0.0",
55 note = "Use `try_new` instead which returns None if no statistics are available"
56 )]
57 #[expect(clippy::needless_pass_by_value)]
58 pub fn new(
59 predicate: Arc<dyn PhysicalExpr>,
60 logical_file_schema: &SchemaRef,
61 _partition_fields: Vec<FieldRef>,
62 partitioned_file: PartitionedFile,
63 predicate_creation_errors: Count,
64 ) -> Result<Self> {
65 Self::try_new(
66 predicate,
67 logical_file_schema,
68 &partitioned_file,
69 predicate_creation_errors,
70 )
71 .ok_or_else(|| {
72 internal_datafusion_err!(
73 "FilePruner::new called on a file without statistics: {:?}",
74 partitioned_file
75 )
76 })
77 }
78
79 /// Create a file pruner for this file, or `None` when pruning it cannot
80 /// help.
81 ///
82 /// Returns `None` when the file has no statistics struct to evaluate a
83 /// pruning predicate against, or when the predicate is purely static and the
84 /// file has no usable column statistics — in that case planning already did
85 /// everything such a pruner could. A predicate carrying a dynamic filter is
86 /// always accepted (given a statistics struct), since it may prune via
87 /// partition-value folding even without column statistics.
88 pub fn try_new(
89 predicate: Arc<dyn PhysicalExpr>,
90 file_schema: &SchemaRef,
91 partitioned_file: &PartitionedFile,
92 predicate_creation_errors: Count,
93 ) -> Option<Self> {
94 // A pruning predicate is evaluated against a statistics struct, so one
95 // must exist (its columns may all be `Absent`).
96 let file_stats = partitioned_file.statistics.as_ref()?;
97 let tracking = DynamicFilterTracking::classify(&predicate);
98 // Only build a pruner when it could prune something planning didn't
99 // already: the file has real column statistics, or the predicate carries
100 // a dynamic filter (whose value, or folded partition columns, can prune
101 // even without column statistics). For a purely static predicate with no
102 // usable stats there is nothing to gain.
103 if !partitioned_file.has_statistics() && !tracking.contains_dynamic_filter() {
104 return None;
105 }
106 let file_stats_pruning =
107 PrunableStatistics::new(vec![file_stats.clone()], Arc::clone(file_schema));
108 Some(Self {
109 predicate,
110 tracking,
111 checked_once: false,
112 file_schema: Arc::clone(file_schema),
113 file_stats_pruning,
114 predicate_creation_errors,
115 })
116 }
117
118 /// Returns `true` if this pruner watches a dynamic filter that can still
119 /// change, meaning [`Self::should_prune`] is worth re-checking as the scan
120 /// progresses. When `false`, the predicate is effectively static for the
121 /// remainder of the scan and the caller can avoid wrapping the stream in a
122 /// per-batch re-pruning adapter.
123 pub fn is_watching(&self) -> bool {
124 matches!(self.tracking, DynamicFilterTracking::Watching(_))
125 }
126
127 pub fn should_prune(&mut self) -> Result<bool> {
128 // Building the pruning predicate is expensive (it involves expression
129 // analysis), so we only do it on the first check and whenever a dynamic
130 // filter inside the predicate has actually moved.
131 //
132 // Dynamic filter expressions can change their values during query
133 // execution; `DynamicFilterTracking` watches the still-incomplete
134 // filters and reports a change at most once per update. A purely static
135 // predicate (or one whose dynamic filters have all completed) is checked
136 // exactly once.
137 let should_build = if self.checked_once {
138 self.tracking.watcher().is_some_and(|w| w.changed())
139 } else {
140 self.checked_once = true;
141 true
142 };
143 if !should_build {
144 return Ok(false);
145 }
146 let pruning_predicate = build_pruning_predicate(
147 Arc::clone(&self.predicate),
148 &self.file_schema,
149 &self.predicate_creation_errors,
150 );
151 let Some(pruning_predicate) = pruning_predicate else {
152 return Ok(false);
153 };
154 match pruning_predicate.prune(&self.file_stats_pruning) {
155 Ok(values) => {
156 assert!(values.len() == 1);
157 // We expect a single container -> if all containers are false skip this file
158 if values.into_iter().all(|v| !v) {
159 return Ok(true);
160 }
161 }
162 // Stats filter array could not be built, so we can't prune
163 Err(e) => {
164 debug!("Ignoring error building pruning predicate for file: {e}");
165 self.predicate_creation_errors.add(1);
166 }
167 }
168
169 Ok(false)
170 }
171}