Skip to main content

hawdb_optimizer_relational/
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};
20use hawdb_expression::BindingId;
21use std::collections::BTreeMap;
22use std::num::NonZeroU64;
23
24const FULL_SCAN_SETUP_CPU: u64 = 4;
25// Descriptor and key artifacts are initialized per row-root invocation.
26const SNAPSHOT_ROOT_ARTIFACT_SETUP_CPU: u64 = 2;
27// A checked root descriptor reads its fixed record and both nonempty key bounds.
28const SNAPSHOT_ROOT_DESCRIPTOR_READS: u64 = 3;
29
30/// Immutable structural work for the row source of one relation.
31///
32/// The default retains descriptor-only logical costing. A snapshot context
33/// must come from the exact pinned reader used by execution, rather than a
34/// global storage mode or cache counter. Counts describe logical lookup work;
35/// they do not certify cache warmth or predict physical I/O or elapsed time.
36#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
37pub struct RelationalAccessCostContext {
38    snapshot_rows: Option<RelationalSnapshotRowCost>,
39}
40
41/// Per-binding row-source costs for one immutable join enumeration.
42///
43/// Bindings, rather than a query-wide storage mode, distinguish mixed sources
44/// and self-joins. Missing bindings retain the descriptor-only default. The
45/// caller derives each entry from the reader pinned for that relation.
46#[derive(Debug, Default, Clone, PartialEq, Eq)]
47pub struct RelationalJoinCostContexts {
48    relations: BTreeMap<BindingId, RelationalAccessCostContext>,
49}
50
51impl RelationalJoinCostContexts {
52    pub fn with_relation(
53        mut self,
54        binding: BindingId,
55        context: RelationalAccessCostContext,
56    ) -> Self {
57        self.relations.insert(binding, context);
58        self
59    }
60
61    pub fn for_relation(&self, binding: BindingId) -> RelationalAccessCostContext {
62        self.relations.get(&binding).copied().unwrap_or_default()
63    }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67struct RelationalSnapshotRowCost {
68    table_rows: NonZeroU64,
69    table_pages: NonZeroU64,
70}
71
72impl RelationalSnapshotRowCost {
73    fn point_work(self, rows: u64) -> SnapshotRootWork {
74        let search_steps = u64::from(u64::BITS - self.table_pages.get().leading_zeros());
75        SnapshotRootWork {
76            cpu: rows.saturating_mul(SNAPSHOT_ROOT_ARTIFACT_SETUP_CPU.saturating_add(search_steps)),
77            random: rows
78                .saturating_mul(search_steps)
79                .saturating_mul(SNAPSHOT_ROOT_DESCRIPTOR_READS),
80            sequential: 0,
81        }
82    }
83}
84
85#[derive(Default)]
86struct SnapshotRootWork {
87    cpu: u64,
88    random: u64,
89    sequential: u64,
90}
91
92impl RelationalAccessCostContext {
93    /// Records bounded table metadata from a caller-pinned row snapshot.
94    /// Empty or unavailable row roots retain the default context. Mixed row
95    /// sources require one context per relation, including inside join memos.
96    pub const fn for_snapshot_rows(table_rows: NonZeroU64, table_pages: NonZeroU64) -> Self {
97        Self {
98            snapshot_rows: Some(RelationalSnapshotRowCost {
99                table_rows,
100                table_pages,
101            }),
102        }
103    }
104
105    fn additional_work(
106        self,
107        access: &RelationalAccessPathDescriptor,
108        rows: u64,
109    ) -> SnapshotRootWork {
110        let Some(snapshot) = self.snapshot_rows else {
111            return SnapshotRootWork::default();
112        };
113        // Each point invocation initializes descriptor/key artifact access,
114        // then searches the checked page bounds. The current demand scan also
115        // initializes both artifacts for every descriptor it visits. Estimate work
116        // using the binary-search depth bound and average page occupancy.
117        // Descriptor records and both bounds also incur logical access work:
118        // point probes jump among descriptors, while scans visit them in order.
119        // Cache residency does not remove those checked metadata reads.
120        match access.kind {
121            RelationalAccessPathKind::FullScan => {
122                let pages = (u128::from(rows) * u128::from(snapshot.table_pages.get()))
123                    .div_ceil(u128::from(snapshot.table_rows.get()))
124                    .min(u128::from(snapshot.table_pages.get())) as u64;
125                SnapshotRootWork {
126                    cpu: pages.saturating_mul(SNAPSHOT_ROOT_ARTIFACT_SETUP_CPU.saturating_add(1)),
127                    random: 0,
128                    sequential: pages.saturating_mul(SNAPSHOT_ROOT_DESCRIPTOR_READS),
129                }
130            }
131            RelationalAccessPathKind::PrimaryKey => snapshot.point_work(rows),
132            RelationalAccessPathKind::Index if access.requires_row_fetch => {
133                snapshot.point_work(rows)
134            }
135            RelationalAccessPathKind::Index => SnapshotRootWork::default(),
136        }
137    }
138
139    fn locator_fetch_work(self, rows: u64) -> SnapshotRootWork {
140        let Some(snapshot) = self.snapshot_rows else {
141            // Keep the existing descriptor-only join contract for callers
142            // without a pinned canonical row snapshot.
143            return SnapshotRootWork::default();
144        };
145        let root = snapshot.point_work(rows);
146        SnapshotRootWork {
147            cpu: rows.saturating_add(root.cpu),
148            random: rows.saturating_add(root.random),
149            sequential: 0,
150        }
151    }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum RelationalJoinCardinality {
156    Inner,
157    PreserveLeft,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum RelationalJoinRightInput {
162    /// The right access runs once per outer row, so its component costs scale
163    /// with the outer cardinality instead of being charged as a subtree.
164    Probe,
165    /// The right subtree is produced once, then the join charges row-pair work.
166    Materialized,
167    /// Build a hash table once, then probe it with the left input.
168    Hash,
169    /// Merge two compatibly ordered inputs without sorting them.
170    Merge,
171}
172
173#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
174pub enum RelationalJoinSelectivity {
175    /// No trustworthy predicate statistics are available. Materialized joins
176    /// use the documented 10% fallback; probe inputs ignore this value because
177    /// their row estimate is already a per-outer-row fanout.
178    #[default]
179    Unknown,
180    /// Distinct-value counts for the complete equality key on either side.
181    /// One known side is still a useful conservative denominator; when both
182    /// sides are known, the standard `1 / max(left_ndv, right_ndv)` estimate is
183    /// used.
184    EquiJoin {
185        left_distinct_values: Option<u64>,
186        right_distinct_values: Option<u64>,
187    },
188}
189
190impl RelationalJoinSelectivity {
191    pub const fn equi_join(
192        left_distinct_values: Option<u64>,
193        right_distinct_values: Option<u64>,
194    ) -> Self {
195        Self::EquiJoin {
196            left_distinct_values,
197            right_distinct_values,
198        }
199    }
200
201    fn materialized_divisor(self, left_rows: u64, right_rows: u64) -> u64 {
202        let cap = |distinct_values: u64, rows: u64| distinct_values.max(1).min(rows.max(1));
203        match self {
204            Self::Unknown => JOIN_SELECTIVITY_DIVISOR,
205            Self::EquiJoin {
206                left_distinct_values,
207                right_distinct_values,
208            } => left_distinct_values
209                .map(|distinct_values| cap(distinct_values, left_rows))
210                .into_iter()
211                .chain(
212                    right_distinct_values.map(|distinct_values| cap(distinct_values, right_rows)),
213                )
214                .max()
215                .unwrap_or(JOIN_SELECTIVITY_DIVISOR),
216        }
217    }
218}
219
220pub fn estimate_relational_access_cost(estimated_rows: usize) -> PlanCostBreakdown {
221    let rows = u64::try_from(estimated_rows).unwrap_or(u64::MAX).max(1);
222    PlanCostBreakdown::new(rows, rows, 0, 0, 0)
223}
224
225/// Estimates logical work for a validated access descriptor.
226///
227/// Cardinality counts visited candidate rows, before residual filtering. A full
228/// scan visits sequential rows; primary-key access directly locates rows. An
229/// index navigates once, visits entries, and fetches each non-covering row.
230/// Ordering changes delivered properties, not the number of visited entries.
231/// These units do not predict file I/O: residency, row width, page clustering
232/// and caches require separate runtime evidence. The rows-only helper retains
233/// its original CPU-only contract for callers without an access descriptor.
234pub fn estimate_relational_access_path_cost(
235    access: &RelationalAccessPathDescriptor,
236) -> PlanCostBreakdown {
237    estimate_relational_access_path_cost_with_context(
238        access,
239        RelationalAccessCostContext::default(),
240    )
241}
242
243/// Uses the same raw-component contract with relation-specific structural
244/// context. Row-root setup CPU and metadata access patterns retain separate raw
245/// components; cardinality and output stay independent. The final constructor
246/// weights every component once.
247pub fn estimate_relational_access_path_cost_with_context(
248    access: &RelationalAccessPathDescriptor,
249    context: RelationalAccessCostContext,
250) -> PlanCostBreakdown {
251    let rows = u64::try_from(access.estimated_rows)
252        .unwrap_or(u64::MAX)
253        .max(1);
254    let (cpu, random, sequential) = match access.kind {
255        RelationalAccessPathKind::FullScan => (rows.saturating_add(FULL_SCAN_SETUP_CPU), 0, rows),
256        RelationalAccessPathKind::PrimaryKey => (rows, rows, 0),
257        RelationalAccessPathKind::Index => {
258            let fetches = if access.requires_row_fetch { rows } else { 0 };
259            (
260                rows.saturating_add(fetches),
261                1_u64.saturating_add(fetches),
262                if access.unique_point { 0 } else { rows },
263            )
264        }
265    };
266    let root_work = context.additional_work(access, rows);
267    PlanCostBreakdown::new(
268        rows,
269        cpu.saturating_add(root_work.cpu),
270        random.saturating_add(root_work.random),
271        sequential.saturating_add(root_work.sequential),
272        rows,
273    )
274}
275
276/// Extends a left-deep plan with one probe join using the canonical
277/// relational cost model.
278pub fn estimate_relational_probe_join_cost(
279    left: PlanCostBreakdown,
280    inner_estimated_rows: usize,
281    cardinality: RelationalJoinCardinality,
282) -> PlanCostBreakdown {
283    estimate_relational_join_cost(
284        left,
285        estimate_relational_access_cost(inner_estimated_rows),
286        cardinality,
287        RelationalJoinRightInput::Probe,
288        RelationalJoinSelectivity::Unknown,
289    )
290}
291
292pub fn estimate_relational_join_cost(
293    left: PlanCostBreakdown,
294    right: PlanCostBreakdown,
295    cardinality: RelationalJoinCardinality,
296    right_input: RelationalJoinRightInput,
297    selectivity: RelationalJoinSelectivity,
298) -> PlanCostBreakdown {
299    estimate_relational_join_cost_with_contexts(
300        left,
301        right,
302        cardinality,
303        right_input,
304        selectivity,
305        RelationalAccessCostContext::default(),
306        RelationalAccessCostContext::default(),
307    )
308}
309
310/// Extends join composition with the original row sources of its two inputs.
311///
312/// The current relational Hash adapter retains locators, even without spill,
313/// and point-fetches both projected rows for every matching candidate. Charge
314/// that resident work in addition to producing the inputs. LEFT also allows
315/// one unmatched locator fetch for every left row, a conservative bound since
316/// equality pair counts do not identify distinct matched outer rows. Merge
317/// retains projected rows; probe/materialized costs already charge their input
318/// invocations. Default contexts preserve the descriptor-only contract.
319/// Spill validation and repartitioning are additional unmodeled work: this is
320/// a resident logical estimate, not a guarantee of execution within a budget.
321pub fn estimate_relational_join_cost_with_contexts(
322    left: PlanCostBreakdown,
323    right: PlanCostBreakdown,
324    cardinality: RelationalJoinCardinality,
325    right_input: RelationalJoinRightInput,
326    selectivity: RelationalJoinSelectivity,
327    left_context: RelationalAccessCostContext,
328    right_context: RelationalAccessCostContext,
329) -> PlanCostBreakdown {
330    let candidate_pairs = left.estimated_rows.saturating_mul(right.estimated_rows);
331    let joined_rows = match right_input {
332        RelationalJoinRightInput::Probe => candidate_pairs,
333        RelationalJoinRightInput::Materialized
334        | RelationalJoinRightInput::Hash
335        | RelationalJoinRightInput::Merge => candidate_pairs
336            .div_ceil(selectivity.materialized_divisor(left.estimated_rows, right.estimated_rows)),
337    };
338    let estimated_rows = match cardinality {
339        RelationalJoinCardinality::Inner => joined_rows,
340        RelationalJoinCardinality::PreserveLeft => joined_rows.max(left.estimated_rows),
341    };
342    let (right_multiplier, join_cpu) = match right_input {
343        RelationalJoinRightInput::Probe => (left.estimated_rows, 0),
344        RelationalJoinRightInput::Materialized => (1, candidate_pairs),
345        RelationalJoinRightInput::Hash => (
346            1,
347            left.estimated_rows
348                .saturating_add(right.estimated_rows.saturating_mul(2))
349                .saturating_add(joined_rows),
350        ),
351        RelationalJoinRightInput::Merge => (
352            1,
353            left.estimated_rows
354                .saturating_add(right.estimated_rows)
355                .saturating_add(joined_rows),
356        ),
357    };
358    let (left_replay, right_replay) = if right_input == RelationalJoinRightInput::Hash {
359        let left_fetches = joined_rows.saturating_add(match cardinality {
360            RelationalJoinCardinality::Inner => 0,
361            RelationalJoinCardinality::PreserveLeft => left.estimated_rows,
362        });
363        (
364            left_context.locator_fetch_work(left_fetches),
365            right_context.locator_fetch_work(joined_rows),
366        )
367    } else {
368        (SnapshotRootWork::default(), SnapshotRootWork::default())
369    };
370    PlanCostBreakdown::new(
371        estimated_rows,
372        left.cpu
373            .saturating_add(right.cpu.saturating_mul(right_multiplier))
374            .saturating_add(join_cpu)
375            .saturating_add(left_replay.cpu)
376            .saturating_add(right_replay.cpu),
377        left.random_io
378            .saturating_add(right.random_io.saturating_mul(right_multiplier))
379            .saturating_add(left_replay.random)
380            .saturating_add(right_replay.random),
381        left.sequential_io
382            .saturating_add(right.sequential_io.saturating_mul(right_multiplier)),
383        left.output_rows
384            .saturating_add(right.output_rows.saturating_mul(right_multiplier)),
385    )
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use crate::PlanCost;
392
393    #[test]
394    fn snapshot_hash_locator_replay_preserves_mixed_sources_outer_floor_and_saturation() {
395        let snapshot = |rows, pages| {
396            RelationalAccessCostContext::for_snapshot_rows(
397                NonZeroU64::new(rows).unwrap(),
398                NonZeroU64::new(pages).unwrap(),
399            )
400        };
401        let empty = RelationalAccessCostContext::default();
402        let left_context = snapshot(10, 4);
403        let right_context = snapshot(4, 1);
404        let left = PlanCostBreakdown::new(10, 11, 13, 17, 19);
405        let right = PlanCostBreakdown::new(4, 23, 29, 31, 37);
406        let selectivity = RelationalJoinSelectivity::equi_join(Some(10), Some(4));
407        // Four estimated pairs. Resident join work is CPU22; each A fetch
408        // costs CPU6/random10, each B fetch CPU4/random4. Input components
409        // are supplied independently, not reconstructed by an access helper.
410        for (left_source, right_source, cpu, random) in [
411            (empty, empty, 56, 42),
412            (left_context, empty, 80, 82),
413            (empty, right_context, 72, 58),
414            (left_context, right_context, 96, 98),
415        ] {
416            let result = estimate_relational_join_cost_with_contexts(
417                left,
418                right,
419                RelationalJoinCardinality::Inner,
420                RelationalJoinRightInput::Hash,
421                selectivity,
422                left_source,
423                right_source,
424            );
425            assert_eq!(result, PlanCostBreakdown::new(4, cpu, random, 48, 56));
426        }
427        // Pair counts do not identify matched distinct probes. LEFT's bound
428        // allows another ten left fetches while preserving output cardinality
429        // and charging the supplied input/output components only once.
430        assert_eq!(
431            estimate_relational_join_cost_with_contexts(
432                left,
433                right,
434                RelationalJoinCardinality::PreserveLeft,
435                RelationalJoinRightInput::Hash,
436                selectivity,
437                left_context,
438                right_context,
439            ),
440            PlanCostBreakdown::new(10, 156, 198, 48, 56),
441        );
442        for cardinality in [
443            RelationalJoinCardinality::Inner,
444            RelationalJoinCardinality::PreserveLeft,
445        ] {
446            for input in [
447                RelationalJoinRightInput::Probe,
448                RelationalJoinRightInput::Materialized,
449                RelationalJoinRightInput::Merge,
450            ] {
451                assert_eq!(
452                    estimate_relational_join_cost_with_contexts(
453                        left,
454                        right,
455                        cardinality,
456                        input,
457                        selectivity,
458                        left_context,
459                        right_context
460                    ),
461                    estimate_relational_join_cost(left, right, cardinality, input, selectivity),
462                );
463            }
464            assert_eq!(
465                estimate_relational_join_cost_with_contexts(
466                    left,
467                    right,
468                    cardinality,
469                    RelationalJoinRightInput::Hash,
470                    selectivity,
471                    empty,
472                    empty
473                ),
474                estimate_relational_join_cost(
475                    left,
476                    right,
477                    cardinality,
478                    RelationalJoinRightInput::Hash,
479                    selectivity
480                ),
481            );
482        }
483        let max = PlanCostBreakdown::new(u64::MAX, u64::MAX, u64::MAX, u64::MAX, u64::MAX);
484        let max_context = snapshot(u64::MAX, u64::MAX);
485        assert_eq!(
486            estimate_relational_join_cost_with_contexts(
487                max,
488                max,
489                RelationalJoinCardinality::PreserveLeft,
490                RelationalJoinRightInput::Hash,
491                RelationalJoinSelectivity::Unknown,
492                max_context,
493                max_context
494            ),
495            max,
496        );
497    }
498
499    #[test]
500    fn equi_join_algorithms_charge_linear_work_and_preserve_component_costs() {
501        for input in [
502            RelationalJoinRightInput::Hash,
503            RelationalJoinRightInput::Merge,
504        ] {
505            let left = PlanCostBreakdown::new(100, 7, 11, 13, 17);
506            let right = PlanCostBreakdown::new(200, 19, 23, 29, 31);
507            let result = estimate_relational_join_cost(
508                left,
509                right,
510                RelationalJoinCardinality::Inner,
511                input,
512                RelationalJoinSelectivity::equi_join(Some(100), Some(200)),
513            );
514            assert_eq!(result.estimated_rows, 100);
515            assert_eq!(
516                result.cpu,
517                26 + if input == RelationalJoinRightInput::Hash {
518                    600
519                } else {
520                    400
521                }
522            );
523            assert_eq!(
524                (result.random_io, result.sequential_io, result.output_rows),
525                (34, 42, 48)
526            );
527            let empty = PlanCostBreakdown::new(0, 0, 0, 0, 0);
528            let outer = estimate_relational_join_cost(
529                left,
530                empty,
531                RelationalJoinCardinality::PreserveLeft,
532                input,
533                RelationalJoinSelectivity::Unknown,
534            );
535            assert_eq!(outer.estimated_rows, 100);
536        }
537    }
538
539    #[test]
540    fn probe_join_preserves_the_existing_scalar_cost() {
541        let left = estimate_relational_access_cost(5);
542        let cost = estimate_relational_probe_join_cost(left, 2, RelationalJoinCardinality::Inner);
543
544        assert_eq!(
545            cost.as_plan_cost(),
546            PlanCost {
547                estimated_rows: 10,
548                cost: 15,
549            }
550        );
551    }
552
553    #[test]
554    fn materialized_join_charges_the_right_plan_once() {
555        let left = estimate_relational_access_cost(2);
556        let right = estimate_relational_access_cost(3);
557
558        let cost = estimate_relational_join_cost(
559            left,
560            right,
561            RelationalJoinCardinality::Inner,
562            RelationalJoinRightInput::Materialized,
563            RelationalJoinSelectivity::Unknown,
564        );
565
566        assert_eq!(
567            cost.as_plan_cost(),
568            PlanCost {
569                estimated_rows: 1,
570                cost: 11,
571            }
572        );
573    }
574
575    #[test]
576    fn materialized_equi_join_uses_the_larger_distinct_count() {
577        let left = estimate_relational_access_cost(10_000);
578        let right = estimate_relational_access_cost(20_000);
579
580        let cost = estimate_relational_join_cost(
581            left,
582            right,
583            RelationalJoinCardinality::Inner,
584            RelationalJoinRightInput::Materialized,
585            RelationalJoinSelectivity::equi_join(Some(10_000), Some(5_000)),
586        );
587
588        assert_eq!(cost.estimated_rows, 20_000);
589        assert_eq!(cost.cpu, 200_030_000);
590    }
591
592    #[test]
593    fn materialized_equi_join_uses_one_known_distinct_count() {
594        let left = estimate_relational_access_cost(10_000);
595        let right = estimate_relational_access_cost(20_000);
596
597        let cost = estimate_relational_join_cost(
598            left,
599            right,
600            RelationalJoinCardinality::Inner,
601            RelationalJoinRightInput::Materialized,
602            RelationalJoinSelectivity::equi_join(Some(10_000), None),
603        );
604
605        assert_eq!(cost.estimated_rows, 20_000);
606    }
607
608    #[test]
609    fn probe_join_scales_the_right_cost_components_by_outer_rows() {
610        let left = PlanCostBreakdown::new(2, 3, 5, 7, 11);
611        let right = PlanCostBreakdown::new(3, 13, 17, 19, 23);
612
613        let cost = estimate_relational_join_cost(
614            left,
615            right,
616            RelationalJoinCardinality::Inner,
617            RelationalJoinRightInput::Probe,
618            RelationalJoinSelectivity::equi_join(Some(2), Some(3)),
619        );
620
621        assert_eq!(cost.estimated_rows, 6);
622        assert_eq!(cost.cpu, 29);
623        assert_eq!(cost.random_io, 39);
624        assert_eq!(cost.sequential_io, 45);
625        assert_eq!(cost.output_rows, 57);
626        assert_eq!(cost.cost, 209);
627    }
628
629    #[test]
630    fn left_preserving_join_keeps_the_outer_cardinality_floor() {
631        let left = PlanCostBreakdown::new(4, 4, 0, 0, 0);
632        let empty_right = PlanCostBreakdown {
633            estimated_rows: 0,
634            cost: 0,
635            cpu: 0,
636            random_io: 0,
637            sequential_io: 0,
638            output_rows: 0,
639        };
640
641        let cost = estimate_relational_join_cost(
642            left,
643            empty_right,
644            RelationalJoinCardinality::PreserveLeft,
645            RelationalJoinRightInput::Probe,
646            RelationalJoinSelectivity::Unknown,
647        );
648
649        assert_eq!(cost.estimated_rows, 4);
650        assert_eq!(cost.as_plan_cost().cost, 4);
651    }
652}