Skip to main content

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(&current))).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(&current, 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(&current) {
569            for rule in &self.guarded_rules {
570                let rule_name = rule.name().to_string();
571                let outcome =
572                    catch_unwind(AssertUnwindSafe(|| rule.apply(&current, 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}