use crate::context::check_current_query_cancelled;
use crate::expression::JoinFilter;
use crate::hash_table::{
hash_keys_with, JoinHashState, JoinHashTable, ProbeCursor, DEFAULT_JOIN_HASH_STATE_MAX_BYTES,
};
use crate::operator::{ColumnInfo, ColumnSource, JoinProjection, Operator, RowRef};
use radixdb_core::value::NULL_VALUE;
use radixdb_core::CompactArc;
use radixdb_core::{Result, Row};
const BUILD_COLUMN_NAMES: [&str; 32] = [
"build_0", "build_1", "build_2", "build_3", "build_4", "build_5", "build_6", "build_7",
"build_8", "build_9", "build_10", "build_11", "build_12", "build_13", "build_14", "build_15",
"build_16", "build_17", "build_18", "build_19", "build_20", "build_21", "build_22", "build_23",
"build_24", "build_25", "build_26", "build_27", "build_28", "build_29", "build_30", "build_31",
];
#[inline]
fn get_build_column_name(i: usize) -> String {
if i < BUILD_COLUMN_NAMES.len() {
BUILD_COLUMN_NAMES[i].to_string()
} else {
format!("build_{}", i)
}
}
#[inline]
fn verify_probe_build_key_equality(
probe: &RowRef,
build: &Row,
probe_indices: &[usize],
build_indices: &[usize],
) -> bool {
debug_assert_eq!(probe_indices.len(), build_indices.len());
probe_indices
.iter()
.zip(build_indices.iter())
.all(|(&probe_idx, &build_idx)| {
let (Some(probe_value), Some(build_value)) =
(probe.get(probe_idx), build.get(build_idx))
else {
return false;
};
!probe_value.is_null() && !build_value.is_null() && probe_value == build_value
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JoinSide {
Left,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JoinType {
Inner,
Left,
Right,
Full,
Cross,
Semi,
Anti,
}
impl JoinType {
pub fn parse(s: &str) -> Self {
let bytes = s.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
match b | 32 {
b'l' if i + 4 <= bytes.len()
&& (bytes[i + 1] | 32) == b'e'
&& (bytes[i + 2] | 32) == b'f'
&& (bytes[i + 3] | 32) == b't' =>
{
return JoinType::Left;
}
b'r' if i + 5 <= bytes.len()
&& (bytes[i + 1] | 32) == b'i'
&& (bytes[i + 2] | 32) == b'g'
&& (bytes[i + 3] | 32) == b'h'
&& (bytes[i + 4] | 32) == b't' =>
{
return JoinType::Right;
}
b'f' if i + 4 <= bytes.len()
&& (bytes[i + 1] | 32) == b'u'
&& (bytes[i + 2] | 32) == b'l'
&& (bytes[i + 3] | 32) == b'l' =>
{
return JoinType::Full;
}
b'c' if i + 5 <= bytes.len()
&& (bytes[i + 1] | 32) == b'r'
&& (bytes[i + 2] | 32) == b'o'
&& (bytes[i + 3] | 32) == b's'
&& (bytes[i + 4] | 32) == b's' =>
{
return JoinType::Cross;
}
b's' if i + 4 <= bytes.len()
&& (bytes[i + 1] | 32) == b'e'
&& (bytes[i + 2] | 32) == b'm'
&& (bytes[i + 3] | 32) == b'i' =>
{
return JoinType::Semi;
}
b'a' if i + 4 <= bytes.len()
&& (bytes[i + 1] | 32) == b'n'
&& (bytes[i + 2] | 32) == b't'
&& (bytes[i + 3] | 32) == b'i' =>
{
return JoinType::Anti;
}
_ => {}
}
}
JoinType::Inner
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self {
Self::parse(s)
}
pub fn needs_unmatched_probe(&self, swapped: bool) -> bool {
match self {
JoinType::Inner | JoinType::Cross | JoinType::Semi => false,
JoinType::Anti => !swapped, JoinType::Left => !swapped, JoinType::Right => swapped, JoinType::Full => true, }
}
pub fn needs_unmatched_build(&self, swapped: bool) -> bool {
match self {
JoinType::Inner | JoinType::Cross | JoinType::Semi | JoinType::Anti => false,
JoinType::Left => swapped, JoinType::Right => !swapped, JoinType::Full => true, }
}
pub fn is_semi(&self) -> bool {
matches!(self, JoinType::Semi)
}
pub fn is_anti(&self) -> bool {
matches!(self, JoinType::Anti)
}
}
pub struct HashJoinOperator {
left: Box<dyn Operator>,
right: Box<dyn Operator>,
join_type: JoinType,
build_side: JoinSide,
left_key_indices: Vec<usize>,
right_key_indices: Vec<usize>,
residual_filters: Vec<JoinFilter>,
build_rows: CompactArc<Vec<Row>>,
hash_table: Option<std::sync::Arc<JoinHashTable>>,
hash_state_max_bytes: usize,
scan_fallback: bool,
schema: Vec<ColumnInfo>,
left_col_count: usize,
right_col_count: usize,
projection: Option<JoinProjection>,
projection_columns: Option<CompactArc<[ColumnSource]>>,
current_probe_row: Option<RowRef>,
current_probe_cursor: ProbeCursor,
current_scan_idx: usize,
pending_build_idx: Option<usize>,
probe_had_match: bool,
build_matched: Vec<bool>,
returning_unmatched_build: bool,
unmatched_build_idx: usize,
is_self_join: bool,
self_join_probe_idx: usize,
cached_null_build: Option<Row>,
cached_null_probe: Option<Row>,
opened: bool,
probe_exhausted: bool,
observed_probe_rows: u64,
observed_candidate_rows: u64,
observed_deferred_probe_rows: u64,
observed_deferred_output_rows: u64,
deferred_metrics_recorded: bool,
}
impl HashJoinOperator {
pub fn new(
left: Box<dyn Operator>,
right: Box<dyn Operator>,
join_type: JoinType,
left_key_indices: Vec<usize>,
right_key_indices: Vec<usize>,
build_side: JoinSide,
) -> Self {
let mut schema = Vec::new();
if join_type.is_semi() || join_type.is_anti() {
schema.extend(left.schema().iter().cloned());
} else {
schema.extend(left.schema().iter().cloned());
schema.extend(right.schema().iter().cloned());
}
let left_col_count = left.schema().len();
let right_col_count = right.schema().len();
Self {
left,
right,
join_type,
build_side,
left_key_indices,
right_key_indices,
residual_filters: Vec::new(),
build_rows: CompactArc::new(Vec::new()),
hash_table: None,
hash_state_max_bytes: DEFAULT_JOIN_HASH_STATE_MAX_BYTES,
scan_fallback: false,
schema,
left_col_count,
right_col_count,
projection: None,
projection_columns: None,
current_probe_row: None,
current_probe_cursor: ProbeCursor::default(),
current_scan_idx: 0,
pending_build_idx: None,
probe_had_match: false,
build_matched: Vec::new(),
returning_unmatched_build: false,
unmatched_build_idx: 0,
is_self_join: false,
self_join_probe_idx: 0,
cached_null_build: None,
cached_null_probe: None,
opened: false,
probe_exhausted: false,
observed_probe_rows: 0,
observed_candidate_rows: 0,
observed_deferred_probe_rows: 0,
observed_deferred_output_rows: 0,
deferred_metrics_recorded: false,
}
}
pub fn with_prebuilt(
probe: Box<dyn Operator>,
hash_state: JoinHashState,
join_type: JoinType,
left_key_indices: Vec<usize>,
right_key_indices: Vec<usize>,
build_is_left: bool,
build_col_count: usize,
) -> Result<Self> {
let build_key_indices = if build_is_left {
&left_key_indices
} else {
&right_key_indices
};
if !hash_state.matches(hash_state.build_rows(), build_key_indices) {
return Err(radixdb_core::Error::internal(
"pre-built join hash state key layout mismatch",
));
}
let build_rows = CompactArc::clone(hash_state.build_rows());
let hash_table = std::sync::Arc::clone(hash_state.table());
let probe_col_count = probe.schema().len();
let total_cols = build_col_count + probe_col_count;
let mut schema = Vec::with_capacity(total_cols);
let (left_col_count, right_col_count) = if build_is_left {
for i in 0..build_col_count {
schema.push(ColumnInfo::new(get_build_column_name(i)));
}
schema.extend(probe.schema().iter().cloned());
(build_col_count, probe_col_count)
} else {
schema.extend(probe.schema().iter().cloned());
for i in 0..build_col_count {
schema.push(ColumnInfo::new(get_build_column_name(i)));
}
(probe_col_count, build_col_count)
};
let build_side = if build_is_left {
JoinSide::Left
} else {
JoinSide::Right
};
let build_matched = if matches!(join_type, JoinType::Full)
|| (matches!(join_type, JoinType::Left) && build_is_left)
|| (matches!(join_type, JoinType::Right) && !build_is_left)
{
vec![false; build_rows.len()]
} else {
Vec::new()
};
let (left, right) = if build_is_left {
(
Box::new(crate::operator::EmptyOperator::new()) as Box<dyn Operator>,
probe,
)
} else {
(
probe,
Box::new(crate::operator::EmptyOperator::new()) as Box<dyn Operator>,
)
};
Ok(Self {
left,
right,
join_type,
build_side,
left_key_indices,
right_key_indices,
residual_filters: Vec::new(),
build_rows,
hash_table: Some(hash_table),
hash_state_max_bytes: DEFAULT_JOIN_HASH_STATE_MAX_BYTES,
scan_fallback: false,
schema,
left_col_count,
right_col_count,
projection: None,
projection_columns: None,
current_probe_row: None,
current_probe_cursor: ProbeCursor::default(),
current_scan_idx: 0,
pending_build_idx: None,
probe_had_match: false,
build_matched,
returning_unmatched_build: false,
unmatched_build_idx: 0,
is_self_join: false,
self_join_probe_idx: 0,
cached_null_build: None,
cached_null_probe: None,
opened: false,
probe_exhausted: false,
observed_probe_rows: 0,
observed_candidate_rows: 0,
observed_deferred_probe_rows: 0,
observed_deferred_output_rows: 0,
deferred_metrics_recorded: false,
})
}
pub fn self_join(
input: Box<dyn Operator>,
join_type: JoinType,
left_key_indices: Vec<usize>,
right_key_indices: Vec<usize>,
) -> Self {
let mut schema = Vec::new();
schema.extend(input.schema().iter().cloned());
schema.extend(input.schema().iter().cloned());
let col_count = input.schema().len();
Self {
left: input,
right: Box::new(crate::operator::EmptyOperator::new()),
join_type,
build_side: JoinSide::Left, left_key_indices,
right_key_indices,
residual_filters: Vec::new(),
build_rows: CompactArc::new(Vec::new()),
hash_table: None,
hash_state_max_bytes: DEFAULT_JOIN_HASH_STATE_MAX_BYTES,
scan_fallback: false,
schema,
left_col_count: col_count,
right_col_count: col_count,
projection: None,
projection_columns: None,
current_probe_row: None,
current_probe_cursor: ProbeCursor::default(),
current_scan_idx: 0,
pending_build_idx: None,
probe_had_match: false,
build_matched: Vec::new(),
returning_unmatched_build: false,
unmatched_build_idx: 0,
is_self_join: true,
self_join_probe_idx: 0,
cached_null_build: None,
cached_null_probe: None,
opened: false,
probe_exhausted: false,
observed_probe_rows: 0,
observed_candidate_rows: 0,
observed_deferred_probe_rows: 0,
observed_deferred_output_rows: 0,
deferred_metrics_recorded: false,
}
}
pub(crate) fn observed_probe_rows(&self) -> u64 {
self.observed_probe_rows
}
pub(crate) fn observed_candidate_rows(&self) -> u64 {
self.observed_candidate_rows
}
pub(crate) fn used_scan_fallback(&self) -> bool {
self.scan_fallback
}
pub(crate) fn with_hash_state_max_bytes(mut self, max_bytes: usize) -> Self {
self.hash_state_max_bytes = max_bytes;
self
}
fn publish_deferred_metrics(&mut self) {
if self.deferred_metrics_recorded {
return;
}
radixdb_storage::instrumentation::record_join_deferred_rows(
self.observed_deferred_output_rows,
self.observed_deferred_probe_rows,
);
self.deferred_metrics_recorded = true;
}
#[inline]
fn observe_output(&mut self, row: RowRef) -> RowRef {
if row.is_deferred() {
self.observed_deferred_output_rows =
self.observed_deferred_output_rows.saturating_add(1);
}
row
}
pub fn with_projection(
mut self,
columns: Vec<ColumnSource>,
projected_schema: Vec<ColumnInfo>,
) -> Self {
self.projection_columns = Some(CompactArc::from(columns.clone()));
self.projection = Some(JoinProjection { columns });
self.schema = projected_schema;
self
}
pub fn with_residual_filters(mut self, filters: Vec<JoinFilter>) -> Self {
self.residual_filters = filters;
self
}
#[inline]
fn candidate_passes_residual(&self, probe_row: &RowRef, build_idx: usize) -> Result<bool> {
if self.residual_filters.is_empty() {
return Ok(true);
}
let build_row = RowRef::shared(CompactArc::clone(&self.build_rows), build_idx);
let (left, right) = match self.build_side {
JoinSide::Left => (&build_row, probe_row),
JoinSide::Right => (probe_row, &build_row),
};
for filter in &self.residual_filters {
if !filter.matches_row_refs_checked(left, right)? {
return Ok(false);
}
}
Ok(true)
}
fn build_key_indices(&self) -> &[usize] {
match self.build_side {
JoinSide::Left => &self.left_key_indices,
JoinSide::Right => &self.right_key_indices,
}
}
fn probe_key_indices(&self) -> &[usize] {
match self.build_side {
JoinSide::Left => &self.right_key_indices,
JoinSide::Right => &self.left_key_indices,
}
}
#[inline]
fn project_left_right_refs(&self, left: RowRef, right: RowRef) -> RowRef {
RowRef::projected(
left,
right,
CompactArc::clone(
self.projection_columns
.as_ref()
.expect("projection columns must exist when projection is enabled"),
),
)
}
#[inline]
fn project_probe_build_match(&self, probe_row: RowRef, build_idx: usize) -> RowRef {
let build_row = RowRef::shared(CompactArc::clone(&self.build_rows), build_idx);
match self.build_side {
JoinSide::Left => self.project_left_right_refs(build_row, probe_row),
JoinSide::Right => self.project_left_right_refs(probe_row, build_row),
}
}
#[inline]
fn project_probe_without_build(&self, probe_row: RowRef, null_build: Row) -> RowRef {
let null_build = RowRef::owned(null_build);
match self.build_side {
JoinSide::Left => self.project_left_right_refs(null_build, probe_row),
JoinSide::Right => self.project_left_right_refs(probe_row, null_build),
}
}
#[inline]
fn project_build_without_probe(&self, build_idx: usize, null_probe: Row) -> RowRef {
let build_row = RowRef::shared(CompactArc::clone(&self.build_rows), build_idx);
let null_probe = RowRef::owned(null_probe);
match self.build_side {
JoinSide::Left => self.project_left_right_refs(build_row, null_probe),
JoinSide::Right => self.project_left_right_refs(null_probe, build_row),
}
}
#[inline]
fn null_build_row(&mut self) -> Row {
if let Some(ref row) = self.cached_null_build {
return row.clone();
}
let count = match self.build_side {
JoinSide::Left => self.left_col_count,
JoinSide::Right => self.right_col_count,
};
let row = Row::from_values(vec![NULL_VALUE; count]);
self.cached_null_build = Some(row.clone());
row
}
#[inline]
fn null_probe_row(&mut self) -> Row {
if let Some(ref row) = self.cached_null_probe {
return row.clone();
}
let count = match self.build_side {
JoinSide::Left => self.right_col_count,
JoinSide::Right => self.left_col_count,
};
let row = Row::from_values(vec![NULL_VALUE; count]);
self.cached_null_probe = Some(row.clone());
row
}
#[inline]
fn combine_rows_direct(&self, probe_row: RowRef, build_idx: usize) -> RowRef {
if self.projection.is_some() {
return self.project_probe_build_match(probe_row, build_idx);
}
let probe_is_left = matches!(self.build_side, JoinSide::Right);
RowRef::direct_build_composite(
probe_row.into_owned(),
CompactArc::clone(&self.build_rows),
build_idx,
probe_is_left,
)
}
#[inline]
fn combine_rows_ref(&self, probe_row: RowRef, build_row: Row) -> RowRef {
if self.projection.is_some() {
let build_row = RowRef::owned(build_row);
return match self.build_side {
JoinSide::Left => self.project_left_right_refs(build_row, probe_row),
JoinSide::Right => self.project_left_right_refs(probe_row, build_row),
};
}
let probe_row = probe_row.into_owned();
match self.build_side {
JoinSide::Left => {
RowRef::Composite(crate::operator::CompositeRow::new(build_row, probe_row))
}
JoinSide::Right => {
RowRef::Composite(crate::operator::CompositeRow::new(probe_row, build_row))
}
}
}
fn next_probe_row(&mut self) -> Result<Option<RowRef>> {
if self.is_self_join {
if self.self_join_probe_idx >= self.build_rows.len() {
return Ok(None);
}
let row = self.build_rows[self.self_join_probe_idx].clone();
self.self_join_probe_idx += 1;
Ok(Some(RowRef::owned(row)))
} else {
let probe_op = match self.build_side {
JoinSide::Left => &mut self.right,
JoinSide::Right => &mut self.left,
};
probe_op.next()
}
}
#[inline]
fn next_build_candidate(&mut self) -> Option<usize> {
if let Some(pending) = self.pending_build_idx.take() {
return Some(pending);
}
if self.scan_fallback {
if self.current_scan_idx >= self.build_rows.len() {
return None;
}
let candidate = self.current_scan_idx;
self.current_scan_idx += 1;
Some(candidate)
} else {
self.hash_table
.as_ref()
.expect("opened hash join must own a table")
.probe_next(&mut self.current_probe_cursor)
}
}
#[inline]
fn prefetch_build_candidate(&mut self) {
self.pending_build_idx = if self.scan_fallback {
if self.current_scan_idx < self.build_rows.len() {
let candidate = self.current_scan_idx;
self.current_scan_idx += 1;
Some(candidate)
} else {
None
}
} else {
self.hash_table
.as_ref()
.expect("opened hash join must own a table")
.probe_next(&mut self.current_probe_cursor)
};
}
}
impl Operator for HashJoinOperator {
fn open(&mut self) -> Result<()> {
self.observed_probe_rows = 0;
self.observed_candidate_rows = 0;
self.observed_deferred_probe_rows = 0;
self.observed_deferred_output_rows = 0;
self.deferred_metrics_recorded = false;
if let Some(projection) = &self.projection {
projection.validate(self.left_col_count, self.right_col_count, self.schema.len())?;
}
if self.hash_table.is_some() {
let probe_op = match self.build_side {
JoinSide::Left => &mut self.right,
JoinSide::Right => &mut self.left,
};
if let Err(error) = probe_op.open() {
let _ = probe_op.close();
return Err(error);
}
self.opened = true;
return Ok(());
}
if let Err(error) = self.left.open() {
let _ = self.left.close();
return Err(error);
}
if !self.is_self_join {
if let Err(error) = self.right.open() {
let _ = self.right.close();
let _ = self.left.close();
return Err(error);
}
}
let open_result = (|| {
check_current_query_cancelled()?;
let build_op = match self.build_side {
JoinSide::Left => &mut self.left,
JoinSide::Right => &mut self.right,
};
let mut build_rows = Vec::new();
while let Some(row_ref) = build_op.next()? {
if build_rows.len() & 0xff == 0 {
check_current_query_cancelled()?;
}
build_rows.push(row_ref.into_owned());
}
let build_key_indices = self.build_key_indices().to_vec();
let hash_table =
if JoinHashTable::fits_retained_budget(build_rows.len(), self.hash_state_max_bytes)
{
Some(std::sync::Arc::new(JoinHashTable::build(
&build_rows,
&build_key_indices,
)))
} else {
self.scan_fallback = true;
None
};
let needs_build_tracking = matches!(self.join_type, JoinType::Full)
|| (matches!(self.join_type, JoinType::Left) && self.build_side == JoinSide::Left)
|| (matches!(self.join_type, JoinType::Right)
&& self.build_side == JoinSide::Right)
|| (self.is_self_join && !matches!(self.join_type, JoinType::Inner));
if needs_build_tracking {
self.build_matched = vec![false; build_rows.len()];
}
self.build_rows = CompactArc::new(build_rows);
self.hash_table = hash_table;
self.opened = true;
Ok(())
})();
if let Err(error) = open_result {
let _ = self.close();
return Err(error);
}
Ok(())
}
fn next(&mut self) -> Result<Option<RowRef>> {
check_current_query_cancelled()?;
if !self.opened {
return Err(radixdb_core::Error::internal(
"HashJoinOperator::next called before open",
));
}
if self.returning_unmatched_build {
while self.unmatched_build_idx < self.build_rows.len() {
if self.unmatched_build_idx & 0xff == 0 {
check_current_query_cancelled()?;
}
let idx = self.unmatched_build_idx;
self.unmatched_build_idx += 1;
if !self.build_matched[idx] {
if self.projection.is_some() {
let null_probe = self.null_probe_row();
let row = self.project_build_without_probe(idx, null_probe);
return Ok(Some(self.observe_output(row)));
}
let build_row = self.build_rows[idx].clone();
let null_probe = self.null_probe_row();
let row = self.combine_rows_ref(RowRef::owned(null_probe), build_row);
return Ok(Some(self.observe_output(row)));
}
}
return Ok(None);
}
let mut scanned_probe_rows = 0_usize;
loop {
while self.current_probe_row.is_some() {
if self.observed_candidate_rows & 0xff == 0 {
check_current_query_cancelled()?;
}
let Some(build_idx) = self.next_build_candidate() else {
break;
};
self.observed_candidate_rows = self.observed_candidate_rows.saturating_add(1);
let build_row = &self.build_rows[build_idx];
if verify_probe_build_key_equality(
self.current_probe_row.as_ref().unwrap(),
build_row,
self.probe_key_indices(),
self.build_key_indices(),
) {
if !self.candidate_passes_residual(
self.current_probe_row.as_ref().unwrap(),
build_idx,
)? {
continue;
}
self.probe_had_match = true;
if self.join_type.is_semi() {
let probe_row = self.current_probe_row.take().unwrap();
self.pending_build_idx = None;
self.current_probe_cursor = ProbeCursor::default();
if self.projection.is_some() {
let row = self.project_probe_build_match(probe_row, build_idx);
return Ok(Some(self.observe_output(row)));
}
return Ok(Some(self.observe_output(probe_row)));
}
if self.join_type.is_anti() {
self.current_probe_row = None;
self.pending_build_idx = None;
self.current_probe_cursor = ProbeCursor::default();
continue;
}
if !self.build_matched.is_empty() {
self.build_matched[build_idx] = true;
}
self.prefetch_build_candidate();
let probe_row = if self.pending_build_idx.is_none() {
self.current_probe_row.take().unwrap()
} else {
self.current_probe_row.as_ref().unwrap().clone()
};
let row = self.combine_rows_direct(probe_row, build_idx);
return Ok(Some(self.observe_output(row)));
}
}
if self.join_type.is_anti() && !self.probe_had_match {
if let Some(probe_row) = self.current_probe_row.take() {
if self.projection.is_some() {
let null_build = self.null_build_row();
let row = self.project_probe_without_build(probe_row, null_build);
return Ok(Some(self.observe_output(row)));
}
return Ok(Some(self.observe_output(probe_row)));
}
}
let needs_unmatched_probe = matches!(self.join_type, JoinType::Full)
|| (matches!(self.join_type, JoinType::Right) && self.build_side == JoinSide::Left)
|| (matches!(self.join_type, JoinType::Left) && self.build_side == JoinSide::Right);
if needs_unmatched_probe && !self.probe_had_match {
if let Some(probe_row) = self.current_probe_row.take() {
if self.projection.is_some() {
let null_build = self.null_build_row();
let row = self.project_probe_without_build(probe_row, null_build);
return Ok(Some(self.observe_output(row)));
}
let null_build = self.null_build_row();
let row = self.combine_rows_ref(probe_row, null_build);
return Ok(Some(self.observe_output(row)));
}
}
if scanned_probe_rows & 0xff == 0 {
check_current_query_cancelled()?;
}
let next_probe = self.next_probe_row()?;
scanned_probe_rows = scanned_probe_rows.saturating_add(1);
match next_probe {
Some(probe_row) => {
self.observed_probe_rows = self.observed_probe_rows.saturating_add(1);
if probe_row.is_deferred() {
self.observed_deferred_probe_rows =
self.observed_deferred_probe_rows.saturating_add(1);
}
if self.scan_fallback {
self.current_scan_idx = 0;
self.current_probe_cursor = ProbeCursor::default();
} else {
let probe_key_indices = self.probe_key_indices();
let hash = hash_keys_with(probe_key_indices, |idx| probe_row.get(idx));
self.current_probe_cursor = self
.hash_table
.as_ref()
.expect("opened hash join must own a table")
.probe_cursor(hash);
}
self.pending_build_idx = None;
self.current_probe_row = Some(probe_row);
self.probe_had_match = false;
}
None => {
self.probe_exhausted = true;
if !self.build_matched.is_empty() {
self.returning_unmatched_build = true;
self.unmatched_build_idx = 0;
return self.next();
}
return Ok(None);
}
}
}
}
fn close(&mut self) -> Result<()> {
self.publish_deferred_metrics();
let left = self.left.close();
let right = if self.is_self_join {
Ok(())
} else {
self.right.close()
};
left.and(right)
}
fn schema(&self) -> &[ColumnInfo] {
&self.schema
}
fn estimated_rows(&self) -> Option<usize> {
let left_est = self.left.estimated_rows()?;
let right_est = self.right.estimated_rows()?;
Some(match self.join_type {
JoinType::Inner => left_est.min(right_est),
JoinType::Left => left_est,
JoinType::Right => right_est,
JoinType::Full => left_est + right_est,
JoinType::Cross => left_est * right_est,
JoinType::Semi => left_est.min(right_est), JoinType::Anti => left_est, })
}
fn name(&self) -> &str {
match self.join_type {
JoinType::Inner => "HashJoin (INNER)",
JoinType::Left => "HashJoin (LEFT)",
JoinType::Right => "HashJoin (RIGHT)",
JoinType::Full => "HashJoin (FULL)",
JoinType::Cross => "HashJoin (CROSS)",
JoinType::Semi => "HashJoin (SEMI)",
JoinType::Anti => "HashJoin (ANTI)",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::operator::MaterializedOperator;
use radixdb_core::Value;
use radixdb_storage::instrumentation;
fn make_rows(data: Vec<Vec<i64>>) -> Vec<Row> {
data.into_iter()
.map(|vals| Row::from_values(vals.into_iter().map(Value::integer).collect()))
.collect()
}
fn make_operator(data: Vec<Vec<i64>>, cols: Vec<&str>) -> Box<dyn Operator> {
let rows = make_rows(data);
let schema = cols.into_iter().map(ColumnInfo::new).collect();
Box::new(MaterializedOperator::new(rows, schema))
}
fn collect_results(op: &mut dyn Operator) -> Result<Vec<Row>> {
let mut results = Vec::new();
op.open()?;
while let Some(row_ref) = op.next()? {
results.push(row_ref.into_owned());
}
op.close()?;
Ok(results)
}
fn canonical_rows(rows: Vec<Row>) -> Vec<String> {
let mut rows = rows
.into_iter()
.map(|row| format!("{row:?}"))
.collect::<Vec<_>>();
rows.sort_unstable();
rows
}
fn execute_budget_case(
join_type: JoinType,
build_side: JoinSide,
max_bytes: usize,
) -> (Vec<String>, bool) {
let left = make_operator(
vec![vec![1, 10], vec![1, 11], vec![2, 20], vec![4, 40]],
vec!["id", "left_value"],
);
let right = make_operator(
vec![vec![1, 100], vec![1, 101], vec![3, 300]],
vec!["id", "right_value"],
);
let mut join = HashJoinOperator::new(left, right, join_type, vec![0], vec![0], build_side)
.with_hash_state_max_bytes(max_bytes);
let rows = collect_results(&mut join).unwrap();
(canonical_rows(rows), join.used_scan_fallback())
}
#[test]
fn bounded_scan_fallback_matches_hash_semantics_for_all_join_types() {
let cases = [
(JoinType::Inner, JoinSide::Right),
(JoinType::Left, JoinSide::Right),
(JoinType::Right, JoinSide::Left),
(JoinType::Full, JoinSide::Right),
(JoinType::Semi, JoinSide::Right),
(JoinType::Anti, JoinSide::Right),
];
for (join_type, build_side) in cases {
let (hashed, hash_fallback) = execute_budget_case(join_type, build_side, usize::MAX);
let (scanned, scan_fallback) = execute_budget_case(join_type, build_side, 0);
assert!(!hash_fallback, "{join_type:?} unexpectedly used fallback");
assert!(scan_fallback, "{join_type:?} did not use fallback");
assert_eq!(scanned, hashed, "{join_type:?} fallback changed results");
}
}
#[test]
fn test_inner_join() {
let left = make_operator(
vec![vec![1, 10], vec![2, 20], vec![3, 30]],
vec!["id", "value"],
);
let right = make_operator(vec![vec![1, 100], vec![3, 300]], vec!["id", "data"]);
let mut join = HashJoinOperator::new(
left,
right,
JoinType::Inner,
vec![0], vec![0], JoinSide::Right,
);
let results = collect_results(&mut join).unwrap();
assert_eq!(results.len(), 2);
let row1 = &results[0];
assert_eq!(row1.get(0), Some(&Value::integer(1)));
assert_eq!(row1.get(1), Some(&Value::integer(10)));
assert_eq!(row1.get(2), Some(&Value::integer(1)));
assert_eq!(row1.get(3), Some(&Value::integer(100)));
}
#[test]
fn test_inner_join_projection_materializes_selected_columns_only() {
let left = make_operator(
vec![vec![1, 10, 1000], vec![2, 20, 2000], vec![3, 30, 3000]],
vec!["id", "value", "unused_left"],
);
let right = make_operator(
vec![vec![1, 100, 9000], vec![3, 300, 7000]],
vec!["id", "data", "unused_right"],
);
let projected_schema = vec![ColumnInfo::new("value"), ColumnInfo::new("data")];
let mut join = HashJoinOperator::new(
left,
right,
JoinType::Inner,
vec![0],
vec![0],
JoinSide::Right,
)
.with_projection(
vec![ColumnSource::Outer(1), ColumnSource::Inner(1)],
projected_schema,
);
assert_eq!(join.schema().len(), 2);
let results = collect_results(&mut join).unwrap();
assert_eq!(results.len(), 2);
assert!(results.iter().all(|row| row.len() == 2));
assert_eq!(results[0].get(0), Some(&Value::integer(10)));
assert_eq!(results[0].get(1), Some(&Value::integer(100)));
assert_eq!(results[1].get(0), Some(&Value::integer(30)));
assert_eq!(results[1].get(1), Some(&Value::integer(300)));
}
#[test]
fn projected_hash_chain_keeps_probe_rows_deferred_between_edges() {
let first_left = make_operator(vec![vec![1, 10], vec![2, 20]], vec!["id", "payload"]);
let first_right = make_operator(vec![vec![1, 100], vec![2, 200]], vec!["id", "dictionary"]);
let first = HashJoinOperator::new(
first_left,
first_right,
JoinType::Inner,
vec![0],
vec![0],
JoinSide::Right,
)
.with_projection(
vec![
ColumnSource::Outer(0),
ColumnSource::Outer(1),
ColumnSource::Inner(1),
],
vec![
ColumnInfo::new("id"),
ColumnInfo::new("payload"),
ColumnInfo::new("dictionary"),
],
);
let second_right = make_operator(vec![vec![1, 1000], vec![2, 2000]], vec!["id", "leaf"]);
let mut second = HashJoinOperator::new(
Box::new(first),
second_right,
JoinType::Inner,
vec![0],
vec![0],
JoinSide::Right,
)
.with_projection(
vec![
ColumnSource::Outer(1),
ColumnSource::Outer(2),
ColumnSource::Inner(1),
],
vec![
ColumnInfo::new("payload"),
ColumnInfo::new("dictionary"),
ColumnInfo::new("leaf"),
],
);
instrumentation::begin_join_execution_probe();
second.open().unwrap();
let first_output = second.next().unwrap().unwrap();
assert!(first_output.is_deferred());
assert_eq!(first_output.get(0), Some(&Value::integer(10)));
assert_eq!(first_output.get(1), Some(&Value::integer(100)));
assert_eq!(first_output.get(2), Some(&Value::integer(1000)));
assert_eq!(first_output.into_owned().len(), 3);
let second_output = second.next().unwrap().unwrap();
assert!(second_output.is_deferred());
assert_eq!(second_output.get(0), Some(&Value::integer(20)));
assert_eq!(second_output.get(1), Some(&Value::integer(200)));
assert_eq!(second_output.get(2), Some(&Value::integer(2000)));
assert!(second.next().unwrap().is_none());
second.close().unwrap();
let probe = instrumentation::end_join_execution_probe();
assert_eq!(probe.deferred_rows, 4);
assert_eq!(probe.deferred_rows_consumed, 2);
}
#[test]
fn public_hash_join_rejects_invalid_projection_before_reading_rows() {
let left = make_operator(vec![vec![1]], vec!["left_id"]);
let right = make_operator(vec![vec![1]], vec!["right_id"]);
let mut join = HashJoinOperator::new(
left,
right,
JoinType::Inner,
vec![0],
vec![0],
JoinSide::Right,
)
.with_projection(
vec![ColumnSource::Inner(1)],
vec![ColumnInfo::new("invalid")],
);
assert!(join.open().is_err());
}
#[test]
fn test_left_join() {
let left = make_operator(
vec![vec![1, 10], vec![2, 20], vec![3, 30]],
vec!["id", "value"],
);
let right = make_operator(vec![vec![1, 100]], vec!["id", "data"]);
let mut join = HashJoinOperator::new(
left,
right,
JoinType::Left,
vec![0],
vec![0],
JoinSide::Right,
);
let results = collect_results(&mut join).unwrap();
assert_eq!(results.len(), 3);
let row2 = results
.iter()
.find(|r| r.get(0) == Some(&Value::integer(2)))
.unwrap();
assert!(row2.get(2).unwrap().is_null());
assert!(row2.get(3).unwrap().is_null());
}
#[test]
fn test_left_join_projection_uses_sparse_null_build_side() {
let left = make_operator(
vec![vec![1, 10], vec![2, 20], vec![3, 30]],
vec!["id", "value"],
);
let right = make_operator(vec![vec![1, 100]], vec!["id", "data"]);
let mut join = HashJoinOperator::new(
left,
right,
JoinType::Left,
vec![0],
vec![0],
JoinSide::Right,
)
.with_projection(
vec![ColumnSource::Outer(1), ColumnSource::Inner(1)],
vec![ColumnInfo::new("value"), ColumnInfo::new("data")],
);
let results = collect_results(&mut join).unwrap();
assert_eq!(results.len(), 3);
let row2 = results
.iter()
.find(|row| row.get(0) == Some(&Value::integer(20)))
.unwrap();
assert_eq!(row2.len(), 2);
assert!(row2.get(1).unwrap().is_null());
}
#[test]
fn test_self_join() {
let input = make_operator(
vec![vec![1, 10], vec![2, 10], vec![3, 20]],
vec!["id", "age"],
);
let mut join = HashJoinOperator::self_join(
input,
JoinType::Inner,
vec![1], vec![1], );
let results = collect_results(&mut join).unwrap();
assert_eq!(results.len(), 5);
}
#[test]
fn test_empty_build() {
let left = make_operator(vec![vec![1, 10], vec![2, 20]], vec!["id", "value"]);
let right = make_operator(vec![], vec!["id", "data"]);
let mut join = HashJoinOperator::new(
left,
right,
JoinType::Inner,
vec![0],
vec![0],
JoinSide::Right,
);
let results = collect_results(&mut join).unwrap();
assert_eq!(results.len(), 0);
}
#[test]
fn test_multi_key_join() {
let left = make_operator(
vec![vec![1, 10, 100], vec![1, 20, 200], vec![2, 10, 300]],
vec!["a", "b", "val"],
);
let right = make_operator(
vec![vec![1, 10, 1000], vec![1, 20, 2000]],
vec!["a", "b", "data"],
);
let mut join = HashJoinOperator::new(
left,
right,
JoinType::Inner,
vec![0, 1], vec![0, 1], JoinSide::Right,
);
let results = collect_results(&mut join).unwrap();
assert_eq!(results.len(), 2);
}
#[test]
fn test_semi_join() {
let left = make_operator(
vec![vec![1, 100], vec![2, 200], vec![3, 300]],
vec!["id", "value"],
);
let right = make_operator(
vec![vec![1, 10], vec![1, 20], vec![3, 30]],
vec!["user_id", "order_id"],
);
let mut join = HashJoinOperator::new(
left,
right,
JoinType::Semi,
vec![0], vec![0], JoinSide::Right,
);
let results = collect_results(&mut join).unwrap();
assert_eq!(results.len(), 2);
assert_eq!(join.schema().len(), 2);
let ids: Vec<i64> = results
.iter()
.map(|r| r.get(0).unwrap().as_int64().unwrap())
.collect();
assert!(ids.contains(&1));
assert!(ids.contains(&3));
assert!(!ids.contains(&2));
}
#[test]
fn test_semi_join_projection_returns_requested_probe_columns_only() {
let left = make_operator(
vec![vec![1, 100, 1000], vec![2, 200, 2000], vec![3, 300, 3000]],
vec!["id", "value", "unused_left"],
);
let right = make_operator(
vec![vec![1, 10], vec![1, 20], vec![3, 30]],
vec!["user_id", "order_id"],
);
let mut join = HashJoinOperator::new(
left,
right,
JoinType::Semi,
vec![0],
vec![0],
JoinSide::Right,
)
.with_projection(vec![ColumnSource::Outer(1)], vec![ColumnInfo::new("value")]);
let results = collect_results(&mut join).unwrap();
assert_eq!(join.schema().len(), 1);
assert_eq!(results.len(), 2);
assert!(results.iter().all(|row| row.len() == 1));
let values: Vec<i64> = results
.iter()
.map(|row| row.get(0).unwrap().as_int64().unwrap())
.collect();
assert!(values.contains(&100));
assert!(values.contains(&300));
assert!(!values.contains(&200));
}
#[test]
fn test_anti_join() {
let left = make_operator(
vec![vec![1, 100], vec![2, 200], vec![3, 300]],
vec!["id", "value"],
);
let right = make_operator(vec![vec![1, 10], vec![3, 30]], vec!["user_id", "order_id"]);
let mut join = HashJoinOperator::new(
left,
right,
JoinType::Anti,
vec![0], vec![0], JoinSide::Right,
);
let results = collect_results(&mut join).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(join.schema().len(), 2);
let row = &results[0];
assert_eq!(row.get(0), Some(&Value::integer(2)));
assert_eq!(row.get(1), Some(&Value::integer(200)));
}
#[test]
fn test_anti_join_projection_returns_requested_probe_columns_only() {
let left = make_operator(
vec![vec![1, 100, 1000], vec![2, 200, 2000], vec![3, 300, 3000]],
vec!["id", "value", "unused_left"],
);
let right = make_operator(vec![vec![1, 10], vec![3, 30]], vec!["user_id", "order_id"]);
let mut join = HashJoinOperator::new(
left,
right,
JoinType::Anti,
vec![0],
vec![0],
JoinSide::Right,
)
.with_projection(vec![ColumnSource::Outer(1)], vec![ColumnInfo::new("value")]);
let results = collect_results(&mut join).unwrap();
assert_eq!(join.schema().len(), 1);
assert_eq!(results.len(), 1);
assert_eq!(results[0].len(), 1);
assert_eq!(results[0].get(0), Some(&Value::integer(200)));
}
#[test]
fn test_anti_join_empty_right() {
let left = make_operator(
vec![vec![1, 100], vec![2, 200], vec![3, 300]],
vec!["id", "value"],
);
let right = make_operator(vec![], vec!["user_id", "order_id"]);
let mut join = HashJoinOperator::new(
left,
right,
JoinType::Anti,
vec![0],
vec![0],
JoinSide::Right,
);
let results = collect_results(&mut join).unwrap();
assert_eq!(results.len(), 3);
}
#[test]
fn test_semi_join_empty_right() {
let left = make_operator(
vec![vec![1, 100], vec![2, 200], vec![3, 300]],
vec!["id", "value"],
);
let right = make_operator(vec![], vec!["user_id", "order_id"]);
let mut join = HashJoinOperator::new(
left,
right,
JoinType::Semi,
vec![0],
vec![0],
JoinSide::Right,
);
let results = collect_results(&mut join).unwrap();
assert_eq!(results.len(), 0);
}
}