Skip to main content

radixdb_executor/
hash_table.rs

1// Copyright 2026 RadixDB Contributors
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//! Optimized hash table for join operations.
16//!
17//! This module provides a specialized hash table designed for the build phase
18//! of hash joins. Key optimizations:
19//!
20//! 1. **Pre-allocated**: Sized upfront based on build side cardinality
21//! 2. **Cache-efficient**: Linear probing within cache lines
22//! 3. **Zero-allocation probe**: Iterator returns indices without allocation
23//! 4. **Full hash stored**: Quick rejection without row access
24//!
25//! # Memory Layout
26//!
27//! ```text
28//! JoinHashTable
29//! ├── bucket_heads: Vec<i32>    [bucket_count]     // First entry index per bucket
30//! ├── entries: Vec<HashEntry>   [row_count]        // One per build row
31//! └── bucket_mask: u64                             // For fast modulo
32//!
33//! HashEntry (16 bytes, cache-aligned)
34//! ├── hash: u64     // Full hash for quick rejection
35//! ├── row_idx: u32  // Index into build rows
36//! └── next: u32     // Next in chain (EMPTY = end)
37//! ```
38
39use std::hash::{Hash, Hasher};
40use std::sync::{Arc, Mutex, Weak};
41
42use rustc_hash::FxHasher;
43
44use radixdb_core::CompactArc;
45use radixdb_core::{Row, Value};
46
47/// Minimal sink used while a JOIN hash table and a probe accelerator are
48/// populated in one pass. The optimizer owns the accelerator; the physical
49/// hash foundation depends only on this lifecycle-neutral contract.
50pub trait JoinHashObserver {
51    /// Observe one canonical hash already computed for the hash table.
52    fn insert_raw_hash(&mut self, hash: u64);
53
54    /// Bytes retained by the observer and charged to the request budget.
55    fn retained_bytes(&self) -> usize;
56}
57
58/// Immutable build-side state shared by every probe consumer of one physical
59/// JOIN edge.
60///
61/// The row batch, key layout and hash table travel as one object. This makes
62/// it impossible to attach a pre-built table to a different row ordering or a
63/// different set of key columns, while cheap clones let a fused physical
64/// pipeline reuse the same build state without rebuilding it.
65#[derive(Clone)]
66pub struct JoinHashState {
67    build_rows: CompactArc<Vec<Row>>,
68    key_indices: CompactArc<[usize]>,
69    table: Arc<JoinHashTable>,
70    /// One allocation is charged exactly once even when the immutable state is
71    /// cloned by several physical consumers.
72    _memory_reservation: Option<Arc<JoinMemoryReservation>>,
73}
74
75#[derive(Default)]
76struct JoinMemoryUsage {
77    retained_bytes: usize,
78    peak_bytes: usize,
79}
80
81#[derive(Default)]
82pub(crate) struct JoinMemoryOwner {
83    usage: Mutex<JoinMemoryUsage>,
84}
85
86impl std::fmt::Debug for JoinMemoryOwner {
87    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        formatter
89            .debug_struct("JoinMemoryOwner")
90            .field("retained_bytes", &self.retained_bytes())
91            .field("peak_bytes", &self.peak_bytes())
92            .finish()
93    }
94}
95
96impl JoinMemoryOwner {
97    pub(crate) fn try_reserve(
98        self: &Arc<Self>,
99        bytes: usize,
100        max_bytes: usize,
101    ) -> Option<JoinMemoryReservation> {
102        let mut usage = self
103            .usage
104            .lock()
105            .unwrap_or_else(std::sync::PoisonError::into_inner);
106        let next = usage.retained_bytes.checked_add(bytes)?;
107        if next > max_bytes {
108            return None;
109        }
110        usage.retained_bytes = next;
111        usage.peak_bytes = usage.peak_bytes.max(next);
112        Some(JoinMemoryReservation {
113            owner: Arc::downgrade(self),
114            bytes,
115        })
116    }
117
118    pub(crate) fn retained_bytes(&self) -> usize {
119        self.usage
120            .lock()
121            .unwrap_or_else(std::sync::PoisonError::into_inner)
122            .retained_bytes
123    }
124
125    pub(crate) fn peak_bytes(&self) -> usize {
126        self.usage
127            .lock()
128            .unwrap_or_else(std::sync::PoisonError::into_inner)
129            .peak_bytes
130    }
131}
132
133#[doc(hidden)]
134pub struct JoinMemoryReservation {
135    owner: Weak<JoinMemoryOwner>,
136    bytes: usize,
137}
138
139impl JoinMemoryReservation {
140    /// Resize one live request-local reservation without opening a second
141    /// accounting window. Blocking operators whose retained set grows one row
142    /// at a time (Top-N, DISTINCT, sort runs) use this to share the same hard
143    /// ceiling as JOIN hash/probe state.
144    #[doc(hidden)]
145    pub fn try_resize(&mut self, bytes: usize, max_bytes: usize) -> bool {
146        let Some(owner) = self.owner.upgrade() else {
147            // The request context has already gone away. No other owner can
148            // compete for this budget, so only keep the local bookkeeping.
149            self.bytes = bytes;
150            return true;
151        };
152        let mut usage = owner
153            .usage
154            .lock()
155            .unwrap_or_else(std::sync::PoisonError::into_inner);
156        let without_self = usage.retained_bytes.saturating_sub(self.bytes);
157        let Some(next) = without_self.checked_add(bytes) else {
158            return false;
159        };
160        if next > max_bytes {
161            return false;
162        }
163        usage.retained_bytes = next;
164        usage.peak_bytes = usage.peak_bytes.max(next);
165        self.bytes = bytes;
166        true
167    }
168}
169
170impl std::fmt::Debug for JoinMemoryReservation {
171    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        formatter
173            .debug_struct("JoinMemoryReservation")
174            .field("bytes", &self.bytes)
175            .finish()
176    }
177}
178
179impl Drop for JoinMemoryReservation {
180    fn drop(&mut self) {
181        let Some(owner) = self.owner.upgrade() else {
182            return;
183        };
184        let mut usage = owner
185            .usage
186            .lock()
187            .unwrap_or_else(std::sync::PoisonError::into_inner);
188        usage.retained_bytes = usage.retained_bytes.saturating_sub(self.bytes);
189    }
190}
191
192impl JoinHashState {
193    /// Build one immutable hash state for an already materialized physical
194    /// batch. No row is copied: the state retains the exact batch identity.
195    pub fn build(build_rows: CompactArc<Vec<Row>>, key_indices: &[usize]) -> Self {
196        let table = JoinHashTable::build(&build_rows, key_indices);
197        Self::from_table(build_rows, key_indices, table, None)
198    }
199
200    /// Build only when the immutable bucket/entry state fits its admission
201    /// budget. `None` selects the bounded scan fallback rather than risking an
202    /// allocator abort or process OOM.
203    pub fn try_build(
204        build_rows: CompactArc<Vec<Row>>,
205        key_indices: &[usize],
206        max_bytes: usize,
207    ) -> Option<Self> {
208        JoinHashTable::fits_retained_budget(build_rows.len(), max_bytes)
209            .then(|| Self::build(build_rows, key_indices))
210    }
211
212    pub(crate) fn build_reserved(
213        build_rows: CompactArc<Vec<Row>>,
214        key_indices: &[usize],
215        reservation: JoinMemoryReservation,
216    ) -> Self {
217        let table = JoinHashTable::build(&build_rows, key_indices);
218        Self::from_table(build_rows, key_indices, table, Some(reservation))
219    }
220
221    /// Build one state while populating a bloom accelerator in the same pass.
222    /// Keeping this constructor here prevents callers from pairing a table
223    /// built for one key layout with metadata claiming another layout.
224    pub fn build_with_bloom(
225        build_rows: CompactArc<Vec<Row>>,
226        key_indices: &[usize],
227        observer: &mut impl JoinHashObserver,
228    ) -> Self {
229        let table = JoinHashTable::build_with_observer(&build_rows, key_indices, observer);
230        Self::from_table(build_rows, key_indices, table, None)
231    }
232
233    pub(crate) fn build_with_bloom_reserved(
234        build_rows: CompactArc<Vec<Row>>,
235        key_indices: &[usize],
236        observer: &mut impl JoinHashObserver,
237        reservation: JoinMemoryReservation,
238    ) -> Self {
239        let table = JoinHashTable::build_with_observer(&build_rows, key_indices, observer);
240        Self::from_table(build_rows, key_indices, table, Some(reservation))
241    }
242
243    fn from_table(
244        build_rows: CompactArc<Vec<Row>>,
245        key_indices: &[usize],
246        table: JoinHashTable,
247        reservation: Option<JoinMemoryReservation>,
248    ) -> Self {
249        assert_eq!(
250            table.len(),
251            build_rows.len(),
252            "join hash state must contain one entry per build row"
253        );
254        Self {
255            build_rows,
256            key_indices: CompactArc::from(key_indices.to_vec()),
257            table: Arc::new(table),
258            _memory_reservation: reservation.map(Arc::new),
259        }
260    }
261
262    /// Whether this state belongs to exactly this immutable batch and key
263    /// layout. Pointer identity is intentional: equal values in a newly
264    /// materialized batch do not prove equal physical row indices.
265    pub fn matches(&self, build_rows: &CompactArc<Vec<Row>>, key_indices: &[usize]) -> bool {
266        CompactArc::ptr_eq(&self.build_rows, build_rows) && self.key_indices.as_ref() == key_indices
267    }
268
269    #[inline]
270    pub fn build_rows(&self) -> &CompactArc<Vec<Row>> {
271        &self.build_rows
272    }
273
274    #[inline]
275    pub fn table(&self) -> &Arc<JoinHashTable> {
276        &self.table
277    }
278
279    #[cfg(test)]
280    fn shares_allocations_with(&self, other: &Self) -> bool {
281        CompactArc::ptr_eq(&self.build_rows, &other.build_rows)
282            && Arc::ptr_eq(&self.table, &other.table)
283    }
284}
285
286/// Sentinel value indicating end of chain or empty bucket.
287const EMPTY: u32 = u32::MAX;
288
289/// Minimum number of buckets (must be power of 2).
290const MIN_BUCKETS: usize = 16;
291
292/// Default admission boundary for the hash index itself. Build rows are owned
293/// by the upstream relation; this limit prevents the JOIN from allocating an
294/// additional unbounded bucket/entry structure over them.
295#[doc(hidden)]
296pub const DEFAULT_JOIN_HASH_STATE_MAX_BYTES: usize = 256 * 1024 * 1024;
297
298/// A hash entry in the join hash table.
299///
300/// Each entry represents one row from the build side.
301#[repr(C)]
302#[derive(Debug, Clone, Copy)]
303struct HashEntry {
304    /// Full 64-bit hash for quick rejection during probe.
305    /// Comparing hashes first avoids touching row data for non-matches.
306    hash: u64,
307    /// Index into the build rows vector.
308    row_idx: u32,
309    /// Index of next entry in the chain (EMPTY = end of chain).
310    next: u32,
311}
312
313impl HashEntry {
314    #[inline]
315    fn new(hash: u64, row_idx: u32, next: u32) -> Self {
316        Self {
317            hash,
318            row_idx,
319            next,
320        }
321    }
322}
323
324/// Optimized hash table for join operations.
325///
326/// This hash table is specifically designed for the build phase of hash joins.
327/// It uses chaining with linked entries stored in a flat vector for cache efficiency.
328///
329/// # Example
330///
331/// ```ignore
332/// // Build phase
333/// let mut table = JoinHashTable::with_capacity(build_rows.len());
334/// for (idx, row) in build_rows.iter().enumerate() {
335///     let hash = hash_row_keys(row, &key_indices);
336///     table.insert(hash, idx as u32);
337/// }
338///
339/// // Probe phase
340/// for probe_row in probe_rows {
341///     let hash = hash_row_keys(probe_row, &probe_key_indices);
342///     for build_idx in table.probe(hash) {
343///         // Verify actual key equality and produce output
344///     }
345/// }
346/// ```
347pub struct JoinHashTable {
348    /// First entry index for each bucket (-1 if empty).
349    /// Sized to power of 2 for fast modulo via bitwise AND.
350    bucket_heads: Vec<i32>,
351
352    /// Flat storage of all entries.
353    /// One entry per build row.
354    entries: Vec<HashEntry>,
355
356    /// Mask for computing bucket index: bucket = hash & mask
357    bucket_mask: u64,
358
359    /// Number of entries inserted.
360    len: usize,
361}
362
363impl JoinHashTable {
364    fn bucket_count_for_rows(row_count: usize) -> Option<usize> {
365        row_count
366            .checked_mul(4)
367            .map(|scaled| scaled / 3)
368            .map(|scaled| scaled.max(MIN_BUCKETS))?
369            .checked_next_power_of_two()
370    }
371
372    /// Exact retained size of bucket heads and compact entries before Vec
373    /// allocator rounding. Overflow or row indices outside u32 are rejected.
374    pub fn estimated_retained_bytes(row_count: usize) -> Option<usize> {
375        if row_count > u32::MAX as usize {
376            return None;
377        }
378        let buckets = Self::bucket_count_for_rows(row_count)?;
379        buckets
380            .checked_mul(std::mem::size_of::<i32>())?
381            .checked_add(row_count.checked_mul(std::mem::size_of::<HashEntry>())?)
382    }
383
384    #[inline]
385    pub fn fits_retained_budget(row_count: usize, max_bytes: usize) -> bool {
386        Self::estimated_retained_bytes(row_count).is_some_and(|bytes| bytes <= max_bytes)
387    }
388
389    /// Create a new hash table with capacity for the given number of rows.
390    ///
391    /// The table is pre-allocated to avoid resizing during build.
392    /// Bucket count is sized to achieve ~75% load factor.
393    pub fn with_capacity(row_count: usize) -> Self {
394        // Choose bucket count as next power of 2 >= row_count * 4/3.
395        // Runtime callers apply `fits_retained_budget` first; this constructor
396        // remains infallible for already-admitted sizes.
397        let bucket_count = Self::bucket_count_for_rows(row_count)
398            .expect("join hash table capacity exceeds addressable range");
399        assert!(
400            row_count <= u32::MAX as usize,
401            "join hash table row index exceeds u32"
402        );
403
404        let bucket_mask = (bucket_count - 1) as u64;
405
406        Self {
407            bucket_heads: vec![-1; bucket_count],
408            entries: Vec::with_capacity(row_count),
409            bucket_mask,
410            len: 0,
411        }
412    }
413
414    /// Create an empty hash table (for cases where build side is empty).
415    pub fn empty() -> Self {
416        Self {
417            bucket_heads: vec![-1; MIN_BUCKETS],
418            entries: Vec::new(),
419            bucket_mask: (MIN_BUCKETS - 1) as u64,
420            len: 0,
421        }
422    }
423
424    /// Build a hash table from rows using the specified key indices.
425    ///
426    /// This is the main entry point for creating a join hash table.
427    pub fn build(rows: &[Row], key_indices: &[usize]) -> Self {
428        #[cfg(feature = "bench-harness")]
429        let started = std::time::Instant::now();
430        let mut table = Self::with_capacity(rows.len());
431
432        for (idx, row) in rows.iter().enumerate() {
433            let hash = hash_row_keys(row, key_indices);
434            table.insert(hash, idx as u32);
435        }
436
437        #[cfg(feature = "bench-harness")]
438        radixdb_storage::instrumentation::record_hash_build(rows.len(), started.elapsed());
439        table
440    }
441
442    /// Build hash table and populate bloom filter in a single pass.
443    ///
444    /// This is more efficient than building separately because we only
445    /// extract and hash key values once for both structures.
446    ///
447    /// # Arguments
448    /// * `rows` - Build side rows
449    /// * `key_indices` - Indices of join key columns
450    /// * `bloom_builder` - Bloom filter builder to populate
451    ///
452    /// # Returns
453    /// The built hash table (bloom filter is populated in-place)
454    pub fn build_with_observer(
455        rows: &[Row],
456        key_indices: &[usize],
457        observer: &mut impl JoinHashObserver,
458    ) -> Self {
459        #[cfg(feature = "bench-harness")]
460        let started = std::time::Instant::now();
461        let mut table = Self::with_capacity(rows.len());
462
463        for (idx, row) in rows.iter().enumerate() {
464            // Extract keys and compute hash once
465            let hash = hash_row_keys(row, key_indices);
466
467            // Insert into hash table
468            table.insert(hash, idx as u32);
469
470            // Insert into bloom filter using the same pre-computed hash
471            // This avoids re-hashing the same key values
472            observer.insert_raw_hash(hash);
473        }
474
475        #[cfg(feature = "bench-harness")]
476        radixdb_storage::instrumentation::record_hash_build(rows.len(), started.elapsed());
477        table
478    }
479
480    /// Insert a row index with its pre-computed hash.
481    ///
482    /// # Arguments
483    /// * `hash` - The hash of the row's key columns
484    /// * `row_idx` - The index of the row in the build rows vector
485    #[inline]
486    pub fn insert(&mut self, hash: u64, row_idx: u32) {
487        let bucket = (hash & self.bucket_mask) as usize;
488
489        // Get current head of chain
490        let old_head = self.bucket_heads[bucket];
491
492        // Create new entry pointing to old head
493        // Use cached len instead of Vec::len() to avoid repeated length checks
494        let entry_idx = self.len as u32;
495        let next = if old_head >= 0 {
496            old_head as u32
497        } else {
498            EMPTY
499        };
500        self.entries.push(HashEntry::new(hash, row_idx, next));
501
502        // Update bucket head to point to new entry
503        self.bucket_heads[bucket] = entry_idx as i32;
504        self.len += 1;
505    }
506
507    /// Probe the hash table for matching row indices.
508    ///
509    /// Returns an iterator that yields row indices for entries
510    /// with matching hashes. The caller must verify actual key
511    /// equality for each returned index (to handle hash collisions).
512    ///
513    /// This is a zero-allocation operation - the iterator only
514    /// holds a reference to the table.
515    #[inline]
516    pub fn probe(&self, hash: u64) -> ProbeIter<'_> {
517        let bucket = (hash & self.bucket_mask) as usize;
518        let first = self.bucket_heads[bucket];
519
520        ProbeIter {
521            table: self,
522            hash,
523            current: first,
524        }
525    }
526
527    /// Start a resumable zero-allocation probe. Unlike `ProbeIter`, the cursor
528    /// owns no borrow and can therefore live inside a streaming operator while
529    /// that operator mutates its other state between emitted matches.
530    #[inline]
531    pub fn probe_cursor(&self, hash: u64) -> ProbeCursor {
532        let bucket = (hash & self.bucket_mask) as usize;
533        ProbeCursor {
534            hash,
535            current: self.bucket_heads[bucket],
536        }
537    }
538
539    /// Advance one resumable probe and return the next matching row index.
540    #[inline]
541    pub fn probe_next(&self, cursor: &mut ProbeCursor) -> Option<usize> {
542        while cursor.current >= 0 {
543            let entry = &self.entries[cursor.current as usize];
544            cursor.current = if entry.next == EMPTY {
545                -1
546            } else {
547                entry.next as i32
548            };
549            if entry.hash == cursor.hash {
550                return Some(entry.row_idx as usize);
551            }
552        }
553        None
554    }
555
556    /// Get the number of entries in the table.
557    #[inline]
558    pub fn len(&self) -> usize {
559        self.len
560    }
561
562    /// Check if the table is empty.
563    #[inline]
564    pub fn is_empty(&self) -> bool {
565        self.len == 0
566    }
567
568    /// Get the number of buckets.
569    #[inline]
570    pub fn bucket_count(&self) -> usize {
571        self.bucket_heads.len()
572    }
573
574    /// Get the load factor (entries / buckets).
575    #[inline]
576    pub fn load_factor(&self) -> f64 {
577        self.len as f64 / self.bucket_heads.len() as f64
578    }
579}
580
581/// Zero-allocation iterator over probe results.
582///
583/// Yields row indices for entries whose hash matches the probe hash.
584/// The caller must verify actual key equality for each returned index.
585pub struct ProbeIter<'a> {
586    table: &'a JoinHashTable,
587    hash: u64,
588    current: i32,
589}
590
591/// Borrow-free position in one hash bucket chain.
592#[derive(Debug, Clone, Copy, Default)]
593pub struct ProbeCursor {
594    hash: u64,
595    current: i32,
596}
597
598impl Iterator for ProbeIter<'_> {
599    type Item = usize;
600
601    #[inline]
602    fn next(&mut self) -> Option<usize> {
603        while self.current >= 0 {
604            let entry = &self.table.entries[self.current as usize];
605            self.current = if entry.next == EMPTY {
606                -1
607            } else {
608                entry.next as i32
609            };
610
611            // Only return if hash matches (quick rejection for non-matches)
612            if entry.hash == self.hash {
613                return Some(entry.row_idx as usize);
614            }
615        }
616        None
617    }
618}
619
620// ============================================================================
621// Hashing Utilities
622// ============================================================================
623
624/// Hash values at given indices using a get function.
625///
626/// This is a generic version that works with any type that provides indexed access
627/// to values (Row, RowRef, etc.). Uses the same FxHash algorithm as hash_row_keys.
628#[inline]
629pub fn hash_keys_with<'a, F>(key_indices: &[usize], get_value: F) -> u64
630where
631    F: Fn(usize) -> Option<&'a Value>,
632{
633    let mut hasher = FxHasher::default();
634
635    for &idx in key_indices {
636        if let Some(value) = get_value(idx) {
637            hash_value(&mut hasher, value);
638        } else {
639            // NULL marker - use a sentinel that's unlikely to collide
640            0xDEADBEEF_u64.hash(&mut hasher);
641        }
642    }
643
644    hasher.finish()
645}
646
647/// Hash row key columns into a single u64.
648///
649/// Uses FxHasher which is optimized for trusted keys in embedded database context.
650/// This is the same algorithm used in utils.rs but kept here to avoid circular deps.
651#[inline]
652pub fn hash_row_keys(row: &Row, key_indices: &[usize]) -> u64 {
653    let mut hasher = FxHasher::default();
654
655    for &idx in key_indices {
656        if let Some(value) = row.get(idx) {
657            hash_value(&mut hasher, value);
658        } else {
659            // NULL marker - use a sentinel that's unlikely to collide
660            0xDEADBEEF_u64.hash(&mut hasher);
661        }
662    }
663
664    hasher.finish()
665}
666
667/// Hash a single value into a hasher.
668#[inline]
669fn hash_value<H: Hasher>(hasher: &mut H, value: &Value) {
670    value.hash(hasher);
671}
672
673/// Verify that two rows have equal key values.
674///
675/// Used after hash matching to confirm actual equality (handling hash collisions).
676#[inline]
677pub fn verify_key_equality(row1: &Row, row2: &Row, indices1: &[usize], indices2: &[usize]) -> bool {
678    debug_assert_eq!(indices1.len(), indices2.len());
679
680    for (&idx1, &idx2) in indices1.iter().zip(indices2.iter()) {
681        let (Some(value1), Some(value2)) = (row1.get(idx1), row2.get(idx2)) else {
682            return false;
683        };
684
685        if value1.is_null() || value2.is_null() || value1 != value2 {
686            return false;
687        }
688    }
689
690    true
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696
697    #[test]
698    fn hash_entry_retains_only_hash_and_compact_row_reference() {
699        assert_eq!(std::mem::size_of::<HashEntry>(), 16);
700    }
701
702    #[test]
703    fn immutable_hash_state_reuses_only_exact_batch_and_key_layout() {
704        let rows = CompactArc::new(vec![make_row(vec![1, 10]), make_row(vec![2, 20])]);
705        let state = JoinHashState::build(CompactArc::clone(&rows), &[0]);
706        let reused = state.clone();
707
708        assert!(state.matches(&rows, &[0]));
709        assert!(!state.matches(&rows, &[1]));
710        assert!(!state.matches(&CompactArc::new((*rows).clone()), &[0]));
711        assert!(state.shares_allocations_with(&reused));
712    }
713
714    #[test]
715    fn hash_state_admission_counts_buckets_and_entries_before_allocation() {
716        let retained = JoinHashTable::estimated_retained_bytes(100).unwrap();
717        assert_eq!(retained, 256 * std::mem::size_of::<i32>() + 100 * 16);
718        assert!(JoinHashTable::fits_retained_budget(100, retained));
719        assert!(!JoinHashTable::fits_retained_budget(100, retained - 1));
720        assert!(JoinHashTable::estimated_retained_bytes(u32::MAX as usize + 1).is_none());
721    }
722
723    use radixdb_core::DataType;
724
725    fn make_row(values: Vec<i64>) -> Row {
726        Row::from_values(values.into_iter().map(Value::integer).collect())
727    }
728
729    #[test]
730    fn test_basic_insert_and_probe() {
731        let mut table = JoinHashTable::with_capacity(4);
732
733        // Insert some entries
734        table.insert(100, 0);
735        table.insert(200, 1);
736        table.insert(100, 2); // Same hash as first entry
737        table.insert(300, 3);
738
739        assert_eq!(table.len(), 4);
740
741        // Probe for hash 100 should find entries 0 and 2
742        let matches: Vec<_> = table.probe(100).collect();
743        assert_eq!(matches.len(), 2);
744        assert!(matches.contains(&0));
745        assert!(matches.contains(&2));
746
747        // Probe for hash 200 should find entry 1
748        let matches: Vec<_> = table.probe(200).collect();
749        assert_eq!(matches, vec![1]);
750
751        // Probe for non-existent hash should find nothing
752        let matches: Vec<_> = table.probe(999).collect();
753        assert!(matches.is_empty());
754    }
755
756    #[test]
757    fn test_build_from_rows() {
758        let rows = vec![
759            make_row(vec![1, 10]),
760            make_row(vec![2, 20]),
761            make_row(vec![1, 30]), // Same key as first row
762            make_row(vec![3, 40]),
763        ];
764
765        let key_indices = vec![0]; // Key on first column
766        let table = JoinHashTable::build(&rows, &key_indices);
767
768        assert_eq!(table.len(), 4);
769
770        // Probe for key=1
771        let hash = hash_row_keys(&rows[0], &key_indices);
772        let matches: Vec<_> = table.probe(hash).collect();
773        assert_eq!(matches.len(), 2);
774    }
775
776    #[cfg(feature = "bench-harness")]
777    #[test]
778    fn runtime_profile_observes_bulk_hash_build_under_parallel_tests() {
779        // Runtime instrumentation is process-wide by contract. Other executor
780        // tests may build hash tables concurrently, so this test must prove
781        // the contribution of its own build without claiming exclusive
782        // ownership of the global counters.
783        let before = radixdb_storage::instrumentation::snapshot().runtime_profile;
784        let rows = vec![make_row(vec![1]), make_row(vec![2]), make_row(vec![3])];
785
786        let _table = JoinHashTable::build(&rows, &[0]);
787
788        let after = radixdb_storage::instrumentation::snapshot().runtime_profile;
789        let delta = after.delta(before);
790        assert!(delta.hash_build_calls >= 1);
791        assert!(delta.hash_build_rows >= 3);
792        assert!(delta.hash_build_nanos > 0);
793    }
794
795    #[test]
796    fn test_empty_table() {
797        let table = JoinHashTable::empty();
798        assert!(table.is_empty());
799        assert_eq!(table.len(), 0);
800
801        let matches: Vec<_> = table.probe(100).collect();
802        assert!(matches.is_empty());
803    }
804
805    #[test]
806    fn test_load_factor() {
807        let mut table = JoinHashTable::with_capacity(100);
808
809        for i in 0..100 {
810            table.insert(i as u64, i as u32);
811        }
812
813        // With 100 entries, load factor depends on bucket count
814        // We target ~75% load factor, but it can vary
815        let load = table.load_factor();
816        assert!(
817            load > 0.3 && load <= 1.0,
818            "Load factor {} out of expected range",
819            load
820        );
821        // Verify we have all entries
822        assert_eq!(table.len(), 100);
823    }
824
825    #[test]
826    fn test_verify_key_equality() {
827        let row1 = Row::from_values(vec![Value::integer(1), Value::text("hello")]);
828        let row2 = Row::from_values(vec![Value::integer(1), Value::text("hello")]);
829        let row3 = Row::from_values(vec![Value::integer(2), Value::text("hello")]);
830
831        assert!(verify_key_equality(&row1, &row2, &[0, 1], &[0, 1]));
832        assert!(!verify_key_equality(&row1, &row3, &[0, 1], &[0, 1]));
833    }
834
835    #[test]
836    fn test_hash_row_keys() {
837        let row1 = make_row(vec![1, 2, 3]);
838        let row2 = make_row(vec![1, 2, 3]);
839        let row3 = make_row(vec![1, 2, 4]);
840
841        let indices = vec![0, 1];
842
843        // Same keys should produce same hash
844        assert_eq!(
845            hash_row_keys(&row1, &indices),
846            hash_row_keys(&row2, &indices)
847        );
848
849        // Different values in non-key column shouldn't affect hash
850        let row4 = make_row(vec![1, 2, 999]);
851        assert_eq!(
852            hash_row_keys(&row1, &indices),
853            hash_row_keys(&row4, &indices)
854        );
855
856        // Different keys should (usually) produce different hash
857        // This isn't guaranteed but is very likely
858        assert_ne!(hash_row_keys(&row1, &[0, 2]), hash_row_keys(&row3, &[0, 2]));
859    }
860
861    fn assert_join_key_equal(left: Value, right: Value) {
862        let left_row = Row::from_values(vec![left]);
863        let right_row = Row::from_values(vec![right]);
864
865        assert_eq!(
866            hash_row_keys(&left_row, &[0]),
867            hash_row_keys(&right_row, &[0])
868        );
869        assert!(verify_key_equality(&left_row, &right_row, &[0], &[0]));
870    }
871
872    #[test]
873    fn test_canonical_numeric_join_keys() {
874        const TWO_POW_53: i64 = 9_007_199_254_740_992;
875
876        assert_join_key_equal(Value::Integer(TWO_POW_53), Value::Float(TWO_POW_53 as f64));
877        assert_join_key_equal(
878            Value::Integer(TWO_POW_53 + 2),
879            Value::Float((TWO_POW_53 + 2) as f64),
880        );
881        assert_join_key_equal(Value::Float(0.0), Value::Float(-0.0));
882        assert_join_key_equal(Value::Integer(0), Value::Float(-0.0));
883    }
884
885    #[test]
886    fn test_rounded_integer_neighbor_does_not_join() {
887        const TWO_POW_53: i64 = 9_007_199_254_740_992;
888
889        let build_rows = [Row::from_values(vec![Value::Integer(TWO_POW_53 + 1)])];
890        let probe_row = Row::from_values(vec![Value::Float((TWO_POW_53 + 1) as f64)]);
891        let probe_hash = hash_row_keys(&probe_row, &[0]);
892        let mut table = JoinHashTable::with_capacity(1);
893        table.insert(probe_hash, 0); // Force the collision verifier to run.
894
895        assert!(!verify_key_equality(&probe_row, &build_rows[0], &[0], &[0]));
896        let verified_matches = table
897            .probe(probe_hash)
898            .filter(|&build_idx| {
899                verify_key_equality(&probe_row, &build_rows[build_idx], &[0], &[0])
900            })
901            .count();
902        assert_eq!(verified_matches, 0);
903    }
904
905    #[test]
906    fn test_nan_payloads_share_join_key_contract() {
907        let nan1 = f64::from_bits(0x7ff8_0000_0000_0001);
908        let nan2 = f64::from_bits(0x7ff8_0000_0000_0002);
909
910        assert_join_key_equal(Value::Float(nan1), Value::Float(nan2));
911    }
912
913    #[test]
914    fn test_null_keys_never_join() {
915        let left = Row::from_values(vec![Value::Null(DataType::Integer)]);
916        let right = Row::from_values(vec![Value::Null(DataType::Float)]);
917
918        assert_eq!(hash_row_keys(&left, &[0]), hash_row_keys(&right, &[0]));
919        assert!(!verify_key_equality(&left, &right, &[0], &[0]));
920    }
921
922    #[test]
923    fn test_composite_keys_use_canonical_value_contract() {
924        const TWO_POW_53: i64 = 9_007_199_254_740_992;
925
926        let integer_key =
927            Row::from_values(vec![Value::Integer(TWO_POW_53), Value::Text("same".into())]);
928        let float_key = Row::from_values(vec![
929            Value::Float(TWO_POW_53 as f64),
930            Value::Text("same".into()),
931        ]);
932        let different_tail = Row::from_values(vec![
933            Value::Float(TWO_POW_53 as f64),
934            Value::Text("different".into()),
935        ]);
936        let null_tail = Row::from_values(vec![
937            Value::Float(TWO_POW_53 as f64),
938            Value::Null(DataType::Text),
939        ]);
940
941        assert_eq!(
942            hash_row_keys(&integer_key, &[0, 1]),
943            hash_row_keys(&float_key, &[0, 1])
944        );
945        assert!(verify_key_equality(
946            &integer_key,
947            &float_key,
948            &[0, 1],
949            &[0, 1]
950        ));
951        assert!(!verify_key_equality(
952            &integer_key,
953            &different_tail,
954            &[0, 1],
955            &[0, 1]
956        ));
957        assert!(!verify_key_equality(
958            &integer_key,
959            &null_tail,
960            &[0, 1],
961            &[0, 1]
962        ));
963    }
964
965    #[test]
966    fn test_row_and_indexed_get_hash_paths_are_identical() {
967        let row = Row::from_values(vec![
968            Value::Integer(9_007_199_254_740_992),
969            Value::Float(-0.0),
970            Value::Text("key".into()),
971        ]);
972        let indices = [0, 1, 2];
973
974        assert_eq!(
975            hash_row_keys(&row, &indices),
976            hash_keys_with(&indices, |idx| row.get(idx))
977        );
978    }
979
980    #[test]
981    fn test_chain_collision() {
982        // Force collisions by using a small bucket count
983        let mut table = JoinHashTable {
984            bucket_heads: vec![-1; 4], // Only 4 buckets
985            entries: Vec::new(),
986            bucket_mask: 3,
987            len: 0,
988        };
989
990        // All these will go to bucket 0 (hash & 3 == 0)
991        table.insert(0, 0);
992        table.insert(4, 1);
993        table.insert(8, 2);
994        table.insert(12, 3);
995
996        // Probe for each should find exactly one match
997        assert_eq!(table.probe(0).count(), 1);
998        assert_eq!(table.probe(4).count(), 1);
999        assert_eq!(table.probe(8).count(), 1);
1000        assert_eq!(table.probe(12).count(), 1);
1001    }
1002}