1use crate::{NodeOp, Partitioning, PhysicalPlan, PlanNode};
50
51use super::{AqeRule, RuntimeStats, StreamingAqeGuard};
52
53pub const DPP_MAX_BUILD_ROWS: u64 = 1_000;
60
61pub const DPP_MAX_KEYS: usize = 8_192;
68
69#[non_exhaustive]
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct DppAdvice {
75 pub join_key: String,
77 pub build_node_id: String,
79 pub probe_node_id: String,
81 pub build_rows: u64,
83 pub max_keys: usize,
85}
86
87impl DppAdvice {
88 pub fn is_eligible(&self) -> bool {
90 self.build_rows > 0 && self.build_rows <= DPP_MAX_BUILD_ROWS
91 }
92}
93
94pub struct DynamicPartitionPruningRule {
97 max_build_rows: u64,
99 max_keys: usize,
101}
102
103impl DynamicPartitionPruningRule {
104 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 pub fn with_defaults() -> Self {
114 Self::new(DPP_MAX_BUILD_ROWS, DPP_MAX_KEYS)
115 }
116
117 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 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 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 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 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 let label_suffix = format!("DppProbeFilter(key={key})");
199 let new_label = format!("{} ({label_suffix})", node.label());
200 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 #[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 #[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 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 assert!(result.nodes().iter().any(|n| n.id() == "fact"));
375 assert!(result.nodes().iter().any(|n| n.id() == "dim"));
376 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 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}