Skip to main content

rudb_opt/
pass.rs

1//! What a rewrite is, and what it is given besides the plan.
2//!
3//! `spec/09-optimizer.md` section 9.1 asks for a fixed sequence of passes, each one toggleable by
4//! name. The sequence is [`crate::PASSES`] and the toggle is [`Context`]. Both of those need more
5//! than one pass to mean anything, which is why neither of them existed while column pruning was
6//! the only rewrite: a trait with one implementor is a description of that implementor and a
7//! pipeline of one is a function call.
8//!
9//! The names are DuckDB's, because `SET disabled_optimizers = 'filter_pushdown'` appears in corpus
10//! files that were written against DuckDB and a corpus file that turns a pass off has to turn off
11//! the pass it meant. `SELECT name FROM duckdb_optimizers()` on the pinned binary lists forty four
12//! of them and rudb has two, so most of that list is a name rudb does not answer to yet rather
13//! than a name it disagrees about.
14
15use std::collections::VecDeque;
16
17use rudb_common::{Error, Result};
18use rudb_plan::{NodeRef, Plan};
19
20/// One rewrite from a plan to a plan.
21///
22/// Every pass preserves the plan invariant and the width of the root, which [`crate::optimize`]
23/// checks once at the end rather than each pass checking itself.
24///
25/// A pass is a unit struct rather than a closure because it has a name, and the name is what the
26/// toggle, the per pass corpus sweep and the bisector all address it by. `spec/engine/11-optimizer.md`
27/// section 11.9 makes the bisector a binary search over the set of names, which needs the set to be
28/// a value rather than a position in a list.
29pub trait Pass {
30    /// What this pass is called, in DuckDB's spelling.
31    fn name(&self) -> &'static str;
32
33    /// Rewrites the plan in place.
34    ///
35    /// # Errors
36    ///
37    /// Anything the pass cannot carry on past. A pass that merely cannot improve a plan leaves it
38    /// alone and reports success, because "there was nothing to do" and "this query is broken" are
39    /// not the same answer.
40    fn run(&self, plan: &mut Plan, context: &Context) -> Result<()>;
41}
42
43/// What the passes are given besides the plan.
44///
45/// The settings and the statistics. The catalog and the planning deadline are the other two things
46/// `spec/engine/11-optimizer.md` puts in here, and each arrives with the first pass that reads it:
47/// the deadline with join ordering, which is the only search in the plan and so the only thing
48/// that can spend real time. A field that no pass reads is a field whose meaning nobody has had to
49/// decide yet, and deciding it early is how it ends up wrong.
50///
51/// The statistics are a copy of the row counts rather than a handle on the catalog, which keeps a
52/// lifetime out of this type and out of everything that builds one. What it costs is that a
53/// context built before a table grows estimates against the size the table was, and a context is
54/// built per statement, so the window is one statement wide.
55#[derive(Debug, Clone, Default)]
56pub struct Context {
57    disabled: Vec<&'static str>,
58    statistics: crate::estimate::Statistics,
59}
60
61impl Context {
62    /// Every pass enabled, which is what a query gets unless it says otherwise.
63    #[must_use]
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Turns off the passes named in DuckDB's comma separated spelling.
69    ///
70    /// Empty entries are skipped, so a trailing comma is not an error, which is what the binary
71    /// does with one.
72    ///
73    /// # Errors
74    ///
75    /// For a name that is not a pass, with the sentence the binary prints for one. rudb lists
76    /// every name it has rather than the closest one by edit distance, which is the same
77    /// divergence it already has on every other complaint about a name it does not know.
78    pub fn without(names: &str) -> Result<Self> {
79        let mut context = Self::new();
80        for name in names.split(',') {
81            let name = name.trim();
82            if !name.is_empty() {
83                context.disable(name)?;
84            }
85        }
86        Ok(context)
87    }
88
89    /// Turns off one pass by name.
90    ///
91    /// # Errors
92    ///
93    /// For a name that is not a pass.
94    pub fn disable(&mut self, name: &str) -> Result<()> {
95        let Some(found) = crate::PASSES.iter().find(|pass| pass.name() == name) else {
96            let known: Vec<String> =
97                crate::PASSES.iter().map(|pass| format!("\"{}\"", pass.name())).collect();
98            return Err(Error::parser(format!(
99                "Optimizer type \"{name}\" not recognized\n\nCandidate optimizers: {}",
100                known.join(", ")
101            )));
102        };
103        if !self.is_disabled(name) {
104            self.disabled.push(found.name());
105        }
106        Ok(())
107    }
108
109    /// Whether the pass by that name has been turned off.
110    #[must_use]
111    pub fn is_disabled(&self, name: &str) -> bool {
112        self.disabled.contains(&name)
113    }
114
115    /// Hands the optimizer what is known about how large the tables are.
116    ///
117    /// Whoever builds the context does this, because the catalog lives a layer above the optimizer
118    /// and is not going to be reached from inside it. A context nobody told is a context that
119    /// estimates nothing, which is the right answer for the optimizer's own tests and for a plan
120    /// that arrived as text.
121    pub fn measure(&mut self, statistics: crate::estimate::Statistics) {
122        self.statistics = statistics;
123    }
124
125    /// What is known about how large the tables are.
126    #[must_use]
127    pub fn statistics(&self) -> &crate::estimate::Statistics {
128        &self.statistics
129    }
130}
131
132/// Every node the root reaches, parents before children.
133///
134/// Not every node in the arena. A rewrite that replaced a node leaves the old one behind, and a
135/// pass that walked the arena would go on rewriting nodes that nothing runs, which costs time on
136/// every later pass and can report an error about a plan nobody asked about.
137pub(crate) fn top_down(plan: &Plan) -> Vec<NodeRef> {
138    let mut found = Vec::new();
139    let mut pending = VecDeque::from([plan.root()]);
140    while let Some(node) = pending.pop_front() {
141        if found.contains(&node) {
142            continue;
143        }
144        found.push(node);
145        pending.extend(plan.node(node).children().into_iter().flatten());
146    }
147    found
148}
149
150#[cfg(test)]
151mod tests {
152    use super::Context;
153
154    #[test]
155    fn every_pass_is_on_unless_it_is_named() {
156        let context = Context::new();
157        assert!(!context.is_disabled("expression_rewriter"));
158        let context = Context::without("expression_rewriter").expect("a name that is a pass");
159        assert!(context.is_disabled("expression_rewriter"));
160        assert!(!context.is_disabled("unused_columns"));
161    }
162
163    #[test]
164    fn a_list_turns_off_each_of_them_and_a_trailing_comma_is_not_an_error() {
165        let context = Context::without("expression_rewriter, unused_columns,")
166            .expect("two names and a comma");
167        assert!(context.is_disabled("expression_rewriter"));
168        assert!(context.is_disabled("unused_columns"));
169    }
170
171    #[test]
172    fn naming_the_same_pass_twice_is_naming_it_once() {
173        let context = Context::without("unused_columns,unused_columns").expect("the same name");
174        assert!(context.is_disabled("unused_columns"));
175    }
176
177    #[test]
178    fn a_name_that_is_not_a_pass_is_the_error_duckdb_prints() {
179        let error = Context::without("bogus").expect_err("not a pass");
180        assert_eq!(error.code().duckdb_name(), "Parser Error");
181        assert!(
182            error.message().starts_with("Optimizer type \"bogus\" not recognized"),
183            "{}",
184            error.message()
185        );
186        assert!(error.message().contains("Candidate optimizers:"), "{}", error.message());
187    }
188}