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