Skip to main content

hawdb_optimizer/
relational_join_cost.rs

1// Copyright 2026 Nowledge
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Canonical cost composition for relational join enumeration.
16
17use crate::cardinality_defaults::JOIN_SELECTIVITY_DIVISOR;
18use crate::cost::PlanCostBreakdown;
19use crate::{RelationalAccessPathDescriptor, RelationalAccessPathKind};
20
21const FULL_SCAN_SETUP_CPU: u64 = 4;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum RelationalJoinCardinality {
25    Inner,
26    PreserveLeft,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum RelationalJoinRightInput {
31    /// The right access runs once per outer row, so its component costs scale
32    /// with the outer cardinality instead of being charged as a subtree.
33    Probe,
34    /// The right subtree is produced once, then the join charges row-pair work.
35    Materialized,
36    /// Build a hash table once, then probe it with the left input.
37    Hash,
38    /// Merge two compatibly ordered inputs without sorting them.
39    Merge,
40}
41
42#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
43pub enum RelationalJoinSelectivity {
44    /// No trustworthy predicate statistics are available. Materialized joins
45    /// use the documented 10% fallback; probe inputs ignore this value because
46    /// their row estimate is already a per-outer-row fanout.
47    #[default]
48    Unknown,
49    /// Distinct-value counts for the complete equality key on either side.
50    /// One known side is still a useful conservative denominator; when both
51    /// sides are known, the standard `1 / max(left_ndv, right_ndv)` estimate is
52    /// used.
53    EquiJoin {
54        left_distinct_values: Option<u64>,
55        right_distinct_values: Option<u64>,
56    },
57}
58
59impl RelationalJoinSelectivity {
60    pub const fn equi_join(
61        left_distinct_values: Option<u64>,
62        right_distinct_values: Option<u64>,
63    ) -> Self {
64        Self::EquiJoin {
65            left_distinct_values,
66            right_distinct_values,
67        }
68    }
69
70    fn materialized_divisor(self, left_rows: u64, right_rows: u64) -> u64 {
71        let cap = |distinct_values: u64, rows: u64| distinct_values.max(1).min(rows.max(1));
72        match self {
73            Self::Unknown => JOIN_SELECTIVITY_DIVISOR,
74            Self::EquiJoin {
75                left_distinct_values,
76                right_distinct_values,
77            } => left_distinct_values
78                .map(|distinct_values| cap(distinct_values, left_rows))
79                .into_iter()
80                .chain(
81                    right_distinct_values.map(|distinct_values| cap(distinct_values, right_rows)),
82                )
83                .max()
84                .unwrap_or(JOIN_SELECTIVITY_DIVISOR),
85        }
86    }
87}
88
89pub fn estimate_relational_access_cost(estimated_rows: usize) -> PlanCostBreakdown {
90    let rows = u64::try_from(estimated_rows).unwrap_or(u64::MAX).max(1);
91    PlanCostBreakdown::new(rows, rows, 0, 0, 0)
92}
93
94/// Estimates logical work for a validated access descriptor.
95///
96/// Cardinality counts visited candidate rows, before residual filtering. A full
97/// scan visits sequential rows; primary-key access directly locates rows. An
98/// index navigates once, visits entries, and fetches each non-covering row.
99/// Ordering changes delivered properties, not the number of visited entries.
100/// These units do not predict file I/O: residency, row width, page clustering
101/// and caches require separate runtime evidence. The rows-only helper retains
102/// its original CPU-only contract for callers without an access descriptor.
103pub fn estimate_relational_access_path_cost(
104    access: &RelationalAccessPathDescriptor,
105) -> PlanCostBreakdown {
106    let rows = u64::try_from(access.estimated_rows)
107        .unwrap_or(u64::MAX)
108        .max(1);
109    match access.kind {
110        RelationalAccessPathKind::FullScan => {
111            // Iterator setup is paid once, including for a one-row table.
112            PlanCostBreakdown::new(
113                rows,
114                rows.saturating_add(FULL_SCAN_SETUP_CPU),
115                0,
116                rows,
117                rows,
118            )
119        }
120        RelationalAccessPathKind::PrimaryKey => PlanCostBreakdown::new(rows, rows, rows, 0, rows),
121        RelationalAccessPathKind::Index => {
122            let fetches = if access.requires_row_fetch { rows } else { 0 };
123            PlanCostBreakdown::new(
124                rows,
125                rows.saturating_add(fetches),
126                1_u64.saturating_add(fetches),
127                if access.unique_point { 0 } else { rows },
128                rows,
129            )
130        }
131    }
132}
133
134/// Extends a left-deep plan with one probe join using the canonical
135/// relational cost model.
136pub fn estimate_relational_probe_join_cost(
137    left: PlanCostBreakdown,
138    inner_estimated_rows: usize,
139    cardinality: RelationalJoinCardinality,
140) -> PlanCostBreakdown {
141    estimate_relational_join_cost(
142        left,
143        estimate_relational_access_cost(inner_estimated_rows),
144        cardinality,
145        RelationalJoinRightInput::Probe,
146        RelationalJoinSelectivity::Unknown,
147    )
148}
149
150pub fn estimate_relational_join_cost(
151    left: PlanCostBreakdown,
152    right: PlanCostBreakdown,
153    cardinality: RelationalJoinCardinality,
154    right_input: RelationalJoinRightInput,
155    selectivity: RelationalJoinSelectivity,
156) -> PlanCostBreakdown {
157    let candidate_pairs = left.estimated_rows.saturating_mul(right.estimated_rows);
158    let joined_rows = match right_input {
159        RelationalJoinRightInput::Probe => candidate_pairs,
160        RelationalJoinRightInput::Materialized
161        | RelationalJoinRightInput::Hash
162        | RelationalJoinRightInput::Merge => candidate_pairs
163            .div_ceil(selectivity.materialized_divisor(left.estimated_rows, right.estimated_rows)),
164    };
165    let estimated_rows = match cardinality {
166        RelationalJoinCardinality::Inner => joined_rows,
167        RelationalJoinCardinality::PreserveLeft => joined_rows.max(left.estimated_rows),
168    };
169    let (right_multiplier, join_cpu) = match right_input {
170        RelationalJoinRightInput::Probe => (left.estimated_rows, 0),
171        RelationalJoinRightInput::Materialized => (1, candidate_pairs),
172        RelationalJoinRightInput::Hash => (
173            1,
174            left.estimated_rows
175                .saturating_add(right.estimated_rows.saturating_mul(2))
176                .saturating_add(joined_rows),
177        ),
178        RelationalJoinRightInput::Merge => (
179            1,
180            left.estimated_rows
181                .saturating_add(right.estimated_rows)
182                .saturating_add(joined_rows),
183        ),
184    };
185    PlanCostBreakdown::new(
186        estimated_rows,
187        left.cpu
188            .saturating_add(right.cpu.saturating_mul(right_multiplier))
189            .saturating_add(join_cpu),
190        left.random_io
191            .saturating_add(right.random_io.saturating_mul(right_multiplier)),
192        left.sequential_io
193            .saturating_add(right.sequential_io.saturating_mul(right_multiplier)),
194        left.output_rows
195            .saturating_add(right.output_rows.saturating_mul(right_multiplier)),
196    )
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::PlanCost;
203
204    #[test]
205    fn equi_join_algorithms_charge_linear_work_and_preserve_component_costs() {
206        for input in [
207            RelationalJoinRightInput::Hash,
208            RelationalJoinRightInput::Merge,
209        ] {
210            let left = PlanCostBreakdown::new(100, 7, 11, 13, 17);
211            let right = PlanCostBreakdown::new(200, 19, 23, 29, 31);
212            let result = estimate_relational_join_cost(
213                left,
214                right,
215                RelationalJoinCardinality::Inner,
216                input,
217                RelationalJoinSelectivity::equi_join(Some(100), Some(200)),
218            );
219            assert_eq!(result.estimated_rows, 100);
220            assert_eq!(
221                result.cpu,
222                26 + if input == RelationalJoinRightInput::Hash {
223                    600
224                } else {
225                    400
226                }
227            );
228            assert_eq!(
229                (result.random_io, result.sequential_io, result.output_rows),
230                (34, 42, 48)
231            );
232            let empty = PlanCostBreakdown::new(0, 0, 0, 0, 0);
233            let outer = estimate_relational_join_cost(
234                left,
235                empty,
236                RelationalJoinCardinality::PreserveLeft,
237                input,
238                RelationalJoinSelectivity::Unknown,
239            );
240            assert_eq!(outer.estimated_rows, 100);
241        }
242    }
243
244    #[test]
245    fn probe_join_preserves_the_existing_scalar_cost() {
246        let left = estimate_relational_access_cost(5);
247        let cost = estimate_relational_probe_join_cost(left, 2, RelationalJoinCardinality::Inner);
248
249        assert_eq!(
250            cost.as_plan_cost(),
251            PlanCost {
252                estimated_rows: 10,
253                cost: 15,
254            }
255        );
256    }
257
258    #[test]
259    fn materialized_join_charges_the_right_plan_once() {
260        let left = estimate_relational_access_cost(2);
261        let right = estimate_relational_access_cost(3);
262
263        let cost = estimate_relational_join_cost(
264            left,
265            right,
266            RelationalJoinCardinality::Inner,
267            RelationalJoinRightInput::Materialized,
268            RelationalJoinSelectivity::Unknown,
269        );
270
271        assert_eq!(
272            cost.as_plan_cost(),
273            PlanCost {
274                estimated_rows: 1,
275                cost: 11,
276            }
277        );
278    }
279
280    #[test]
281    fn materialized_equi_join_uses_the_larger_distinct_count() {
282        let left = estimate_relational_access_cost(10_000);
283        let right = estimate_relational_access_cost(20_000);
284
285        let cost = estimate_relational_join_cost(
286            left,
287            right,
288            RelationalJoinCardinality::Inner,
289            RelationalJoinRightInput::Materialized,
290            RelationalJoinSelectivity::equi_join(Some(10_000), Some(5_000)),
291        );
292
293        assert_eq!(cost.estimated_rows, 20_000);
294        assert_eq!(cost.cpu, 200_030_000);
295    }
296
297    #[test]
298    fn materialized_equi_join_uses_one_known_distinct_count() {
299        let left = estimate_relational_access_cost(10_000);
300        let right = estimate_relational_access_cost(20_000);
301
302        let cost = estimate_relational_join_cost(
303            left,
304            right,
305            RelationalJoinCardinality::Inner,
306            RelationalJoinRightInput::Materialized,
307            RelationalJoinSelectivity::equi_join(Some(10_000), None),
308        );
309
310        assert_eq!(cost.estimated_rows, 20_000);
311    }
312
313    #[test]
314    fn probe_join_scales_the_right_cost_components_by_outer_rows() {
315        let left = PlanCostBreakdown::new(2, 3, 5, 7, 11);
316        let right = PlanCostBreakdown::new(3, 13, 17, 19, 23);
317
318        let cost = estimate_relational_join_cost(
319            left,
320            right,
321            RelationalJoinCardinality::Inner,
322            RelationalJoinRightInput::Probe,
323            RelationalJoinSelectivity::equi_join(Some(2), Some(3)),
324        );
325
326        assert_eq!(cost.estimated_rows, 6);
327        assert_eq!(cost.cpu, 29);
328        assert_eq!(cost.random_io, 39);
329        assert_eq!(cost.sequential_io, 45);
330        assert_eq!(cost.output_rows, 57);
331        assert_eq!(cost.cost, 209);
332    }
333
334    #[test]
335    fn left_preserving_join_keeps_the_outer_cardinality_floor() {
336        let left = PlanCostBreakdown::new(4, 4, 0, 0, 0);
337        let empty_right = PlanCostBreakdown {
338            estimated_rows: 0,
339            cost: 0,
340            cpu: 0,
341            random_io: 0,
342            sequential_io: 0,
343            output_rows: 0,
344        };
345
346        let cost = estimate_relational_join_cost(
347            left,
348            empty_right,
349            RelationalJoinCardinality::PreserveLeft,
350            RelationalJoinRightInput::Probe,
351            RelationalJoinSelectivity::Unknown,
352        );
353
354        assert_eq!(cost.estimated_rows, 4);
355        assert_eq!(cost.as_plan_cost().cost, 4);
356    }
357}