Skip to main content

rudb_native/
zones.rs

1//! Showing the planner what a native table already wrote down about itself.
2//!
3//! Every stripe carries the two ends and the null count of every column, in the directory, in
4//! memory from the moment the file is opened. The scan has been reading them since the format
5//! existed and the planner has never seen them, so a query over a native table was ordered from the
6//! same constants a query over a table nobody had measured would get.
7//!
8//! Two things come out of that directory here. [`Stripes`] answers how many rows a set of tests
9//! keeps, which is what a filter's estimate rests on. [`distincts`] answers how many values a column
10//! holds, which is what a join's estimate rests on. They are in one module because they are one
11//! idea, and because TPC-H q05 needs both of them and is the reason either exists.
12//!
13//! [`Common`] came later and is not out of the directory. It answers how many rows hold one
14//! particular value, off the frequency synopsis the writer takes per column, and it belongs here
15//! because it is the same idea pointed at the same reader: a number the file already holds that the
16//! planner was assuming its way past.
17//!
18//! # What q05 actually needed
19//!
20//! The filter is the easy half. The `o_orderdate` range over SF1 keeps 227,597 rows of 1,500,000.
21//! Through Parquet the footer gives 227,556 and through the native file the estimate was 60,000,
22//! which is the constant for a range nobody could read. [`Stripes`] closes that: the same query now
23//! estimates 227,556 from the stripe bounds, off the true answer by forty one rows in two hundred
24//! thousand.
25//!
26//! Closing it changed nothing. q05 measured 5,217 ms with the filter estimate fixed against 4,491
27//! before, which is the same plan and a loaded laptop. The join order was never reading the filter.
28//! It was reading the distinct counts, and the containment assumption in `estimate::matched` only
29//! gives way to `left * right / keys` where both key columns have one. DuckDB writes distinct counts
30//! into a Parquet footer, so the Parquet plan divides the customer against supplier join by the 25
31//! nations and scores it at sixty million, which is enough for the search to put customer against
32//! orders first instead. The native file stated no count for an integer column, the divisor fell
33//! back to the table's own row count, and the same join scored 150,000. So the search took it first
34//! and built the twelve million row intermediate that is the whole of q05's time.
35//!
36//! # Why the stripe and not the part
37//!
38//! A stripe's bounds are in the directory and a part's are a page in the file. The planner is
39//! deciding what to read and reading a page per column per stripe to decide it would be the scan
40//! run twice, so this answers from the stripe alone and never touches the file. The loss is smaller
41//! than it sounds: the interpolation below is what the estimate mostly rests on, and interpolating
42//! inside sixteen stripes and inside nine hundred parts of the same column give nearly the same
43//! fraction when the rows are in no particular order, which is the case this exists for. A part
44//! bound is worth reading when the question is which parts to skip, and that question is the scan's
45//! and is already answered by [`Reader::skips`].
46
47use std::cmp::Ordering;
48use std::sync::Arc;
49
50use rudb_common::Result;
51use rudb_common::bounds::{Bound, End, Frequencies, Remainder, Spread, Test, Zones, kept};
52use rudb_common::stat::{Direction, Provenance};
53use rudb_common::{ColumnFacts, Stat, Value};
54use rudb_storage::Probe;
55
56use crate::Reader;
57
58/// The bounds of a committed native table, as the planner asks for them.
59///
60/// Holds the reader rather than a copy of the bounds. A reader is a handful of reference counts and
61/// cloning one shares the caches it has already filled, where copying the bounds out would be every
62/// stripe of every column of the table per statement bound.
63#[derive(Debug, Clone)]
64pub struct Stripes {
65    reader: Reader,
66}
67
68impl Stripes {
69    /// The bounds of a table somebody has open.
70    #[must_use]
71    pub fn new(reader: Reader) -> Self {
72        Self { reader }
73    }
74}
75
76impl Zones for Stripes {
77    fn column(&self, name: &str) -> Option<usize> {
78        self.reader.table().fields().iter().position(|field| field.name == name)
79    }
80
81    fn surviving(&self, tests: &[Test]) -> Option<u64> {
82        let probes = probes(tests);
83        let mut total: u64 = 0;
84        for (at, stripe) in self.reader.table().stripes().iter().enumerate() {
85            if self.reader.stripe_skips(at, &probes) {
86                continue;
87            }
88            total = total.checked_add(u64::try_from(stripe.rows()).ok()?)?;
89        }
90        Some(total)
91    }
92
93    fn spread(&self, tests: &[Test]) -> Option<Spread> {
94        let mut passing = 0.0_f64;
95        let mut whole = 0.0_f64;
96        let mut read = 0;
97        for stripe in self.reader.table().stripes() {
98            let rows = rows(stripe.rows());
99            let spread = fraction(tests, stripe.zone());
100            whole += rows;
101            passing += rows * spread.fraction;
102            // The most any one stripe could read rather than a total of them, the same as the
103            // Parquet footer does it and for the same reason: the caller is charging its constant
104            // for the tests nobody answered, so the question is whether anybody answered this one.
105            read = read.max(spread.read);
106        }
107        (read > 0 && whole > 0.0)
108            .then(|| Spread { fraction: (passing / whole).clamp(0.0, 1.0), read })
109    }
110
111    fn nulls(&self, column: usize) -> Stat<u64> {
112        // Every stripe of a native file states its null count, so this is exact or the column is
113        // not there. A reader that cannot answer its own directory fails the scan a moment later
114        // with the same error, and the planner is not the place to raise it.
115        self.reader
116            .null_count(column)
117            .map_or(Stat::Unknown, |nulls| Stat::exact(nulls, Provenance::NullCount))
118    }
119
120    fn extreme(&self, column: usize, end: End) -> Stat<Bound> {
121        // The reader folds the stripes itself and answers only where every one of them wrote a
122        // bound its writer called exact, which is the same promise this has to make. A column whose
123        // ends were widened, or whose stripes do not compare against each other, comes back `None`
124        // there and unknown here.
125        match self.reader.exact_extremes(column) {
126            Ok(Some((low, high))) => {
127                Stat::exact(if end == End::Low { low } else { high }, Provenance::ZoneMap)
128            }
129            _ => Stat::Unknown,
130        }
131    }
132}
133
134/// How many distinct values each column of a native table holds, for the columns it can say.
135///
136/// Two sources, and a column with neither is left out rather than guessed at. An absent column
137/// reads back as unknown and the estimator falls back to the table's row count, which is what every
138/// column did before this existed.
139///
140/// A string column has a global dictionary and the directory records how many codes any row of it
141/// actually holds, so that count is exact and comes back as such. The dictionary page is not opened
142/// to answer, which matters: this runs once per table per statement bound.
143///
144/// Every other column is answered from its two ends, where they are integers. A column of integers
145/// between `low` and `high` cannot hold more than `high - low + 1` distinct values, so the span is a
146/// ceiling, and on the columns that decide a join order it is a tight one. TPC-H nationkey runs 0 to
147/// 24 and holds 25 values, regionkey 0 to 4 and holds 5. On `l_orderkey` the span is six million
148/// against a true one and a half, which is loose and still safe, for the reason below.
149///
150/// # Why a ceiling is the safe end here
151///
152/// The two readers of a distinct count both divide by it. A divisor that is too large makes the
153/// join look smaller, and `estimate::matched` takes the larger of that and the containment
154/// assumption, so too large a span can only fail to raise an estimate and can never lower one below
155/// what shape alone already said. Too small a divisor is the dangerous direction and a span cannot
156/// be too small: a widened bound is wider than the truth, never narrower, so the span it implies is
157/// a ceiling however the bound was written.
158///
159/// A span at or above the table's row count is dropped rather than recorded. The row count is what
160/// the estimator already falls back to for a column nobody counted, so recording it would be an
161/// entry that says what its own absence says.
162///
163/// # Errors
164///
165/// Never, today. The two reads it makes are indexed by a column this loop produced, so neither can
166/// be out of range, and the signature carries the `Result` because both of them do.
167pub fn distincts(reader: &Reader) -> Result<Vec<(String, Stat<u64>)>> {
168    let table = reader.table();
169    let rows = u64::try_from(table.rows()).unwrap_or(u64::MAX);
170    let mut counted = Vec::new();
171    for (at, field) in table.fields().iter().enumerate() {
172        if let Some(exact) = reader.distinct_values(at)? {
173            counted.push((field.name.clone(), Stat::exact(exact, Provenance::Dictionary)));
174            continue;
175        }
176        let Some((Bound::Int(low), Bound::Int(high))) = reader.exact_extremes(at)? else {
177            continue;
178        };
179        let Some(span) = high.checked_sub(low).and_then(|span| u64::try_from(span).ok()) else {
180            continue;
181        };
182        let Some(span) = span.checked_add(1).filter(|&span| span < rows) else {
183            continue;
184        };
185        // The weakest certificate there is: certain from above with the relative error unbounded,
186        // which is the class a ceiling with nothing under it takes everywhere else in the tree.
187        counted.push((
188            field.name.clone(),
189            Stat::certified(span, 1.0, Direction::AtMost, Provenance::ZoneMap),
190        ));
191    }
192    Ok(counted)
193}
194
195/// Everything [`distincts`], [`ascending`] and [`widths`] say, gathered once per open table.
196///
197/// The file does not change under a reader, so the answers do not either, and every plan that reads
198/// the table asks. A directory that cannot answer its distinct counts leaves them out, the way the
199/// catalog always has: the scan fails a moment later with the same error, where it can be raised.
200#[must_use]
201pub fn facts(reader: &Reader) -> Arc<ColumnFacts> {
202    Arc::clone(reader.facts.get_or_init(|| {
203        Arc::new(ColumnFacts {
204            distincts: distincts(reader).unwrap_or_default().into_iter().collect(),
205            ascending: ascending(reader).into_iter().collect(),
206            widths: widths(reader).into_iter().collect(),
207        })
208    }))
209}
210
211/// The columns whose values never go down in row order and hold no null, by name.
212///
213/// This is what lets an aggregate grouped on one of them close a group as soon as the key changes,
214/// and the claim it rests on is the summary's, which read every value in rid order when the table
215/// was written. A summary that is stale, missing or one this build cannot decode leaves its column
216/// out, so the answer can only be too short and a column left out is grouped the way it always was.
217/// A null anywhere is also out, since the summary's order only speaks for the values that are there
218/// and a null that sat between two of them would split a run the grouping thinks is whole.
219#[must_use]
220pub fn ascending(reader: &Reader) -> Vec<String> {
221    reader
222        .table()
223        .fields()
224        .iter()
225        .enumerate()
226        .filter(|&(at, _)| {
227            crate::stats::held_summary(reader, at).is_some_and(|summary| {
228                summary.nulls == 0 && summary.order == rudb_stats::summary::Order::Ascending
229            })
230        })
231        .map(|(_, field)| field.name.clone())
232        .collect()
233}
234
235/// How many bytes a value of each string column takes on average, by name.
236///
237/// Read off the column's summary, which counts the bytes of every value that is not null, so this is
238/// exact as an average and says nothing about how the lengths spread. A column with no summary, or
239/// with nothing in it but nulls, is left out, and a column left out is priced as the header a string
240/// always has.
241#[must_use]
242pub fn widths(reader: &Reader) -> Vec<(String, u64)> {
243    reader
244        .table()
245        .fields()
246        .iter()
247        .enumerate()
248        .filter(|(_, field)| field.ty.physical() == rudb_common::PhysicalType::Varlen)
249        .filter_map(|(at, field)| {
250            let summary = crate::stats::held_summary(reader, at)?;
251            let values = summary.rows.checked_sub(summary.nulls).filter(|&values| values > 0)?;
252            Some((field.name.clone(), summary.bytes.div_ceil(values)))
253        })
254        .collect()
255}
256
257/// What a native table's frequency synopsis says about one value, as the planner asks for it.
258///
259/// Holds the reader for the reason [`Stripes`] does. The synopsis is small where it exists at all,
260/// but it exists per column and copying every column's into every plan would be paying for the
261/// columns nothing filters on, which is most of them.
262#[derive(Debug, Clone)]
263pub struct Common {
264    reader: Reader,
265}
266
267impl Common {
268    /// The frequencies of a table somebody has open.
269    #[must_use]
270    pub fn new(reader: Reader) -> Self {
271        Self { reader }
272    }
273}
274
275impl Frequencies for Common {
276    fn column(&self, name: &str) -> Option<usize> {
277        self.reader.table().fields().iter().position(|field| field.name == name)
278    }
279
280    fn rows(&self) -> u64 {
281        u64::try_from(self.reader.table().rows()).unwrap_or(u64::MAX)
282    }
283
284    fn rows_with(&self, column: usize, value: &Bound) -> Stat<u64> {
285        // The prefix and not only the complete list, because the counts in it are exact either way.
286        // The writer recounts the candidates that survive its pass, so what an incomplete synopsis
287        // lost is values rather than counts, and a value it kept is one of the leading values of the
288        // column, which is the one an equality would otherwise guess worst about.
289        let Ok(Some((entries, omitted_max))) = self.reader.held_prefix(column) else {
290            return Stat::Unknown;
291        };
292        let mut comparable = false;
293        for (held, count) in entries.iter() {
294            // A null entry is the column's nulls, and no equality matches a null. Skipping it is
295            // both the right answer and the only one available, since a null has no bound.
296            let Some(bound) = Bound::of_value(held) else {
297                continue;
298            };
299            match bound.order(value) {
300                Some(Ordering::Equal) => return Stat::exact(*count, Provenance::FrequencySynopsis),
301                Some(_) => comparable = true,
302                None => {}
303            }
304        }
305        // Nothing in the list was the value. That is a count of zero when the list left nothing out
306        // and the constant was in the same domain, because a complete synopsis accounts for every
307        // row. Where the list left something out, the value is somewhere between no rows and the
308        // bound the writer recorded, and a prefix has nothing to say about which. Where not one
309        // entry would even compare, the constant is of another type and the zero would be an
310        // artefact of that rather than a fact about the rows.
311        if omitted_max == 0 && comparable {
312            Stat::exact(0, Provenance::FrequencySynopsis)
313        } else {
314            Stat::Unknown
315        }
316    }
317
318    fn remainder(&self, column: usize) -> Option<Remainder> {
319        let Ok(Some((entries, omitted_max))) = self.reader.held_prefix(column) else {
320            return None;
321        };
322        // A complete list has nothing outside it, and saying so as a remainder of no rows over no
323        // values would hand the caller a division it has to special case. `rows_with` answers that
324        // column outright.
325        if omitted_max == 0 {
326            return None;
327        }
328        let mut held: u64 = 0;
329        let mut listed: u64 = 0;
330        for (value, count) in entries.iter() {
331            held = held.saturating_add(*count);
332            // The null entry's rows come out of the pool and the null itself is not one of the
333            // values, because the counts this is subtracted from do not count it as one. A null in
334            // the list is also the common case rather than an edge: a column with nulls in it usually
335            // has more of them than of anything else.
336            if !matches!(value, Value::Null) {
337                listed += 1;
338            }
339        }
340        // Saturating because two reads of one table disagreeing about its rows is not a reason to
341        // report a tail larger than the column.
342        let rows = Frequencies::rows(self).saturating_sub(held);
343        Some(Remainder { rows, listed, most: omitted_max })
344    }
345}
346
347/// The tests as the storage layer spells them, which is the same three fields under another name.
348fn probes(tests: &[Test]) -> Vec<Probe> {
349    tests
350        .iter()
351        .map(|test| Probe { column: test.column, op: test.op, value: test.value.clone() })
352        .collect()
353}
354
355/// A stripe's row count as a weight, and zero for one that does not read as a count.
356#[expect(clippy::cast_precision_loss, reason = "a row count is a weight here and not an identity")]
357fn rows(count: usize) -> f64 {
358    count as f64
359}
360
361/// The fraction of one stripe these tests are expected to keep, and how many of them said so.
362///
363/// Tests on one column are intersected by [`kept`] and tests on different columns are multiplied
364/// here, which assumes the columns are independent of each other. That is the assumption the
365/// estimator makes everywhere else and the one that fails first, and a pair of bounds cannot do
366/// anything about it either way.
367///
368/// A stripe this cannot read keeps a fraction of one rather than dropping out of the total. Leaving
369/// it out would report the fraction of the stripes that were read as the fraction of the table.
370fn fraction(tests: &[Test], zone: &rudb_storage::Zone) -> Spread {
371    let mut spread = Spread { fraction: 1.0, read: 0 };
372    for (position, test) in tests.iter().enumerate() {
373        // Once per column rather than once per test, because `kept` is handed every test on the
374        // column and answers for all of them at once. The first mention of a column is the one that
375        // asks and the rest are already in that answer.
376        if tests[..position].iter().any(|earlier| earlier.column == test.column) {
377            continue;
378        }
379        let Some(range) = zone.column(test.column) else { continue };
380        let (Some(low), Some(high)) = (range.low.as_ref(), range.high.as_ref()) else { continue };
381        let Some(kept) = kept(tests, test.column, low, high) else { continue };
382        spread.fraction *= kept.fraction;
383        spread.read += kept.read;
384    }
385    spread
386}