krishiv_plan/optimizer.rs
1#![forbid(unsafe_code)]
2
3//! Query optimizer traits and infrastructure for Krishiv.
4//!
5//! This crate defines the rule-based optimizer framework used by both the
6//! logical and physical planning pipelines, as well as the AQE (Adaptive
7//! Query Execution) extension traits that operate on runtime statistics
8//! collected during stage execution.
9
10mod auto_partition;
11mod broadcast;
12mod broadcast_runtime;
13mod coalesce;
14mod constant_folding;
15mod dynamic_partition_pruning;
16mod join_reorder;
17mod predicate_pushdown;
18mod skew_join;
19mod small_file;
20mod stats;
21
22#[cfg(test)]
23mod optimizer_tests;
24
25pub use auto_partition::AutoPartitionRule;
26pub use broadcast::{BroadcastAutoRule, DEFAULT_BROADCAST_THRESHOLD_ROWS};
27pub use broadcast_runtime::{BroadcastRuntimeRule, DEFAULT_MAX_BROADCAST_BYTES};
28pub use coalesce::{CoalesceAdvice, CoalesceRule};
29pub use constant_folding::ConstantFoldingRule;
30pub use dynamic_partition_pruning::{
31 DPP_MAX_BUILD_ROWS, DPP_MAX_KEYS, DppAdvice, DynamicPartitionPruningRule,
32};
33pub use join_reorder::JoinReorderRule;
34pub use predicate_pushdown::PredicatePushdownRule;
35pub use skew_join::{DEFAULT_SALT_FACTOR, DEFAULT_SKEW_THRESHOLD, SkewAdvice, SkewJoinRule};
36pub use small_file::{FileStats, SmallFilePlanner, SplitPlanAdvice};
37pub use stats::{
38 CboCostModel, ColumnCboStats, TableCboStats, TableStatsRegistry, global_table_stats,
39};
40
41use std::panic::{AssertUnwindSafe, catch_unwind};
42
43use crate::{ExecutionKind, LogicalPlan, NodeOp, PhysicalPlan, PlanError};
44
45/// Result type for logical and adaptive optimizer pipelines.
46pub type OptimizerResult<T> = Result<T, OptimizerError>;
47
48/// Errors produced while validating or executing optimizer rules.
49#[non_exhaustive]
50#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
51pub enum OptimizerError {
52 /// The optimizer received a malformed input plan.
53 #[error("invalid {optimizer} optimizer input: {source}")]
54 InvalidInput {
55 optimizer: &'static str,
56 #[source]
57 source: PlanError,
58 },
59 /// A rule returned a malformed output plan.
60 #[error("{optimizer} optimizer rule '{rule}' produced an invalid plan: {source}")]
61 InvalidRuleOutput {
62 optimizer: &'static str,
63 rule: String,
64 #[source]
65 source: PlanError,
66 },
67 /// A rule panicked while processing a plan.
68 #[error("{optimizer} optimizer rule '{rule}' panicked: {message}")]
69 RulePanicked {
70 optimizer: &'static str,
71 rule: String,
72 message: String,
73 },
74}
75
76// ── Cost model ────────────────────────────────────────────────────────────────
77
78/// Estimated cost of executing a plan.
79#[non_exhaustive]
80#[derive(Debug, Clone, PartialEq, Eq, Default)]
81pub struct Cost {
82 /// Estimated CPU time in nanoseconds.
83 pub cpu_nanos: u64,
84 /// Estimated peak memory in bytes.
85 pub memory_bytes: u64,
86 /// Estimated bytes transferred over the network.
87 pub network_bytes: u64,
88}
89
90/// Runtime statistics collected by an executor stage.
91///
92/// These are fed back into AQE rules so the optimizer can re-plan in-flight.
93#[non_exhaustive]
94#[derive(Debug, Clone, PartialEq, Eq, Default)]
95pub struct RuntimeStats {
96 /// Number of input rows processed.
97 pub input_rows: u64,
98 /// Number of output rows produced.
99 pub output_rows: u64,
100 /// Actual CPU time consumed in nanoseconds.
101 pub cpu_nanos: u64,
102 /// Peak memory used in bytes.
103 pub memory_bytes: u64,
104 /// Bytes spilled to disk.
105 pub spill_bytes: u64,
106 /// Actual bytes written to the shuffle store (Arrow IPC / Parquet on disk).
107 ///
108 /// When non-zero, AQE rules prefer this over `memory_bytes` for partition
109 /// sizing, because shuffle output is compressed/serialized and therefore a
110 /// more accurate proxy for network and disk cost. `memory_bytes` is the
111 /// peak in-memory footprint, which can be 2–4× larger than the wire size.
112 /// Zero means the value was not collected (older task builds or non-shuffle
113 /// tasks), and the rule falls back to `memory_bytes`.
114 pub serialized_bytes: u64,
115}
116
117// ── Optimizer traits ──────────────────────────────────────────────────────────
118
119/// Estimates the cost of executing a [`LogicalPlan`].
120pub trait CostModel: Send + Sync {
121 /// Return an estimated [`Cost`] for the given logical plan.
122 fn estimate(&self, plan: &LogicalPlan) -> Cost;
123}
124
125/// Static, row-count-aware cost model for logical plans.
126///
127/// Walks every node in the plan and accumulates cost estimates based on
128/// operator type and the node's `estimated_rows` field. When `estimated_rows`
129/// is `None` a conservative default of 10 000 rows is assumed.
130///
131/// ## Per-node coefficients
132///
133/// | Operator | CPU (ns/row) | Memory (B/row) | Network (B/row) |
134/// |--------------|:------------:|:--------------:|:---------------:|
135/// | Scan | 10 | 64 | 0 |
136/// | Filter | 5 | 0 | 0 |
137/// | Project | 2 | 0 | 0 |
138/// | Aggregate | 50 | 200 | 0 |
139/// | Join | 100 | 100 | 0 |
140/// | Exchange | 20 | 0 | 200 |
141/// | Other/Window | 15 | 64 | 0 |
142///
143/// These figures are deliberately simple and tunable; their absolute values
144/// are less important than their relative ordering (Aggregate > Join > …).
145pub struct StaticCostModel;
146
147impl CostModel for StaticCostModel {
148 fn estimate(&self, plan: &LogicalPlan) -> Cost {
149 const DEFAULT_ROWS: u64 = 10_000;
150 let mut cpu_nanos: u64 = 0;
151 let mut memory_bytes: u64 = 0;
152 let mut network_bytes: u64 = 0;
153
154 for node in plan.nodes() {
155 let rows = node.estimated_rows().unwrap_or(DEFAULT_ROWS);
156 match node.op() {
157 Some(NodeOp::Scan { .. }) => {
158 cpu_nanos = cpu_nanos.saturating_add(rows.saturating_mul(10));
159 memory_bytes = memory_bytes.saturating_add(rows.saturating_mul(64));
160 }
161 Some(NodeOp::Filter { .. }) => {
162 cpu_nanos = cpu_nanos.saturating_add(rows.saturating_mul(5));
163 }
164 Some(NodeOp::Project { .. }) => {
165 cpu_nanos = cpu_nanos.saturating_add(rows.saturating_mul(2));
166 }
167 Some(NodeOp::Aggregate { .. }) => {
168 cpu_nanos = cpu_nanos.saturating_add(rows.saturating_mul(50));
169 memory_bytes = memory_bytes.saturating_add(rows.saturating_mul(200));
170 }
171 Some(NodeOp::Join { .. }) => {
172 cpu_nanos = cpu_nanos.saturating_add(rows.saturating_mul(100));
173 memory_bytes = memory_bytes.saturating_add(rows.saturating_mul(100));
174 }
175 Some(NodeOp::Exchange { .. }) => {
176 cpu_nanos = cpu_nanos.saturating_add(rows.saturating_mul(20));
177 network_bytes = network_bytes.saturating_add(rows.saturating_mul(200));
178 }
179 _ => {
180 cpu_nanos = cpu_nanos.saturating_add(rows.saturating_mul(15));
181 memory_bytes = memory_bytes.saturating_add(rows.saturating_mul(64));
182 }
183 }
184 }
185
186 Cost {
187 cpu_nanos,
188 memory_bytes,
189 network_bytes,
190 }
191 }
192}
193
194/// A rule that transforms a [`LogicalPlan`] into a (possibly better) one.
195///
196/// P2.4: `apply` returns `Option<LogicalPlan>` — `None` means the plan is
197/// unchanged, allowing [`Optimizer`] to skip the clone-and-compare cycle
198/// and to record only rules that actually fired.
199pub trait OptimizerRule: Send + Sync {
200 /// Short, stable rule name used in explain and diagnostics output.
201 fn name(&self) -> &str;
202
203 /// Apply the rule to `plan`.
204 ///
205 /// Return `Some(new_plan)` when the rule rewrites the plan, or `None` when
206 /// the plan is unchanged. Returning `None` is more efficient than returning
207 /// a clone of the original plan unchanged.
208 fn apply(&self, plan: &LogicalPlan) -> Option<LogicalPlan>;
209}
210
211/// An Adaptive Query Execution rule that re-plans based on [`RuntimeStats`].
212///
213/// AQE rules receive the current [`PhysicalPlan`] together with per-stage
214/// runtime statistics and may return a re-optimised physical plan.
215pub trait AqeRule: Send + Sync {
216 /// Short, stable rule name used in explain and diagnostics output.
217 fn name(&self) -> &str;
218
219 /// Apply the AQE rule given collected [`RuntimeStats`] for each stage.
220 ///
221 /// Return `Some(new_plan)` when the rule rewrites the plan, or `None` when
222 /// the plan is unchanged. The rule borrows the plan; clone it internally
223 /// only when a rewrite is needed so non-firing rules pay no clone cost.
224 fn apply(&self, plan: &PhysicalPlan, stats: &[RuntimeStats]) -> Option<PhysicalPlan>;
225}
226
227/// A rule that detects skewed (hot) partitions from [`RuntimeStats`].
228///
229/// Returns the indices of partitions whose row count or resource usage
230/// significantly exceeds the average, signalling that the coordinator should
231/// split or re-balance those partitions.
232pub trait SkewRule: Send + Sync {
233 /// Short, stable rule name used in explain and diagnostics output.
234 fn name(&self) -> &str;
235
236 /// Return the indices of hot partitions detected in `stats`.
237 fn detect_hot_partitions(&self, stats: &[RuntimeStats]) -> Vec<usize>;
238}
239
240// ── Optimizer ─────────────────────────────────────────────────────────────────
241
242/// The result of running the optimizer over a logical plan.
243#[derive(Debug, Clone)]
244pub struct OptimizeResult {
245 /// The (possibly rewritten) logical plan.
246 pub plan: LogicalPlan,
247 /// Names of the rules that fired and changed the plan, in application order.
248 pub applied_rules: Vec<String>,
249}
250
251impl OptimizeResult {
252 /// Return a human-readable summary of which rules fired.
253 pub fn describe(&self) -> String {
254 if self.applied_rules.is_empty() {
255 return "optimizer: no rules applied".to_string();
256 }
257 let rules = self.applied_rules.join(", ");
258 format!("optimizer applied: {rules}")
259 }
260}
261
262/// Rule-based optimizer for Krishiv logical plans.
263///
264/// Rules are applied in the order they were added. Each rule receives the plan
265/// produced by the previous rule. If a rule does not change the plan it should
266/// return the input unchanged; the optimizer detects changes via [`PartialEq`]
267/// and only records a rule in [`OptimizeResult::applied_rules`] when it
268/// actually modifies the plan.
269pub struct Optimizer {
270 rules: Vec<Box<dyn OptimizerRule>>,
271}
272
273impl Optimizer {
274 /// Create an optimizer with no rules.
275 pub fn new() -> Self {
276 Self { rules: Vec::new() }
277 }
278
279 /// Append a rule to the optimizer pipeline.
280 pub fn add_rule(&mut self, rule: Box<dyn OptimizerRule>) {
281 self.rules.push(rule);
282 }
283
284 /// Run all rules in order and return the final plan together with the list
285 /// of rules that produced a visible change.
286 ///
287 /// P2.4: rules signal no-change by returning `None`, avoiding an O(rules ×
288 /// plan_size) clone-per-rule cycle.
289 pub fn optimize(&self, plan: LogicalPlan) -> OptimizerResult<OptimizeResult> {
290 plan.validate()
291 .map_err(|source| OptimizerError::InvalidInput {
292 optimizer: "logical",
293 source,
294 })?;
295 let mut current = plan;
296 let mut applied_rules = Vec::new();
297
298 for rule in &self.rules {
299 let rule_name = rule.name().to_string();
300 let outcome =
301 catch_unwind(AssertUnwindSafe(|| rule.apply(¤t))).map_err(|payload| {
302 OptimizerError::RulePanicked {
303 optimizer: "logical",
304 rule: rule_name.clone(),
305 message: krishiv_common::panic_payload_to_string(&*payload),
306 }
307 })?;
308 if let Some(new_plan) = outcome {
309 if new_plan.name() != current.name() || new_plan.kind() != current.kind() {
310 return Err(OptimizerError::InvalidRuleOutput {
311 optimizer: "logical",
312 rule: rule_name,
313 source: PlanError::Validation(String::from(
314 "logical optimizer rules must preserve plan name and execution kind",
315 )),
316 });
317 }
318 new_plan
319 .validate()
320 .map_err(|source| OptimizerError::InvalidRuleOutput {
321 optimizer: "logical",
322 rule: rule_name.clone(),
323 source,
324 })?;
325 if new_plan != current {
326 applied_rules.push(rule_name);
327 current = new_plan;
328 }
329 }
330 }
331
332 Ok(OptimizeResult {
333 plan: current,
334 applied_rules,
335 })
336 }
337}
338
339impl Default for Optimizer {
340 fn default() -> Self {
341 Self::new()
342 }
343}
344
345// ── ThresholdSkewRule ─────────────────────────────────────────────────────────
346
347/// Detects hot partitions whose `input_rows` exceeds `threshold × median_rows`.
348pub struct ThresholdSkewRule {
349 threshold: f64,
350}
351
352impl ThresholdSkewRule {
353 /// Create a rule that flags partitions exceeding `threshold × median` input rows.
354 ///
355 /// Typical value: 2.0 (flag anything more than 2× the median).
356 pub fn new(threshold: f64) -> Self {
357 Self { threshold }
358 }
359
360 /// P1.16: For even-length arrays, average the two middle values.
361 fn median_rows(stats: &[RuntimeStats]) -> f64 {
362 if stats.is_empty() {
363 return 0.0;
364 }
365 let mut rows: Vec<u64> = stats.iter().map(|s| s.input_rows).collect();
366 rows.sort_unstable();
367 let n = rows.len();
368 let mid = n / 2;
369 if n.is_multiple_of(2) {
370 let a = rows.get(mid.saturating_sub(1)).copied().unwrap_or(0);
371 let b = rows.get(mid).copied().unwrap_or(0);
372 (a as f64 + b as f64) / 2.0
373 } else {
374 rows.get(mid).copied().unwrap_or(0) as f64
375 }
376 }
377}
378
379impl SkewRule for ThresholdSkewRule {
380 fn name(&self) -> &str {
381 "threshold-skew"
382 }
383
384 fn detect_hot_partitions(&self, stats: &[RuntimeStats]) -> Vec<usize> {
385 if stats.is_empty() {
386 return Vec::new();
387 }
388 let median = Self::median_rows(stats);
389 stats
390 .iter()
391 .enumerate()
392 .filter(|(_, s)| s.input_rows as f64 > self.threshold * median)
393 .map(|(i, _)| i)
394 .collect()
395 }
396}
397
398// ── StreamingAqeGuard ─────────────────────────────────────────────────────────
399
400/// Guards streaming plans from AQE rules that would change partition count.
401///
402/// Stateful streaming stages use keyed-distribution routing: the same key must
403/// always map to the same executor task for the entire job lifetime. AQE
404/// coalescing and repartitioning would change the partition count mid-job,
405/// orphaning all in-flight state.
406///
407/// Place this rule first in any AQE pipeline that includes coalescing or
408/// repartitioning rules. When the plan carries `ExecutionKind::Streaming`,
409/// all subsequent AQE rules that affect partitioning must be skipped.
410///
411/// Usage:
412/// ```
413/// use krishiv_plan::optimizer::{AqeOptimizer, CoalesceRule, StreamingAqeGuard};
414/// let mut aqe = AqeOptimizer::new();
415/// aqe.add_guarded_rule(Box::new(CoalesceRule::new(64 * 1024 * 1024)));
416/// ```
417pub struct StreamingAqeGuard;
418
419impl StreamingAqeGuard {
420 /// Returns `true` if the plan contains any streaming node that must not be
421 /// subject to AQE partition-count changes.
422 ///
423 /// P3.18: Walk the plan tree recursively so that hybrid batch/streaming
424 /// plans are also detected. A plan is considered streaming if either its
425 /// top-level `ExecutionKind` is `Streaming` or any of its nodes carries
426 /// `ExecutionKind::Streaming`.
427 pub fn plan_is_streaming(plan: &PhysicalPlan) -> bool {
428 plan.kind() == ExecutionKind::Streaming
429 || plan
430 .nodes()
431 .iter()
432 .any(|node| node.kind() == ExecutionKind::Streaming)
433 }
434}
435
436/// AQE optimizer that automatically skips partition-changing rules for
437/// streaming plans.
438///
439/// Rules added via [`add_guarded_rule`](AqeOptimizer::add_guarded_rule) are
440/// not applied when [`StreamingAqeGuard::plan_is_streaming`] returns `true`.
441/// Rules added via [`add_rule`](AqeOptimizer::add_rule) always run regardless
442/// of execution kind — use this for rules that are safe on streaming plans
443/// (e.g., pure statistics collection).
444pub struct AqeOptimizer {
445 /// Rules that run on all plans, including streaming.
446 always_rules: Vec<Box<dyn AqeRule>>,
447 /// Rules that are skipped for streaming plans.
448 guarded_rules: Vec<Box<dyn AqeRule>>,
449 /// Cost model for cold-start estimation when `RuntimeStats` are absent.
450 ///
451 /// When `stats` passed to `apply` is empty (first execution cycle), the
452 /// optimizer uses this model to estimate memory cost from the logical plan
453 /// and synthesises a single `RuntimeStats` entry so that `AutoPartitionRule`
454 /// can propose an initial partition count rather than defaulting to the plan's
455 /// current value. Defaults to [`StaticCostModel`].
456 cost_model: std::sync::Arc<dyn CostModel>,
457}
458
459impl AqeOptimizer {
460 /// Create an empty AQE optimizer backed by [`StaticCostModel`].
461 pub fn new() -> Self {
462 Self {
463 always_rules: Vec::new(),
464 guarded_rules: Vec::new(),
465 cost_model: std::sync::Arc::new(StaticCostModel),
466 }
467 }
468
469 /// Replace the default cost model with a custom implementation.
470 pub fn with_cost_model(mut self, model: std::sync::Arc<dyn CostModel>) -> Self {
471 self.cost_model = model;
472 self
473 }
474
475 /// Add a rule that always runs, including on streaming plans.
476 pub fn add_rule(&mut self, rule: Box<dyn AqeRule>) {
477 self.always_rules.push(rule);
478 }
479
480 /// Add a rule that is skipped when the plan is a streaming plan.
481 ///
482 /// Use this for coalescing, repartitioning, and any other AQE rule that
483 /// changes partition count or assignment.
484 pub fn add_guarded_rule(&mut self, rule: Box<dyn AqeRule>) {
485 self.guarded_rules.push(rule);
486 }
487
488 /// Apply all applicable rules given per-stage runtime statistics.
489 ///
490 /// When `stats` is empty the cost model is used to synthesise a single
491 /// `RuntimeStats` entry (from the logical plan cost estimate) so that rules
492 /// that need size information can still make a first-pass decision.
493 ///
494 /// Returns the (possibly rewritten) plan and the names of rules that fired.
495 pub fn apply(
496 &self,
497 plan: PhysicalPlan,
498 stats: &[RuntimeStats],
499 ) -> OptimizerResult<(PhysicalPlan, Vec<String>)> {
500 plan.validate()
501 .map_err(|source| OptimizerError::InvalidInput {
502 optimizer: "AQE",
503 source,
504 })?;
505 let input_is_streaming = StreamingAqeGuard::plan_is_streaming(&plan);
506 let mut current = plan;
507 let mut applied = Vec::new();
508
509 // When no runtime stats are available (cold start), synthesise a single
510 // RuntimeStats entry from the cost model estimate so rules that need
511 // size information can propose an initial partition count.
512 //
513 // PhysicalPlan does not carry a back-reference to the original
514 // LogicalPlan, but its PlanNodes expose the same `estimated_rows()`
515 // and `op()` accessors that StaticCostModel uses. We build a
516 // ephemeral LogicalPlan that mirrors the physical nodes so the cost
517 // model can walk them without requiring a separate logical plan to be
518 // threaded through the call stack.
519 let cost_synthesised_stats: Vec<RuntimeStats>;
520 let effective_stats = if stats.is_empty() && !input_is_streaming {
521 let mut lplan = crate::LogicalPlan::new(current.name(), current.kind());
522 for node in current.nodes() {
523 lplan.add_node(node.clone());
524 }
525 let cost = self.cost_model.estimate(&lplan);
526 cost_synthesised_stats = vec![RuntimeStats {
527 memory_bytes: cost.memory_bytes,
528 cpu_nanos: cost.cpu_nanos,
529 ..Default::default()
530 }];
531 &cost_synthesised_stats[..]
532 } else {
533 stats
534 };
535
536 for rule in &self.always_rules {
537 let rule_name = rule.name().to_string();
538 let outcome = catch_unwind(AssertUnwindSafe(|| rule.apply(¤t, effective_stats)))
539 .map_err(|payload| OptimizerError::RulePanicked {
540 optimizer: "AQE",
541 rule: rule_name.clone(),
542 message: krishiv_common::panic_payload_to_string(&*payload),
543 })?;
544 if let Some(new_plan) = outcome {
545 if new_plan.name() != current.name() || new_plan.kind() != current.kind() {
546 return Err(OptimizerError::InvalidRuleOutput {
547 optimizer: "AQE",
548 rule: rule_name,
549 source: PlanError::Validation(String::from(
550 "AQE rules must preserve plan name and execution kind",
551 )),
552 });
553 }
554 new_plan
555 .validate()
556 .map_err(|source| OptimizerError::InvalidRuleOutput {
557 optimizer: "AQE",
558 rule: rule_name.clone(),
559 source,
560 })?;
561 if new_plan != current {
562 applied.push(rule_name);
563 current = new_plan;
564 }
565 }
566 }
567
568 if !input_is_streaming && !StreamingAqeGuard::plan_is_streaming(¤t) {
569 for rule in &self.guarded_rules {
570 let rule_name = rule.name().to_string();
571 let outcome =
572 catch_unwind(AssertUnwindSafe(|| rule.apply(¤t, effective_stats)))
573 .map_err(|payload| OptimizerError::RulePanicked {
574 optimizer: "AQE",
575 rule: rule_name.clone(),
576 message: krishiv_common::panic_payload_to_string(&*payload),
577 })?;
578 if let Some(new_plan) = outcome {
579 if new_plan.name() != current.name() || new_plan.kind() != current.kind() {
580 return Err(OptimizerError::InvalidRuleOutput {
581 optimizer: "AQE",
582 rule: rule_name,
583 source: PlanError::Validation(String::from(
584 "AQE rules must preserve plan name and execution kind",
585 )),
586 });
587 }
588 new_plan
589 .validate()
590 .map_err(|source| OptimizerError::InvalidRuleOutput {
591 optimizer: "AQE",
592 rule: rule_name.clone(),
593 source,
594 })?;
595 if new_plan != current {
596 applied.push(rule_name);
597 current = new_plan;
598 }
599 }
600 }
601 }
602
603 Ok((current, applied))
604 }
605}
606
607impl Default for AqeOptimizer {
608 fn default() -> Self {
609 Self::new()
610 }
611}
612
613pub fn default_logical_optimizer() -> Optimizer {
614 let mut optimizer = Optimizer::new();
615 // 1. Fold constant sub-expressions inside filter predicates (T3).
616 // E.g. `1 = 1 AND col = 1` → `col = 1`, `1 = 0 AND col = 1` → `FALSE`.
617 optimizer.add_rule(Box::new(ConstantFoldingRule));
618 // 2. Push filters into scans so that estimated_rows on scan nodes reflect
619 // the actual filtered size before join ordering kicks in.
620 optimizer.add_rule(Box::new(PredicatePushdownRule));
621 // 3. Mark small scan nodes as broadcast-eligible (uses estimated_rows).
622 optimizer.add_rule(Box::new(BroadcastAutoRule::new(
623 DEFAULT_BROADCAST_THRESHOLD_ROWS,
624 )));
625 // 4. Reorder commutative join inputs so the smaller table is on the left,
626 // minimising intermediate result sizes in left-deep join trees.
627 optimizer.add_rule(Box::new(JoinReorderRule));
628 optimizer
629}
630
631/// Default AQE optimizer with guarded coalescing and the streaming guard.
632///
633/// Includes `BroadcastRuntimeRule`, `AutoPartitionRule`, `CoalesceRule`,
634/// and `SkewJoinRule` as guarded rules (skipped for streaming plans).
635/// Rules that require runtime statistics will be no-ops until stats feed
636/// is wired (see `AqeOptimizer::apply`).
637pub fn default_aqe_optimizer() -> AqeOptimizer {
638 default_aqe_optimizer_with_parallelism(1)
639}
640
641/// [`default_aqe_optimizer`] whose coalescing will not drop a stage below
642/// `min_partitions` partitions.
643///
644/// Pass the cluster's live schedulable slot count. Coalescing sized purely by
645/// bytes will happily reduce a stage to one partition — and therefore one task
646/// on one core — whenever its whole output fits in one target-sized partition,
647/// no matter how much CPU the rows cost to produce. TPC-H q2 at SF100 hit this
648/// on four of its eleven stages and took 24 minutes on a nine-slot cluster
649/// that was one-ninth busy. See [`CoalesceRule::with_min_partitions`].
650pub fn default_aqe_optimizer_with_parallelism(min_partitions: usize) -> AqeOptimizer {
651 let mut optimizer = AqeOptimizer::new();
652 // 1. Promote/demote broadcast joins from observed sizes before bucket
653 // counts are re-derived, so AutoPartitionRule sees the final exchange
654 // shape (a promoted Broadcast node is no longer a Hash/RoundRobin
655 // candidate for bucket stamping).
656 optimizer.add_guarded_rule(Box::new(BroadcastRuntimeRule::new(
657 DEFAULT_MAX_BROADCAST_BYTES,
658 )));
659 optimizer.add_guarded_rule(Box::new(AutoPartitionRule::new(64)));
660 optimizer.add_guarded_rule(Box::new(
661 CoalesceRule::new(64 * 1024 * 1024).with_min_partitions(min_partitions),
662 ));
663 // Skew-join salting runs last so it sees the final partition shape.
664 optimizer.add_guarded_rule(Box::new(SkewJoinRule::with_default_factor(
665 DEFAULT_SKEW_THRESHOLD,
666 )));
667 optimizer
668}
669
670/// [`default_aqe_optimizer`] backed by the process-global table statistics
671/// registry (Phase 54): cold-start estimates use [`CboCostModel`] over
672/// stats collected by `ANALYZE TABLE` / Iceberg auto-stats instead of the
673/// static per-operator coefficients.
674pub fn default_aqe_optimizer_with_stats() -> AqeOptimizer {
675 default_aqe_optimizer_with_stats_and_parallelism(1)
676}
677
678/// [`default_aqe_optimizer_with_stats`] with a coalescing parallelism floor.
679///
680/// See [`default_aqe_optimizer_with_parallelism`].
681pub fn default_aqe_optimizer_with_stats_and_parallelism(min_partitions: usize) -> AqeOptimizer {
682 default_aqe_optimizer_with_parallelism(min_partitions).with_cost_model(std::sync::Arc::new(
683 CboCostModel {
684 registry: stats::global_table_stats().clone(),
685 },
686 ))
687}