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}