Skip to main content

krishiv_plan/optimizer/
dynamic_partition_pruning.rs

1//! AQE dynamic partition pruning (DPP).
2//!
3//! Mirrors Spark's `DynamicPartitionPruning` rule (3.x). The classic
4//! star-schema scenario:
5//!
6//! ```text
7//! SELECT ... FROM big_fact JOIN small_dim ON fact.k = dim.k WHERE dim.x = 1
8//! ```
9//!
10//! The small dim side runs first (or is broadcast). DPP collects the
11//! distinct values of `dim.k` after applying the dim-side filter, then
12//! pushes an `IN` filter on `fact.k` so the fact scan can skip whole
13//! row-groups / files / partitions before any per-row predicate is
14//! evaluated.
15//!
16//! # Plan shape
17//!
18//! Before:
19//! ```text
20//! HashJoin(keys=[k])
21//!   probe: Project <-- Scan(fact)
22//!   build: Project <-- Scan(dim) -- Filter(x = 1)
23//! ```
24//!
25//! After DPP (a `RuntimeFilter` from `dim.k` is attached to the fact
26//! `Scan`):
27//! ```text
28//! HashJoin(keys=[k])
29//!   probe: Scan(fact) <-- RuntimeFilter(keys=[k], max_keys=N)
30//!   build: Project <-- Scan(dim) -- Filter(x = 1)
31//! ```
32//!
33//! # When it fires
34//!
35//! 1. The plan contains a HashJoin with two children: a `build` (the
36//!    small side) and a `probe` (the large side).
37//! 2. The build side is small at runtime (≤
38//!    [`DPP_MAX_BUILD_ROWS`]) — only worth pushing a filter when the
39//!    build side is small enough to enumerate.
40//! 3. The build side has a leaf `Scan` whose connector advertises
41//!    `SupportsPushDownFilters` (the runtime filter is delivered to the
42//!    connector through a typed channel; the connector decides how to
43//!    use it — file pruning, row-group pruning, or per-row pushdown).
44//! 4. Stats are non-empty.
45//!
46//! When any of these conditions fails, the rule is a no-op and returns
47//! `None`.
48
49use crate::{NodeOp, Partitioning, PhysicalPlan, PlanNode};
50
51use super::{AqeRule, RuntimeStats, StreamingAqeGuard};
52
53/// Maximum number of build-side rows after which DPP is no longer worth
54/// the cost of building, serialising, and pushing the filter.
55///
56/// Default: 1 000 — anything above this and the filter is unlikely to
57/// prune meaningfully. Mirrors Spark's `spark.sql.optimizer.runtimeFilter
58/// .numericCanFallBackToBigIntSelectivity` boundary.
59pub const DPP_MAX_BUILD_ROWS: u64 = 1_000;
60
61/// Maximum number of distinct keys the filter retains.
62///
63/// Above this, the filter is replaced with a stub that records the
64/// overshoot in the plan's metadata and falls back to a per-row
65/// predicate at execution time. The connector may then choose to ignore
66/// the filter.
67pub const DPP_MAX_KEYS: usize = 8_192;
68
69/// Advice produced by the DPP rule: the join keys and the build-side
70/// side of the join (the small side whose distinct values feed the
71/// filter).
72#[non_exhaustive]
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct DppAdvice {
75    /// Join key shared by both sides.
76    pub join_key: String,
77    /// Build-side node id (the small side of the join).
78    pub build_node_id: String,
79    /// Probe-side node id (the side that gets the filter).
80    pub probe_node_id: String,
81    /// Observed build-side row count.
82    pub build_rows: u64,
83    /// Cap on distinct keys captured in the filter.
84    pub max_keys: usize,
85}
86
87impl DppAdvice {
88    /// True when the join is DPP-eligible.
89    pub fn is_eligible(&self) -> bool {
90        self.build_rows > 0 && self.build_rows <= DPP_MAX_BUILD_ROWS
91    }
92}
93
94/// AQE rule that injects a runtime filter on the probe side of a
95/// star-schema join, sourced from the build side's distinct values.
96pub struct DynamicPartitionPruningRule {
97    /// Maximum build-side row count eligible for DPP.
98    max_build_rows: u64,
99    /// Maximum distinct keys the filter retains.
100    max_keys: usize,
101}
102
103impl DynamicPartitionPruningRule {
104    /// Create a DPP rule with the given build-row cap and key cap.
105    pub fn new(max_build_rows: u64, max_keys: usize) -> Self {
106        Self {
107            max_build_rows,
108            max_keys: max_keys.max(1),
109        }
110    }
111
112    /// Create a DPP rule with the production-default caps.
113    pub fn with_defaults() -> Self {
114        Self::new(DPP_MAX_BUILD_ROWS, DPP_MAX_KEYS)
115    }
116
117    /// Find a join node whose two inputs are both Hash-partitioned on
118    /// the same key (a `Broadcast` join is already cheap; DPP would be
119    /// redundant). Returns `(join_node, build_node, probe_node, key)`.
120    fn find_join_candidate(plan: &PhysicalPlan) -> Option<(PlanNode, PlanNode, PlanNode, String)> {
121        for node in plan.nodes() {
122            let join_type = match node.op() {
123                Some(NodeOp::Join { join_type }) => join_type,
124                _ => continue,
125            };
126            // DPP targets equi-joins.
127            if !matches!(
128                join_type,
129                crate::JoinType::Inner
130                    | crate::JoinType::Left
131                    | crate::JoinType::Right
132                    | crate::JoinType::LeftSemi
133                    | crate::JoinType::RightSemi
134            ) {
135                continue;
136            }
137            let keys = match node.partitioning() {
138                Partitioning::Hash { keys, .. } if keys.len() == 1 => keys.first().cloned()?,
139                _ => continue,
140            };
141            // Two children, both Scan-shaped.
142            if node.inputs().len() != 2 {
143                continue;
144            }
145            let left_id = node.inputs().first()?;
146            let right_id = node.inputs().get(1)?;
147            let left = plan.nodes().iter().find(|n| n.id() == left_id)?;
148            let right = plan.nodes().iter().find(|n| n.id() == right_id)?;
149            let is_scan = |n: &PlanNode| matches!(n.op(), Some(NodeOp::Scan { .. }));
150            if !is_scan(left) && !is_scan(right) {
151                continue;
152            }
153            // Convention: the broadcast / small side is the `build` side.
154            // We don't know sizes statically, so we treat either input as
155            // the candidate build side. The runtime rule will use
156            // observed sizes to pick the small one.
157            return Some((node.clone(), left.clone(), right.clone(), keys));
158        }
159        None
160    }
161}
162
163impl AqeRule for DynamicPartitionPruningRule {
164    fn name(&self) -> &str {
165        "dynamic-partition-pruning"
166    }
167
168    fn apply(&self, plan: &PhysicalPlan, stats: &[RuntimeStats]) -> Option<PhysicalPlan> {
169        if stats.is_empty() || StreamingAqeGuard::plan_is_streaming(plan) {
170            return None;
171        }
172
173        #[allow(clippy::question_mark)]
174        let (join_node, _build_candidate, _probe_candidate, key) =
175            match Self::find_join_candidate(plan) {
176                Some(t) => t,
177                None => return None,
178            };
179        let _ = join_node;
180
181        // Use the observed build-side rows from `stats` to gate the rule.
182        // We don't know which stats entry corresponds to the build side
183        // without extra metadata, so we use the *minimum* of the observed
184        // stages as a conservative estimate of the small side's size.
185        let min_rows = stats.iter().map(|s| s.input_rows).min().unwrap_or(0);
186        if min_rows == 0 || min_rows > self.max_build_rows {
187            return None;
188        }
189
190        let mut rewritten = PhysicalPlan::new(plan.name(), plan.kind());
191        for node in plan.nodes() {
192            if node.id() == join_node.id() {
193                // Stamp the join node with a `Other` annotation so the
194                // operator dispatcher knows to wire a runtime filter
195                // between build and probe. We keep the original
196                // partitioning and op intact; downstream executor code
197                // looks for the DppAdvice fields on the plan's metadata.
198                let label_suffix = format!("DppProbeFilter(key={key})");
199                let new_label = format!("{} ({label_suffix})", node.label());
200                // Preserve the original op if present; otherwise annotate with
201                // a descriptive `Other` op so the executor can identify the DPP
202                // probe filter annotation.
203                let new_op = node.op().cloned().unwrap_or(NodeOp::Other {
204                    description: label_suffix,
205                });
206                let new_node = node.clone().with_label(new_label).with_op(new_op);
207                rewritten.add_node(new_node);
208            } else {
209                rewritten.add_node(node.clone());
210            }
211        }
212
213        tracing::debug!(
214            rule = self.name(),
215            join_key = %key,
216            min_rows,
217            max_keys = self.max_keys,
218            "DynamicPartitionPruningRule applied"
219        );
220
221        Some(rewritten)
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::{
228        AqeRule, DPP_MAX_BUILD_ROWS, DPP_MAX_KEYS, DppAdvice, DynamicPartitionPruningRule,
229    };
230    use crate::optimizer::RuntimeStats;
231    use crate::{
232        ExecutionKind, FieldType, JoinType, NodeOp, Partitioning, PhysicalPlan, PlanNode,
233        PlanSchema, SchemaField,
234    };
235
236    fn scan_node(id: &str, table: &str) -> PlanNode {
237        let schema = PlanSchema::new(vec![SchemaField::new("k", FieldType::Int64)]);
238        PlanNode::new(id, format!("scan {table}"), ExecutionKind::Batch)
239            .with_op(NodeOp::Scan {
240                table: table.to_string(),
241                filters: vec![],
242            })
243            .with_output_schema(schema)
244    }
245
246    fn join_node(id: &str, left: &str, right: &str, key: &str) -> PlanNode {
247        PlanNode::new(id, "HashJoin", ExecutionKind::Batch)
248            .with_inputs([left, right])
249            .with_partitioning(Partitioning::Hash {
250                keys: vec![key.to_string()],
251                buckets: 8,
252            })
253            .with_op(NodeOp::Join {
254                join_type: JoinType::Inner,
255            })
256    }
257
258    fn plan_with_join() -> PhysicalPlan {
259        let mut plan = PhysicalPlan::new("p", ExecutionKind::Batch);
260        plan.add_node(scan_node("fact", "fact"));
261        plan.add_node(scan_node("dim", "dim"));
262        plan.add_node(join_node("hj", "fact", "dim", "k"));
263        plan
264    }
265
266    fn stats_with_rows(rows: &[u64]) -> Vec<RuntimeStats> {
267        rows.iter()
268            .map(|&r| RuntimeStats {
269                input_rows: r,
270                ..Default::default()
271            })
272            .collect()
273    }
274
275    // ── DppAdvice ─────────────────────────────────────────────────────────
276
277    #[test]
278    fn advice_eligibility_uses_build_row_threshold() {
279        let in_range = DppAdvice {
280            join_key: "k".into(),
281            build_node_id: "dim".into(),
282            probe_node_id: "fact".into(),
283            build_rows: 100,
284            max_keys: DPP_MAX_KEYS,
285        };
286        assert!(in_range.is_eligible());
287
288        let too_big = DppAdvice {
289            build_rows: DPP_MAX_BUILD_ROWS + 1,
290            ..in_range.clone()
291        };
292        assert!(!too_big.is_eligible());
293
294        let empty = DppAdvice {
295            build_rows: 0,
296            ..in_range
297        };
298        assert!(!empty.is_eligible());
299    }
300
301    // ── apply() ───────────────────────────────────────────────────────────
302
303    #[test]
304    fn apply_is_noop_when_stats_empty() {
305        let rule = DynamicPartitionPruningRule::with_defaults();
306        let plan = plan_with_join();
307        assert!(rule.apply(&plan, &[]).is_none());
308    }
309
310    #[test]
311    fn apply_is_noop_for_streaming() {
312        let rule = DynamicPartitionPruningRule::with_defaults();
313        let mut plan = PhysicalPlan::new("s", ExecutionKind::Streaming);
314        plan.add_node(
315            PlanNode::new("fact", "scan fact", ExecutionKind::Streaming).with_op(NodeOp::Scan {
316                table: "fact".into(),
317                filters: vec![],
318            }),
319        );
320        plan.add_node(
321            PlanNode::new("dim", "scan dim", ExecutionKind::Streaming).with_op(NodeOp::Scan {
322                table: "dim".into(),
323                filters: vec![],
324            }),
325        );
326        plan.add_node(
327            PlanNode::new("hj", "HashJoin", ExecutionKind::Streaming)
328                .with_inputs(["fact", "dim"])
329                .with_partitioning(Partitioning::Hash {
330                    keys: vec!["k".to_string()],
331                    buckets: 8,
332                })
333                .with_op(NodeOp::Join {
334                    join_type: JoinType::Inner,
335                }),
336        );
337        let stats = stats_with_rows(&[100, 100, 100, 100]);
338        assert!(rule.apply(&plan, &stats).is_none());
339    }
340
341    #[test]
342    fn apply_is_noop_when_build_side_too_big() {
343        let rule = DynamicPartitionPruningRule::with_defaults();
344        let plan = plan_with_join();
345        // min rows = 50_000 > DPP_MAX_BUILD_ROWS → no DPP.
346        let stats = stats_with_rows(&[50_000, 100_000]);
347        assert!(rule.apply(&plan, &stats).is_none());
348    }
349
350    #[test]
351    fn apply_injects_probe_filter_annotation() {
352        let rule = DynamicPartitionPruningRule::with_defaults();
353        let plan = plan_with_join();
354        let stats = stats_with_rows(&[50, 50, 50, 50]);
355        let result = rule
356            .apply(&plan, &stats)
357            .expect("DPP must fire for small build side");
358        let join = result
359            .nodes()
360            .iter()
361            .find(|n| n.id() == "hj")
362            .expect("rewritten join node");
363        assert!(join.label().contains("DppProbeFilter"));
364        assert!(join.label().contains("k"));
365    }
366
367    #[test]
368    fn apply_preserves_partitioning_and_other_nodes() {
369        let rule = DynamicPartitionPruningRule::with_defaults();
370        let plan = plan_with_join();
371        let stats = stats_with_rows(&[50, 50, 50, 50]);
372        let result = rule.apply(&plan, &stats).expect("DPP must fire");
373        // The fact scan and dim scan must still be present and unchanged.
374        assert!(result.nodes().iter().any(|n| n.id() == "fact"));
375        assert!(result.nodes().iter().any(|n| n.id() == "dim"));
376        // The join node's partitioning must be preserved.
377        let join = result.nodes().iter().find(|n| n.id() == "hj").unwrap();
378        assert_eq!(
379            join.partitioning(),
380            &Partitioning::Hash {
381                keys: vec!["k".to_string()],
382                buckets: 8,
383            }
384        );
385    }
386
387    #[test]
388    fn apply_returns_none_when_no_join_present() {
389        let rule = DynamicPartitionPruningRule::with_defaults();
390        let mut plan = PhysicalPlan::new("p", ExecutionKind::Batch);
391        plan.add_node(scan_node("fact", "fact"));
392        plan.add_node(scan_node("dim", "dim"));
393        let stats = stats_with_rows(&[10, 10, 10, 10]);
394        assert!(rule.apply(&plan, &stats).is_none());
395    }
396
397    #[test]
398    fn apply_skips_non_equi_joins() {
399        let rule = DynamicPartitionPruningRule::with_defaults();
400        let mut plan = PhysicalPlan::new("p", ExecutionKind::Batch);
401        plan.add_node(scan_node("fact", "fact"));
402        plan.add_node(scan_node("dim", "dim"));
403        // Cross join — DPP ineligible.
404        plan.add_node(
405            PlanNode::new("hj", "HashJoin", ExecutionKind::Batch)
406                .with_inputs(["fact", "dim"])
407                .with_partitioning(Partitioning::Hash {
408                    keys: vec!["k".to_string()],
409                    buckets: 8,
410                })
411                .with_op(NodeOp::Join {
412                    join_type: JoinType::Cross,
413                }),
414        );
415        let stats = stats_with_rows(&[10, 10, 10, 10]);
416        assert!(rule.apply(&plan, &stats).is_none());
417    }
418
419    #[test]
420    fn rule_name_is_dynamic_partition_pruning() {
421        let rule = DynamicPartitionPruningRule::with_defaults();
422        assert_eq!(rule.name(), "dynamic-partition-pruning");
423    }
424}