Skip to main content

uqa_execution/
join.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Physical relational join operators.
8
9mod nested_loop;
10
11use std::collections::{BTreeMap, HashMap};
12use std::fs::{File, OpenOptions};
13use std::hash::BuildHasher;
14use std::io::{ErrorKind, Read, Seek, SeekFrom, Write};
15use std::path::Path;
16
17use smallvec::SmallVec;
18use tempfile::{Builder as TempBuilder, NamedTempFile, TempDir};
19use uqa_core::Value;
20use uqa_sql::ast::JoinKind;
21use uqa_sql::expr::truthy;
22use uqa_sql::ResultRow;
23
24use crate::distinct::{encode_non_null_key, hash_canonical_row, EncodedKey};
25use crate::{
26    Batch, ExecError, ExecResult, IndexedSpill, PhysicalOperator, PhysicalRow, ProjectedPredicate,
27    RowSchema, ScalarExpr, SharedExpressionEvaluator, SpillBuffer,
28};
29
30pub use nested_loop::NestedLoopJoin;
31
32const DEFAULT_JOIN_WORK_MEM_BYTES: usize = 64 * 1024 * 1024;
33const HASH_BUCKETS: u64 = 64;
34
35fn output_schema(
36    left: &RowSchema,
37    right: &RowSchema,
38    left_nulls: &ResultRow,
39    right_nulls: &ResultRow,
40) -> RowSchema {
41    RowSchema::join(
42        left,
43        right,
44        left_nulls.keys().chain(right_nulls.keys()).cloned(),
45    )
46}
47
48fn push_output_row(
49    output: &mut SpillBuffer,
50    pending: &mut Vec<PhysicalRow>,
51    schema: &RowSchema,
52    row: PhysicalRow,
53) -> ExecResult<()> {
54    pending.push(row);
55    if pending.len() == crate::batch::DEFAULT_BATCH_SIZE {
56        output.push(Batch::from_physical_rows(
57            schema.clone(),
58            std::mem::take(pending),
59        ))?;
60        pending.reserve(crate::batch::DEFAULT_BATCH_SIZE);
61    }
62    Ok(())
63}
64
65fn join_io_error(operation: &str, error: impl std::fmt::Display) -> ExecError {
66    ExecError::Other(format!("join spill {operation}: {error}"))
67}
68
69/// Positional build-side row storage that only touches disk after its encoded
70/// memory budget is exhausted. Once spilled, every row lives in the indexed
71/// disk store so positional indices remain stable across the transition.
72struct HybridRowStore {
73    schema: RowSchema,
74    memory: Vec<PhysicalRow>,
75    rows: u64,
76    memory_bytes: usize,
77    budget_bytes: usize,
78    disk: Option<IndexedSpill>,
79}
80
81impl HybridRowStore {
82    fn new(schema: RowSchema, budget_bytes: usize) -> Self {
83        Self {
84            schema,
85            memory: Vec::new(),
86            rows: 0,
87            memory_bytes: 0,
88            budget_bytes,
89            disk: None,
90        }
91    }
92
93    fn len(&self) -> u64 {
94        self.disk.as_ref().map_or(self.rows, IndexedSpill::len)
95    }
96
97    fn has_spilled(&self) -> bool {
98        self.disk.is_some()
99    }
100
101    fn memory_row(&self, index: u64) -> Option<&PhysicalRow> {
102        if self.disk.is_some() {
103            return None;
104        }
105        usize::try_from(index)
106            .ok()
107            .and_then(|index| self.memory.get(index))
108    }
109
110    fn push(&mut self, row: PhysicalRow) -> ExecResult<()> {
111        if let Some(disk) = self.disk.as_mut() {
112            return disk.push(&row);
113        }
114
115        let row_bytes = IndexedSpill::encoded_row_size(&self.schema, &row)?;
116        let next_rows = self
117            .rows
118            .checked_add(1)
119            .ok_or_else(|| ExecError::Other("join build row count overflow".into()))?;
120        let fits = self
121            .memory_bytes
122            .checked_add(row_bytes)
123            .is_some_and(|bytes| bytes <= self.budget_bytes);
124        if fits {
125            self.memory.push(row);
126            self.rows = next_rows;
127            self.memory_bytes += row_bytes;
128            return Ok(());
129        }
130
131        // Build the complete disk representation before publishing it. If any
132        // append fails, the original in-memory rows remain available and the
133        // operator aborts without exposing a partial positional store.
134        let mut disk = IndexedSpill::new(self.schema.clone())?;
135        for existing in &self.memory {
136            disk.push(existing)?;
137        }
138        disk.push(&row)?;
139        self.memory.clear();
140        self.rows = disk.len();
141        self.memory_bytes = 0;
142        self.disk = Some(disk);
143        Ok(())
144    }
145
146    fn with_row<T>(
147        &mut self,
148        index: u64,
149        visitor: impl FnOnce(&PhysicalRow) -> ExecResult<T>,
150    ) -> ExecResult<T> {
151        if let Some(disk) = self.disk.as_mut() {
152            let row = disk.get(index)?;
153            return visitor(&row);
154        }
155
156        let index = usize::try_from(index)
157            .map_err(|_| ExecError::Other(format!("join row index {index} exceeds usize")))?;
158        let row = self.memory.get(index).ok_or_else(|| {
159            ExecError::Other(format!(
160                "join row {index} is outside 0..{}",
161                self.memory.len()
162            ))
163        })?;
164        visitor(row)
165    }
166}
167
168/// Allocation-free in-memory index for simple positional equality keys.
169///
170/// Only the canonical hash and build-row position are retained. The key itself
171/// stays in the original [`PhysicalRow`]; hash collisions are resolved by
172/// comparing the mapped source slots. If the index exceeds its budget, its
173/// state is discarded and the caller rebuilds the spill-capable encoded index
174/// from the row store.
175struct DirectHashIndex {
176    buckets: HashMap<u64, SmallVec<[u64; 1]>, ahash::RandomState>,
177    memory_bytes: usize,
178    budget_bytes: usize,
179    overflowed: bool,
180}
181
182impl DirectHashIndex {
183    fn new(budget_bytes: usize) -> Self {
184        Self {
185            buckets: HashMap::with_hasher(ahash::RandomState::new()),
186            memory_bytes: 0,
187            budget_bytes,
188            overflowed: false,
189        }
190    }
191
192    fn hasher(&self) -> &ahash::RandomState {
193        self.buckets.hasher()
194    }
195
196    fn insert(&mut self, hash: u64, row_index: u64) -> ExecResult<()> {
197        if self.overflowed {
198            return Ok(());
199        }
200
201        // Account for the hash, inline first row index, control bytes, and
202        // allocator/table slack. Duplicate-key indices need only one u64.
203        let record_bytes = if self.buckets.contains_key(&hash) {
204            8
205        } else {
206            64
207        };
208        let fits = self
209            .memory_bytes
210            .checked_add(record_bytes)
211            .is_some_and(|bytes| bytes <= self.budget_bytes);
212        if !fits {
213            self.buckets.clear();
214            self.memory_bytes = 0;
215            self.overflowed = true;
216            return Ok(());
217        }
218
219        self.buckets.entry(hash).or_default().push(row_index);
220        self.memory_bytes += record_bytes;
221        Ok(())
222    }
223
224    fn is_available(&self) -> bool {
225        !self.overflowed
226    }
227
228    fn candidates(&self, hash: u64) -> &[u64] {
229        self.buckets.get(&hash).map_or(&[], SmallVec::as_slice)
230    }
231
232    fn keys_are_unique(
233        &self,
234        rows: &HybridRowStore,
235        schema: &RowSchema,
236        positions: &[usize],
237    ) -> bool {
238        self.is_available()
239            && self.buckets.values().all(|bucket| {
240                bucket.iter().enumerate().all(|(offset, left_index)| {
241                    bucket[offset + 1..].iter().all(|right_index| {
242                        let Some(left) = rows.memory_row(*left_index) else {
243                            return false;
244                        };
245                        let Some(right) = rows.memory_row(*right_index) else {
246                            return false;
247                        };
248                        !positional_keys_equal(schema, left, positions, schema, right, positions)
249                    })
250                })
251            })
252    }
253}
254
255fn positional_key_hash<S: BuildHasher>(
256    build_hasher: &S,
257    schema: &RowSchema,
258    row: &PhysicalRow,
259    positions: &[usize],
260) -> ExecResult<Option<u64>> {
261    let view = schema.view(row);
262    if positions.iter().any(|position| {
263        view.value_at(*position)
264            .is_none_or(|value| matches!(value, Value::Null))
265    }) {
266        return Ok(None);
267    }
268    hash_canonical_row(
269        build_hasher,
270        positions.iter().map(|position| view.value_at(*position)),
271    )
272    .map(Some)
273}
274
275fn positional_keys_equal(
276    left_schema: &RowSchema,
277    left_row: &PhysicalRow,
278    left_positions: &[usize],
279    right_schema: &RowSchema,
280    right_row: &PhysicalRow,
281    right_positions: &[usize],
282) -> bool {
283    if left_positions.len() != right_positions.len() {
284        return false;
285    }
286    let left = left_schema.view(left_row);
287    let right = right_schema.view(right_row);
288    left_positions
289        .iter()
290        .zip(right_positions)
291        .all(|(left_position, right_position)| {
292            let Some(left) = left.value_at(*left_position) else {
293                return false;
294            };
295            let Some(right) = right.value_at(*right_position) else {
296                return false;
297            };
298            !matches!(left, Value::Null) && !matches!(right, Value::Null) && left == right
299        })
300}
301
302fn direct_unique_match(
303    index: &DirectHashIndex,
304    build_rows: &HybridRowStore,
305    build_positions: &[usize],
306    probe_schema: &RowSchema,
307    probe_row: &PhysicalRow,
308    probe_positions: &[usize],
309) -> ExecResult<Option<u64>> {
310    let Some(hash) = positional_key_hash(index.hasher(), probe_schema, probe_row, probe_positions)?
311    else {
312        return Ok(None);
313    };
314    Ok(index.candidates(hash).iter().copied().find(|row_index| {
315        build_rows.memory_row(*row_index).is_some_and(|build_row| {
316            positional_keys_equal(
317                &build_rows.schema,
318                build_row,
319                build_positions,
320                probe_schema,
321                probe_row,
322                probe_positions,
323            )
324        })
325    }))
326}
327
328/// One byte per build-side row, held in a temporary file rather than a
329/// cardinality-sized `Vec<bool>`. Random updates are required for RIGHT/FULL
330/// joins and remain constant-memory.
331struct MatchFlags {
332    file: NamedTempFile,
333    rows: u64,
334}
335
336impl MatchFlags {
337    fn new(rows: u64) -> ExecResult<Self> {
338        let file =
339            NamedTempFile::new().map_err(|error| join_io_error("create match flags", error))?;
340        file.as_file()
341            .set_len(rows)
342            .map_err(|error| join_io_error("size match flags", error))?;
343        Ok(Self { file, rows })
344    }
345
346    fn mark(&mut self, index: u64) -> ExecResult<()> {
347        self.check_index(index)?;
348        self.file
349            .as_file_mut()
350            .seek(SeekFrom::Start(index))
351            .map_err(|error| join_io_error("seek match flags", error))?;
352        self.file
353            .as_file_mut()
354            .write_all(&[1])
355            .map_err(|error| join_io_error("write match flag", error))
356    }
357
358    fn is_marked(&mut self, index: u64) -> ExecResult<bool> {
359        self.check_index(index)?;
360        self.file
361            .as_file_mut()
362            .seek(SeekFrom::Start(index))
363            .map_err(|error| join_io_error("seek match flags", error))?;
364        let mut flag = [0_u8; 1];
365        self.file
366            .as_file_mut()
367            .read_exact(&mut flag)
368            .map_err(|error| join_io_error("read match flag", error))?;
369        Ok(flag[0] != 0)
370    }
371
372    fn check_index(&self, index: u64) -> ExecResult<()> {
373        if index >= self.rows {
374            return Err(ExecError::Other(format!(
375                "join match flag {index} is outside 0..{}",
376                self.rows
377            )));
378        }
379        Ok(())
380    }
381}
382
383/// Exact, work-memory-bounded build-side hash index. It starts in memory and
384/// migrates atomically to bucketed temporary files before the encoded key and
385/// row-index records would exceed its byte budget. Disk probes always compare
386/// the full key, so bucket hash collisions cannot create false join matches.
387struct HybridHashIndex {
388    memory: HashMap<EncodedKey, Vec<u64>, ahash::RandomState>,
389    memory_bytes: usize,
390    budget_bytes: usize,
391    disk: Option<DiskHashIndex>,
392}
393
394#[derive(Clone, Copy)]
395enum MemoryMatchSummary {
396    Absent,
397    Single(u64),
398    Multiple,
399}
400
401impl HybridHashIndex {
402    fn new(budget_bytes: usize) -> Self {
403        Self {
404            // AHash keeps a per-index random seed while avoiding SipHash's
405            // cryptographic-round overhead after the canonical SQL key has
406            // already been encoded byte-for-byte.
407            memory: HashMap::with_hasher(ahash::RandomState::new()),
408            memory_bytes: 0,
409            budget_bytes,
410            disk: None,
411        }
412    }
413
414    fn insert(&mut self, key: EncodedKey, row_index: u64) -> ExecResult<()> {
415        if let Some(disk) = self.disk.as_mut() {
416            return disk.insert(&key, row_index);
417        }
418
419        let record_bytes = key
420            .len()
421            .checked_add(16)
422            .ok_or_else(|| ExecError::Other("join hash-index size overflow".into()))?;
423        let fits = self
424            .memory_bytes
425            .checked_add(record_bytes)
426            .is_some_and(|bytes| bytes <= self.budget_bytes);
427        if fits {
428            self.memory.entry(key).or_default().push(row_index);
429            self.memory_bytes += record_bytes;
430            return Ok(());
431        }
432
433        let mut disk = DiskHashIndex::new(None)?;
434        for (existing_key, indices) in &self.memory {
435            for index in indices {
436                disk.insert(existing_key, *index)?;
437            }
438        }
439        disk.insert(&key, row_index)?;
440        self.memory.clear();
441        self.memory_bytes = 0;
442        self.disk = Some(disk);
443        Ok(())
444    }
445
446    fn for_each_match(
447        &mut self,
448        key: &[u8],
449        visitor: &mut dyn FnMut(u64) -> ExecResult<()>,
450    ) -> ExecResult<bool> {
451        if let Some(disk) = self.disk.as_mut() {
452            return disk.for_each_match(key, visitor);
453        }
454        let Some(indices) = self.memory.get(key) else {
455            return Ok(false);
456        };
457        for index in indices {
458            visitor(*index)?;
459        }
460        Ok(!indices.is_empty())
461    }
462
463    /// Summarize a probe without allocation when the index still resides in
464    /// memory. Disk-backed indexes return `None` and use the streaming probe.
465    fn memory_match_summary(&self, key: &[u8]) -> Option<MemoryMatchSummary> {
466        if self.disk.is_some() {
467            return None;
468        }
469        Some(match self.memory.get(key).map(Vec::as_slice) {
470            None | Some([]) => MemoryMatchSummary::Absent,
471            Some([index]) => MemoryMatchSummary::Single(*index),
472            Some(_) => MemoryMatchSummary::Multiple,
473        })
474    }
475
476    fn has_spilled(&self) -> bool {
477        self.disk.is_some()
478    }
479
480    fn is_memory_unique(&self) -> bool {
481        self.disk.is_none() && self.memory.values().all(|indices| indices.len() == 1)
482    }
483}
484
485/// Bucket records are `[key_len: u64][key bytes][row_index: u64]`.
486struct DiskHashIndex {
487    directory: TempDir,
488    buckets: BTreeMap<u8, File>,
489}
490
491impl DiskHashIndex {
492    fn new(parent: Option<&Path>) -> ExecResult<Self> {
493        let mut builder = TempBuilder::new();
494        builder.prefix("uqa-hash-join-");
495        let directory = parent
496            .map_or_else(|| builder.tempdir(), |parent| builder.tempdir_in(parent))
497            .map_err(|error| join_io_error("create hash directory", error))?;
498        Ok(Self {
499            directory,
500            buckets: BTreeMap::new(),
501        })
502    }
503
504    fn insert(&mut self, key: &[u8], row_index: u64) -> ExecResult<()> {
505        let bucket = u8::try_from(stable_hash(key) % HASH_BUCKETS)
506            .map_err(|_| ExecError::Other("join spill bucket exceeds u8".into()))?;
507        if !self.buckets.contains_key(&bucket) {
508            let path = self.directory.path().join(format!("bucket-{bucket:02x}"));
509            let file = OpenOptions::new()
510                .create_new(true)
511                .read(true)
512                .write(true)
513                .open(&path)
514                .map_err(|error| join_io_error("create hash bucket", error))?;
515            self.buckets.insert(bucket, file);
516        }
517        let file = self
518            .buckets
519            .get_mut(&bucket)
520            .ok_or_else(|| ExecError::Other("join hash bucket registration failed".into()))?;
521        let original_len = file
522            .seek(SeekFrom::End(0))
523            .map_err(|error| join_io_error("seek hash bucket", error))?;
524        let key_len = u64::try_from(key.len())
525            .map_err(|_| ExecError::Other("join hash key is too large".into()))?;
526        let write_result = (|| -> std::io::Result<()> {
527            file.write_all(&key_len.to_le_bytes())?;
528            file.write_all(key)?;
529            file.write_all(&row_index.to_le_bytes())?;
530            file.flush()
531        })();
532        if let Err(error) = write_result {
533            if let Err(rollback) = file.set_len(original_len) {
534                return Err(ExecError::Other(format!(
535                    "join spill append hash bucket: {error}; rollback failed: {rollback}"
536                )));
537            }
538            return Err(join_io_error("append hash bucket", error));
539        }
540        Ok(())
541    }
542
543    fn for_each_match(
544        &mut self,
545        key: &[u8],
546        visitor: &mut dyn FnMut(u64) -> ExecResult<()>,
547    ) -> ExecResult<bool> {
548        let bucket = u8::try_from(stable_hash(key) % HASH_BUCKETS)
549            .map_err(|_| ExecError::Other("join spill bucket exceeds u8".into()))?;
550        let Some(file) = self.buckets.get_mut(&bucket) else {
551            return Ok(false);
552        };
553        file.seek(SeekFrom::Start(0))
554            .map_err(|error| join_io_error("rewind hash bucket", error))?;
555        let file_len = file
556            .metadata()
557            .map_err(|error| join_io_error("inspect hash bucket", error))?
558            .len();
559        let mut matched = false;
560        while let Some(key_len) = read_u64(file, "read hash key length")? {
561            let key_start = file
562                .stream_position()
563                .map_err(|error| join_io_error("locate hash key", error))?;
564            let key_end = key_start
565                .checked_add(key_len)
566                .ok_or_else(|| ExecError::Other("join hash key offset overflow".into()))?;
567            let record_end = key_end
568                .checked_add(8)
569                .ok_or_else(|| ExecError::Other("join hash record offset overflow".into()))?;
570            if record_end > file_len {
571                return Err(ExecError::Other(format!(
572                    "join hash key length {key_len} exceeds remaining bucket record bytes"
573                )));
574            }
575            let key_matches = compare_hash_key(file, key_start, key_end, key_len, key)?;
576            let row_index = read_u64(file, "read hash row index")?
577                .ok_or_else(|| ExecError::Other("truncated join hash row index".into()))?;
578            if key_matches {
579                visitor(row_index)?;
580                matched = true;
581            }
582        }
583        Ok(matched)
584    }
585}
586
587fn compare_hash_key(
588    file: &mut File,
589    key_start: u64,
590    key_end: u64,
591    stored_len: u64,
592    expected: &[u8],
593) -> ExecResult<bool> {
594    let expected_len = u64::try_from(expected.len())
595        .map_err(|_| ExecError::Other("join probe key length is invalid".into()))?;
596    if stored_len != expected_len {
597        file.seek(SeekFrom::Start(key_end))
598            .map_err(|error| join_io_error("skip non-matching hash key", error))?;
599        return Ok(false);
600    }
601
602    file.seek(SeekFrom::Start(key_start))
603        .map_err(|error| join_io_error("seek hash key", error))?;
604    let mut buffer = [0_u8; 8 * 1024];
605    let mut compared = 0_usize;
606    let mut matches = true;
607    while compared < expected.len() {
608        let take = (expected.len() - compared).min(buffer.len());
609        file.read_exact(&mut buffer[..take])
610            .map_err(|error| join_io_error("read hash key", error))?;
611        matches &= buffer[..take] == expected[compared..compared + take];
612        compared += take;
613    }
614    Ok(matches)
615}
616
617fn read_u64(file: &mut File, operation: &str) -> ExecResult<Option<u64>> {
618    let mut encoded = [0_u8; 8];
619    match file.read(&mut encoded[..1]) {
620        Ok(0) => return Ok(None),
621        Ok(1) => {}
622        Ok(count) => {
623            return Err(ExecError::Other(format!(
624                "invalid join spill read count: requested 1 byte, received {count}"
625            )));
626        }
627        Err(error) => return Err(join_io_error(operation, error)),
628    }
629    file.read_exact(&mut encoded[1..]).map_err(|error| {
630        if error.kind() == ErrorKind::UnexpectedEof {
631            ExecError::Other(format!(
632                "truncated join spill record while attempting to {operation}"
633            ))
634        } else {
635            join_io_error(operation, error)
636        }
637    })?;
638    Ok(Some(u64::from_le_bytes(encoded)))
639}
640
641fn stable_hash(bytes: &[u8]) -> u64 {
642    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
643    for byte in bytes {
644        hash ^= u64::from(*byte);
645        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
646    }
647    hash
648}
649
650/// Equality join backed by a canonical SQL-key hash table. SQL NULL keys never
651/// match. An optional residual predicate is evaluated on hash candidates
652/// before either side is marked matched, preserving mixed equijoin/non-equality
653/// `ON` semantics for every outer-join shape.
654fn simple_key_positions(schema: &RowSchema, expressions: &[ScalarExpr]) -> Option<Vec<usize>> {
655    expressions
656        .iter()
657        .map(|expression| match expression {
658            ScalarExpr::Column(column) => schema.position(column),
659            ScalarExpr::QualifiedColumn { qualifier, column } => {
660                schema.qualified_position(qualifier, column)
661            }
662            _ => None,
663        })
664        .collect()
665}
666
667pub struct HashJoin<'a> {
668    left: Box<dyn PhysicalOperator + 'a>,
669    right: Box<dyn PhysicalOperator + 'a>,
670    kind: JoinKind,
671    left_keys: Vec<ScalarExpr>,
672    right_keys: Vec<ScalarExpr>,
673    left_key_positions: Option<Vec<usize>>,
674    right_key_positions: Option<Vec<usize>>,
675    predicate: Option<ScalarExpr>,
676    prepared_predicate: Option<ProjectedPredicate>,
677    evaluator: SharedExpressionEvaluator<'a>,
678    left_nulls: PhysicalRow,
679    right_nulls: PhysicalRow,
680    schema: RowSchema,
681    estimated_cardinality: Option<u64>,
682    build_left: bool,
683    work_mem_bytes: usize,
684    output: Option<crate::spill::SpillDrain>,
685    streaming_unique: Option<UniqueHashJoinState>,
686    output_spilled: SpillState,
687    right_input_spilled: SpillState,
688    hash_index_spilled: SpillState,
689}
690
691#[derive(Clone, Copy, Default, Eq, PartialEq)]
692enum SpillState {
693    #[default]
694    InMemory,
695    Spilled,
696}
697
698impl SpillState {
699    fn is_spilled(self) -> bool {
700        matches!(self, Self::Spilled)
701    }
702}
703
704impl From<bool> for SpillState {
705    fn from(spilled: bool) -> Self {
706        if spilled {
707            Self::Spilled
708        } else {
709            Self::InMemory
710        }
711    }
712}
713
714/// State retained while an in-memory unique-key inner join streams its probe
715/// side. At most one output row can be produced per probe row, so the join can
716/// preserve batch backpressure without a cardinality-sized output buffer.
717struct UniqueHashJoinState {
718    build_rows: HybridRowStore,
719    hash_index: UniqueHashIndex,
720    build_left: bool,
721}
722
723enum UniqueHashIndex {
724    /// Simple column keys keep only hashes and row positions. Candidate keys
725    /// are verified against the original build row slots.
726    Direct(DirectHashIndex),
727    /// Evaluated expressions and spill fallback retain canonical byte keys.
728    Encoded(HybridHashIndex),
729}
730
731impl<'a> HashJoin<'a> {
732    #[allow(clippy::too_many_arguments)]
733    pub fn new(
734        left: Box<dyn PhysicalOperator + 'a>,
735        right: Box<dyn PhysicalOperator + 'a>,
736        kind: JoinKind,
737        left_keys: Vec<ScalarExpr>,
738        right_keys: Vec<ScalarExpr>,
739        evaluator: SharedExpressionEvaluator<'a>,
740        left_nulls: ResultRow,
741        right_nulls: ResultRow,
742    ) -> Self {
743        Self::new_with_work_mem(
744            left,
745            right,
746            kind,
747            left_keys,
748            right_keys,
749            evaluator,
750            left_nulls,
751            right_nulls,
752            DEFAULT_JOIN_WORK_MEM_BYTES,
753        )
754    }
755
756    #[allow(clippy::too_many_arguments)]
757    pub fn new_with_work_mem(
758        left: Box<dyn PhysicalOperator + 'a>,
759        right: Box<dyn PhysicalOperator + 'a>,
760        kind: JoinKind,
761        left_keys: Vec<ScalarExpr>,
762        right_keys: Vec<ScalarExpr>,
763        evaluator: SharedExpressionEvaluator<'a>,
764        left_nulls: ResultRow,
765        right_nulls: ResultRow,
766        work_mem_bytes: usize,
767    ) -> Self {
768        Self::new_with_work_mem_and_predicate(
769            left,
770            right,
771            kind,
772            left_keys,
773            right_keys,
774            None,
775            evaluator,
776            left_nulls,
777            right_nulls,
778            work_mem_bytes,
779        )
780    }
781
782    #[allow(clippy::too_many_arguments)]
783    pub fn new_with_work_mem_and_predicate(
784        left: Box<dyn PhysicalOperator + 'a>,
785        right: Box<dyn PhysicalOperator + 'a>,
786        kind: JoinKind,
787        left_keys: Vec<ScalarExpr>,
788        right_keys: Vec<ScalarExpr>,
789        predicate: Option<ScalarExpr>,
790        evaluator: SharedExpressionEvaluator<'a>,
791        left_nulls: ResultRow,
792        right_nulls: ResultRow,
793        work_mem_bytes: usize,
794    ) -> Self {
795        let left_key_positions = simple_key_positions(left.row_schema(), &left_keys);
796        let right_key_positions = simple_key_positions(right.row_schema(), &right_keys);
797        let schema = output_schema(
798            left.row_schema(),
799            right.row_schema(),
800            &left_nulls,
801            &right_nulls,
802        );
803        let left_nulls = PhysicalRow::nulls(left.row_schema().physical_width());
804        let right_nulls = PhysicalRow::nulls(right.row_schema().physical_width());
805        let left_cardinality = left.estimated_cardinality();
806        let right_cardinality = right.estimated_cardinality();
807        let build_left = matches!(kind, JoinKind::Inner)
808            && left_cardinality
809                .zip(right_cardinality)
810                .is_some_and(|(left, right)| left < right);
811        let estimated_cardinality = left_cardinality
812            .zip(right_cardinality)
813            .map(|(left, right)| match kind {
814                JoinKind::Inner => left.max(right),
815                JoinKind::Left => left,
816                JoinKind::Right => right,
817                JoinKind::Full => left.saturating_add(right),
818                JoinKind::Cross => left.saturating_mul(right),
819            });
820        let prepared_predicate = predicate.as_ref().and_then(|predicate| {
821            ProjectedPredicate::compile_with_schema(predicate, &schema, &[])
822                .ok()
823                .flatten()
824        });
825        Self {
826            left,
827            right,
828            kind,
829            left_keys,
830            right_keys,
831            left_key_positions,
832            right_key_positions,
833            predicate,
834            prepared_predicate,
835            evaluator,
836            left_nulls,
837            right_nulls,
838            schema,
839            estimated_cardinality,
840            build_left,
841            work_mem_bytes,
842            output: None,
843            streaming_unique: None,
844            output_spilled: SpillState::InMemory,
845            right_input_spilled: SpillState::InMemory,
846            hash_index_spilled: SpillState::InMemory,
847        }
848    }
849
850    /// Construct a hash join while preparing supported residual predicates
851    /// against its composite output schema. Parameters and constant LIKE
852    /// patterns are folded exactly once before any candidate row is probed.
853    #[allow(clippy::too_many_arguments)]
854    pub fn try_new_with_work_mem_and_predicate(
855        left: Box<dyn PhysicalOperator + 'a>,
856        right: Box<dyn PhysicalOperator + 'a>,
857        kind: JoinKind,
858        left_keys: Vec<ScalarExpr>,
859        right_keys: Vec<ScalarExpr>,
860        predicate: Option<ScalarExpr>,
861        evaluator: SharedExpressionEvaluator<'a>,
862        left_nulls: ResultRow,
863        right_nulls: ResultRow,
864        work_mem_bytes: usize,
865        params: &[uqa_sql::SQLParam],
866    ) -> ExecResult<Self> {
867        let mut join = Self::new_with_work_mem_and_predicate(
868            left,
869            right,
870            kind,
871            left_keys,
872            right_keys,
873            predicate,
874            evaluator,
875            left_nulls,
876            right_nulls,
877            work_mem_bytes,
878        );
879        join.prepared_predicate = join
880            .predicate
881            .as_ref()
882            .map(|predicate| {
883                ProjectedPredicate::compile_with_schema(predicate, &join.schema, params)
884            })
885            .transpose()?
886            .flatten();
887        Ok(join)
888    }
889
890    pub fn output_has_spilled(&self) -> bool {
891        self.output_spilled.is_spilled()
892    }
893
894    pub fn right_input_has_spilled(&self) -> bool {
895        self.right_input_spilled.is_spilled()
896    }
897
898    pub fn hash_index_has_spilled(&self) -> bool {
899        self.hash_index_spilled.is_spilled()
900    }
901
902    pub fn builds_left_input(&self) -> bool {
903        self.build_left
904    }
905
906    fn rebuild_encoded_index(
907        &self,
908        rows: &mut HybridRowStore,
909        expressions: &[ScalarExpr],
910        positions: &[usize],
911        budget_bytes: usize,
912    ) -> ExecResult<HybridHashIndex> {
913        let schema = rows.schema.clone();
914        let mut index = HybridHashIndex::new(budget_bytes);
915        for row_index in 0..rows.len() {
916            let key = rows.with_row(row_index, |row| {
917                self.key(expressions, Some(positions), row, &schema)
918            })?;
919            if let Some(key) = key {
920                index.insert(key, row_index)?;
921            }
922        }
923        Ok(index)
924    }
925
926    fn open_build_left(&mut self, state_budget: usize, output_budget: usize) -> ExecResult<()> {
927        debug_assert!(matches!(self.kind, JoinKind::Inner));
928        let left_budget = state_budget / 2;
929        let hash_budget = state_budget.saturating_sub(left_budget);
930        let left_schema = self.left.row_schema().clone();
931        let mut left = HybridRowStore::new(left_schema, left_budget);
932        let direct_positions = self
933            .predicate
934            .is_none()
935            .then_some(())
936            .and(self.left_key_positions.as_deref())
937            .zip(self.right_key_positions.as_deref());
938        let mut direct_index = direct_positions.map(|_| DirectHashIndex::new(hash_budget));
939        let mut encoded_index = direct_index
940            .is_none()
941            .then(|| HybridHashIndex::new(hash_budget));
942        self.left.open()?;
943        while let Some(batch) = self.left.next()? {
944            for row in batch.rows {
945                let index = left.len();
946                if let (Some(direct), Some((positions, _))) =
947                    (direct_index.as_mut(), direct_positions)
948                {
949                    if let Some(hash) =
950                        positional_key_hash(direct.hasher(), &batch.schema, &row, positions)?
951                    {
952                        direct.insert(hash, index)?;
953                    }
954                } else if let Some(key) = self.key(
955                    &self.left_keys,
956                    self.left_key_positions.as_deref(),
957                    &row,
958                    &batch.schema,
959                )? {
960                    encoded_index
961                        .as_mut()
962                        .ok_or_else(|| ExecError::Other("join hash index is missing".into()))?
963                        .insert(key, index)?;
964                }
965                left.push(row)?;
966            }
967        }
968        self.right_input_spilled = SpillState::InMemory;
969
970        let mut output = SpillBuffer::new(output_budget);
971        if left.len() == 0 {
972            self.output = Some(output.drain()?);
973            return Ok(());
974        }
975
976        let direct_is_unique = direct_index.as_ref().is_some_and(|direct| {
977            direct_positions.is_some_and(|(positions, _)| {
978                !left.has_spilled() && direct.keys_are_unique(&left, &left.schema, positions)
979            })
980        });
981        if direct_is_unique {
982            self.right.open()?;
983            self.streaming_unique = Some(UniqueHashJoinState {
984                build_rows: left,
985                hash_index: UniqueHashIndex::Direct(
986                    direct_index
987                        .take()
988                        .ok_or_else(|| ExecError::Other("direct join index is missing".into()))?,
989                ),
990                build_left: true,
991            });
992            return Ok(());
993        }
994
995        let mut left_by_key = match encoded_index {
996            Some(index) => index,
997            None => {
998                let (positions, _) = direct_positions
999                    .ok_or_else(|| ExecError::Other("direct join positions are missing".into()))?;
1000                self.rebuild_encoded_index(&mut left, &self.left_keys, positions, hash_budget)?
1001            }
1002        };
1003        self.hash_index_spilled = left_by_key.has_spilled().into();
1004        if self.predicate.is_none() && !left.has_spilled() && left_by_key.is_memory_unique() {
1005            self.right.open()?;
1006            self.streaming_unique = Some(UniqueHashJoinState {
1007                build_rows: left,
1008                hash_index: UniqueHashIndex::Encoded(left_by_key),
1009                build_left: true,
1010            });
1011            return Ok(());
1012        }
1013        let mut pending = Vec::with_capacity(crate::batch::DEFAULT_BATCH_SIZE);
1014
1015        self.right.open()?;
1016        while let Some(batch) = self.right.next()? {
1017            for right_row in batch.rows {
1018                let Some(key) = self.key(
1019                    &self.right_keys,
1020                    self.right_key_positions.as_deref(),
1021                    &right_row,
1022                    &batch.schema,
1023                )?
1024                else {
1025                    continue;
1026                };
1027                if self.predicate.is_none() {
1028                    match left_by_key.memory_match_summary(&key) {
1029                        Some(MemoryMatchSummary::Absent) => continue,
1030                        Some(MemoryMatchSummary::Single(index)) => {
1031                            let merged = left.with_row(index, |left_row| {
1032                                Ok(PhysicalRow::concat_right_owned(left_row, right_row))
1033                            })?;
1034                            push_output_row(&mut output, &mut pending, &self.schema, merged)?;
1035                            continue;
1036                        }
1037                        Some(MemoryMatchSummary::Multiple) | None => {}
1038                    }
1039                }
1040                left_by_key.for_each_match(&key, &mut |index| {
1041                    left.with_row(index, |left_row| {
1042                        let merged = PhysicalRow::concat(left_row, &right_row);
1043                        if self.matches(&merged)? {
1044                            push_output_row(&mut output, &mut pending, &self.schema, merged)?;
1045                        }
1046                        Ok(())
1047                    })
1048                })?;
1049            }
1050        }
1051        if !pending.is_empty() {
1052            output.push(Batch::from_physical_rows(self.schema.clone(), pending))?;
1053        }
1054        self.output_spilled = output.has_spilled().into();
1055        self.output = Some(output.drain()?);
1056        Ok(())
1057    }
1058
1059    fn key(
1060        &self,
1061        expressions: &[ScalarExpr],
1062        positions: Option<&[usize]>,
1063        row: &PhysicalRow,
1064        schema: &RowSchema,
1065    ) -> ExecResult<Option<EncodedKey>> {
1066        if let Some(positions) = positions {
1067            let view = schema.view(row);
1068            return encode_non_null_key(positions.iter().map(|position| view.value_at(*position)));
1069        }
1070        let mut values = SmallVec::<[Value; 4]>::with_capacity(expressions.len());
1071        for expression in expressions {
1072            let value = self.evaluator.evaluate_physical(expression, schema, row)?;
1073            if matches!(value, Value::Null) {
1074                return Ok(None);
1075            }
1076            values.push(value);
1077        }
1078        encode_non_null_key(values.iter().map(Some))
1079    }
1080
1081    fn matches(&self, row: &PhysicalRow) -> ExecResult<bool> {
1082        if let Some(predicate) = self.prepared_predicate.as_ref() {
1083            return Ok(predicate.keep_row(&self.schema.view(row))?);
1084        }
1085        self.predicate.as_ref().map_or(Ok(true), |predicate| {
1086            Ok(truthy(&self.evaluator.evaluate_physical(
1087                predicate,
1088                &self.schema,
1089                row,
1090            )?))
1091        })
1092    }
1093
1094    fn next_streaming_unique(
1095        &mut self,
1096        state: &mut UniqueHashJoinState,
1097    ) -> ExecResult<Option<Batch>> {
1098        loop {
1099            let next = if state.build_left {
1100                self.right.next()?
1101            } else {
1102                self.left.next()?
1103            };
1104            let Some(batch) = next else {
1105                return Ok(None);
1106            };
1107            let mut output = Vec::with_capacity(batch.rows.len());
1108            for probe_row in batch.rows {
1109                let index = match &state.hash_index {
1110                    UniqueHashIndex::Direct(index) => {
1111                        let (build_positions, probe_positions) = if state.build_left {
1112                            (
1113                                self.left_key_positions.as_deref(),
1114                                self.right_key_positions.as_deref(),
1115                            )
1116                        } else {
1117                            (
1118                                self.right_key_positions.as_deref(),
1119                                self.left_key_positions.as_deref(),
1120                            )
1121                        };
1122                        let (Some(build_positions), Some(probe_positions)) =
1123                            (build_positions, probe_positions)
1124                        else {
1125                            return Err(ExecError::Other(
1126                                "direct join key positions are missing".into(),
1127                            ));
1128                        };
1129                        direct_unique_match(
1130                            index,
1131                            &state.build_rows,
1132                            build_positions,
1133                            &batch.schema,
1134                            &probe_row,
1135                            probe_positions,
1136                        )?
1137                    }
1138                    UniqueHashIndex::Encoded(index) => {
1139                        let expressions = if state.build_left {
1140                            &self.right_keys
1141                        } else {
1142                            &self.left_keys
1143                        };
1144                        let positions = if state.build_left {
1145                            self.right_key_positions.as_deref()
1146                        } else {
1147                            self.left_key_positions.as_deref()
1148                        };
1149                        let Some(key) =
1150                            self.key(expressions, positions, &probe_row, &batch.schema)?
1151                        else {
1152                            continue;
1153                        };
1154                        match index.memory_match_summary(&key) {
1155                            Some(MemoryMatchSummary::Single(index)) => Some(index),
1156                            _ => None,
1157                        }
1158                    }
1159                };
1160                let Some(index) = index else { continue };
1161                let merged = if state.build_left {
1162                    state.build_rows.with_row(index, |build_row| {
1163                        Ok(PhysicalRow::concat_right_owned(build_row, probe_row))
1164                    })?
1165                } else {
1166                    state.build_rows.with_row(index, |build_row| {
1167                        Ok(PhysicalRow::concat_left_owned(probe_row, build_row))
1168                    })?
1169                };
1170                output.push(merged);
1171            }
1172            if !output.is_empty() {
1173                return Ok(Some(Batch::from_physical_rows(self.schema.clone(), output)));
1174            }
1175        }
1176    }
1177}
1178
1179impl PhysicalOperator for HashJoin<'_> {
1180    fn row_schema(&self) -> &RowSchema {
1181        &self.schema
1182    }
1183
1184    fn estimated_cardinality(&self) -> Option<u64> {
1185        self.estimated_cardinality
1186    }
1187
1188    fn open(&mut self) -> ExecResult<()> {
1189        self.output = None;
1190        self.streaming_unique = None;
1191        self.output_spilled = SpillState::InMemory;
1192        self.right_input_spilled = SpillState::InMemory;
1193        self.hash_index_spilled = SpillState::InMemory;
1194
1195        let state_budget = self.work_mem_bytes / 2;
1196        let output_budget = self.work_mem_bytes.saturating_sub(state_budget);
1197        if self.build_left {
1198            return self.open_build_left(state_budget, output_budget);
1199        }
1200        let right_budget = state_budget / 2;
1201        let hash_budget = state_budget.saturating_sub(right_budget);
1202        let right_schema = self.right.row_schema().clone();
1203        let mut right = HybridRowStore::new(right_schema, right_budget);
1204        let direct_positions = (matches!(self.kind, JoinKind::Inner) && self.predicate.is_none())
1205            .then_some(())
1206            .and(self.right_key_positions.as_deref())
1207            .zip(self.left_key_positions.as_deref());
1208        let mut direct_index = direct_positions.map(|_| DirectHashIndex::new(hash_budget));
1209        let mut encoded_index = direct_index
1210            .is_none()
1211            .then(|| HybridHashIndex::new(hash_budget));
1212        self.right.open()?;
1213        while let Some(batch) = self.right.next()? {
1214            for row in batch.rows {
1215                let index = right.len();
1216                if let (Some(direct), Some((positions, _))) =
1217                    (direct_index.as_mut(), direct_positions)
1218                {
1219                    if let Some(hash) =
1220                        positional_key_hash(direct.hasher(), &batch.schema, &row, positions)?
1221                    {
1222                        direct.insert(hash, index)?;
1223                    }
1224                } else if let Some(key) = self.key(
1225                    &self.right_keys,
1226                    self.right_key_positions.as_deref(),
1227                    &row,
1228                    &batch.schema,
1229                )? {
1230                    encoded_index
1231                        .as_mut()
1232                        .ok_or_else(|| ExecError::Other("join hash index is missing".into()))?
1233                        .insert(key, index)?;
1234                }
1235                right.push(row)?;
1236            }
1237        }
1238        self.right_input_spilled = right.has_spilled().into();
1239
1240        if right.len() == 0 && matches!(self.kind, JoinKind::Inner) {
1241            let mut output = SpillBuffer::new(output_budget);
1242            self.output = Some(output.drain()?);
1243            return Ok(());
1244        }
1245
1246        let direct_is_unique = direct_index.as_ref().is_some_and(|direct| {
1247            direct_positions.is_some_and(|(positions, _)| {
1248                !right.has_spilled() && direct.keys_are_unique(&right, &right.schema, positions)
1249            })
1250        });
1251        if direct_is_unique {
1252            self.left.open()?;
1253            self.streaming_unique = Some(UniqueHashJoinState {
1254                build_rows: right,
1255                hash_index: UniqueHashIndex::Direct(
1256                    direct_index
1257                        .take()
1258                        .ok_or_else(|| ExecError::Other("direct join index is missing".into()))?,
1259                ),
1260                build_left: false,
1261            });
1262            return Ok(());
1263        }
1264
1265        let mut right_by_key = match encoded_index {
1266            Some(index) => index,
1267            None => {
1268                let (positions, _) = direct_positions
1269                    .ok_or_else(|| ExecError::Other("direct join positions are missing".into()))?;
1270                self.rebuild_encoded_index(&mut right, &self.right_keys, positions, hash_budget)?
1271            }
1272        };
1273        self.hash_index_spilled = right_by_key.has_spilled().into();
1274        if matches!(self.kind, JoinKind::Inner)
1275            && self.predicate.is_none()
1276            && !right.has_spilled()
1277            && right_by_key.is_memory_unique()
1278        {
1279            self.left.open()?;
1280            self.streaming_unique = Some(UniqueHashJoinState {
1281                build_rows: right,
1282                hash_index: UniqueHashIndex::Encoded(right_by_key),
1283                build_left: false,
1284            });
1285            return Ok(());
1286        }
1287
1288        let mut matched_right = matches!(self.kind, JoinKind::Right | JoinKind::Full)
1289            .then(|| MatchFlags::new(right.len()))
1290            .transpose()?;
1291        let mut output = SpillBuffer::new(output_budget);
1292        let mut pending = Vec::with_capacity(crate::batch::DEFAULT_BATCH_SIZE);
1293
1294        self.left.open()?;
1295        while let Some(batch) = self.left.next()? {
1296            for left_row in batch.rows {
1297                let mut matched_left = false;
1298                if let Some(key) = self.key(
1299                    &self.left_keys,
1300                    self.left_key_positions.as_deref(),
1301                    &left_row,
1302                    &batch.schema,
1303                )? {
1304                    if self.predicate.is_none() {
1305                        match right_by_key.memory_match_summary(&key) {
1306                            Some(MemoryMatchSummary::Absent) => {
1307                                if matches!(self.kind, JoinKind::Left | JoinKind::Full) {
1308                                    push_output_row(
1309                                        &mut output,
1310                                        &mut pending,
1311                                        &self.schema,
1312                                        PhysicalRow::concat_left_owned(left_row, &self.right_nulls),
1313                                    )?;
1314                                }
1315                                continue;
1316                            }
1317                            Some(MemoryMatchSummary::Single(index)) => {
1318                                let merged = right.with_row(index, |right_row| {
1319                                    Ok(PhysicalRow::concat_left_owned(left_row, right_row))
1320                                })?;
1321                                push_output_row(&mut output, &mut pending, &self.schema, merged)?;
1322                                if let Some(flags) = matched_right.as_mut() {
1323                                    flags.mark(index)?;
1324                                }
1325                                continue;
1326                            }
1327                            Some(MemoryMatchSummary::Multiple) | None => {}
1328                        }
1329                    }
1330                    right_by_key.for_each_match(&key, &mut |index| {
1331                        right.with_row(index, |right_row| {
1332                            let merged = PhysicalRow::concat(&left_row, right_row);
1333                            if self.matches(&merged)? {
1334                                push_output_row(&mut output, &mut pending, &self.schema, merged)?;
1335                                if let Some(flags) = matched_right.as_mut() {
1336                                    flags.mark(index)?;
1337                                }
1338                                matched_left = true;
1339                            }
1340                            Ok(())
1341                        })
1342                    })?;
1343                }
1344                if !matched_left && matches!(self.kind, JoinKind::Left | JoinKind::Full) {
1345                    push_output_row(
1346                        &mut output,
1347                        &mut pending,
1348                        &self.schema,
1349                        PhysicalRow::concat_left_owned(left_row, &self.right_nulls),
1350                    )?;
1351                }
1352            }
1353        }
1354
1355        if matches!(self.kind, JoinKind::Right | JoinKind::Full) {
1356            let matched_right = matched_right.as_mut().ok_or_else(|| {
1357                ExecError::Other("right/full hash join has no match flags".into())
1358            })?;
1359            for index in 0..right.len() {
1360                if !matched_right.is_marked(index)? {
1361                    right.with_row(index, |right_row| {
1362                        push_output_row(
1363                            &mut output,
1364                            &mut pending,
1365                            &self.schema,
1366                            PhysicalRow::concat(&self.left_nulls, right_row),
1367                        )
1368                    })?;
1369                }
1370            }
1371        }
1372        if !pending.is_empty() {
1373            output.push(Batch::from_physical_rows(self.schema.clone(), pending))?;
1374        }
1375        self.output_spilled = output.has_spilled().into();
1376        self.output = Some(output.drain()?);
1377        Ok(())
1378    }
1379
1380    fn next(&mut self) -> ExecResult<Option<Batch>> {
1381        if let Some(mut state) = self.streaming_unique.take() {
1382            let result = self.next_streaming_unique(&mut state);
1383            self.streaming_unique = Some(state);
1384            return result;
1385        }
1386        self.output
1387            .as_mut()
1388            .map_or(Ok(None), |output| output.next().transpose())
1389    }
1390
1391    fn close(&mut self) -> ExecResult<()> {
1392        self.output = None;
1393        self.streaming_unique = None;
1394        let left = self.left.close();
1395        let right = self.right.close();
1396        crate::physical::with_cleanup(left, right, "close right hash-join input")
1397    }
1398}
1399
1400#[cfg(test)]
1401mod tests;