1use crate::context::check_current_query_cancelled;
36use crate::expression::JoinFilter;
37use crate::hash_table::{
38 hash_keys_with, JoinHashState, JoinHashTable, ProbeCursor, DEFAULT_JOIN_HASH_STATE_MAX_BYTES,
39};
40use crate::operator::{ColumnInfo, ColumnSource, JoinProjection, Operator, RowRef};
41use radixdb_core::value::NULL_VALUE;
42use radixdb_core::CompactArc;
43use radixdb_core::{Result, Row};
44
45const BUILD_COLUMN_NAMES: [&str; 32] = [
48 "build_0", "build_1", "build_2", "build_3", "build_4", "build_5", "build_6", "build_7",
49 "build_8", "build_9", "build_10", "build_11", "build_12", "build_13", "build_14", "build_15",
50 "build_16", "build_17", "build_18", "build_19", "build_20", "build_21", "build_22", "build_23",
51 "build_24", "build_25", "build_26", "build_27", "build_28", "build_29", "build_30", "build_31",
52];
53
54#[inline]
56fn get_build_column_name(i: usize) -> String {
57 if i < BUILD_COLUMN_NAMES.len() {
58 BUILD_COLUMN_NAMES[i].to_string()
59 } else {
60 format!("build_{}", i)
61 }
62}
63
64#[inline]
65fn verify_probe_build_key_equality(
66 probe: &RowRef,
67 build: &Row,
68 probe_indices: &[usize],
69 build_indices: &[usize],
70) -> bool {
71 debug_assert_eq!(probe_indices.len(), build_indices.len());
72
73 probe_indices
74 .iter()
75 .zip(build_indices.iter())
76 .all(|(&probe_idx, &build_idx)| {
77 let (Some(probe_value), Some(build_value)) =
78 (probe.get(probe_idx), build.get(build_idx))
79 else {
80 return false;
81 };
82 !probe_value.is_null() && !build_value.is_null() && probe_value == build_value
83 })
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum JoinSide {
89 Left,
91 Right,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum JoinType {
98 Inner,
100 Left,
102 Right,
104 Full,
106 Cross,
108 Semi,
110 Anti,
112}
113
114impl JoinType {
115 pub fn parse(s: &str) -> Self {
118 let bytes = s.as_bytes();
120 for (i, &b) in bytes.iter().enumerate() {
121 match b | 32 {
124 b'l' if i + 4 <= bytes.len()
125 && (bytes[i + 1] | 32) == b'e'
126 && (bytes[i + 2] | 32) == b'f'
127 && (bytes[i + 3] | 32) == b't' =>
128 {
129 return JoinType::Left;
130 }
131 b'r' if i + 5 <= bytes.len()
132 && (bytes[i + 1] | 32) == b'i'
133 && (bytes[i + 2] | 32) == b'g'
134 && (bytes[i + 3] | 32) == b'h'
135 && (bytes[i + 4] | 32) == b't' =>
136 {
137 return JoinType::Right;
138 }
139 b'f' if i + 4 <= bytes.len()
140 && (bytes[i + 1] | 32) == b'u'
141 && (bytes[i + 2] | 32) == b'l'
142 && (bytes[i + 3] | 32) == b'l' =>
143 {
144 return JoinType::Full;
145 }
146 b'c' if i + 5 <= bytes.len()
147 && (bytes[i + 1] | 32) == b'r'
148 && (bytes[i + 2] | 32) == b'o'
149 && (bytes[i + 3] | 32) == b's'
150 && (bytes[i + 4] | 32) == b's' =>
151 {
152 return JoinType::Cross;
153 }
154 b's' if i + 4 <= bytes.len()
155 && (bytes[i + 1] | 32) == b'e'
156 && (bytes[i + 2] | 32) == b'm'
157 && (bytes[i + 3] | 32) == b'i' =>
158 {
159 return JoinType::Semi;
160 }
161 b'a' if i + 4 <= bytes.len()
162 && (bytes[i + 1] | 32) == b'n'
163 && (bytes[i + 2] | 32) == b't'
164 && (bytes[i + 3] | 32) == b'i' =>
165 {
166 return JoinType::Anti;
167 }
168 _ => {}
169 }
170 }
171 JoinType::Inner
172 }
173
174 #[allow(clippy::should_implement_trait)]
176 pub fn from_str(s: &str) -> Self {
177 Self::parse(s)
178 }
179
180 pub fn needs_unmatched_probe(&self, swapped: bool) -> bool {
183 match self {
184 JoinType::Inner | JoinType::Cross | JoinType::Semi => false,
185 JoinType::Anti => !swapped, JoinType::Left => !swapped, JoinType::Right => swapped, JoinType::Full => true, }
190 }
191
192 pub fn needs_unmatched_build(&self, swapped: bool) -> bool {
195 match self {
196 JoinType::Inner | JoinType::Cross | JoinType::Semi | JoinType::Anti => false,
197 JoinType::Left => swapped, JoinType::Right => !swapped, JoinType::Full => true, }
201 }
202
203 pub fn is_semi(&self) -> bool {
205 matches!(self, JoinType::Semi)
206 }
207
208 pub fn is_anti(&self) -> bool {
210 matches!(self, JoinType::Anti)
211 }
212}
213
214pub struct HashJoinOperator {
230 left: Box<dyn Operator>,
232 right: Box<dyn Operator>,
233
234 join_type: JoinType,
236 build_side: JoinSide,
237 left_key_indices: Vec<usize>,
238 right_key_indices: Vec<usize>,
239 residual_filters: Vec<JoinFilter>,
240
241 build_rows: CompactArc<Vec<Row>>,
245 hash_table: Option<std::sync::Arc<JoinHashTable>>,
246 hash_state_max_bytes: usize,
247 scan_fallback: bool,
248
249 schema: Vec<ColumnInfo>,
251 left_col_count: usize,
252 right_col_count: usize,
253 projection: Option<JoinProjection>,
254 projection_columns: Option<CompactArc<[ColumnSource]>>,
255
256 current_probe_row: Option<RowRef>,
259 current_probe_cursor: ProbeCursor,
260 current_scan_idx: usize,
261 pending_build_idx: Option<usize>,
262 probe_had_match: bool,
263
264 build_matched: Vec<bool>,
266 returning_unmatched_build: bool,
267 unmatched_build_idx: usize,
268
269 is_self_join: bool,
271 self_join_probe_idx: usize,
272
273 cached_null_build: Option<Row>,
275 cached_null_probe: Option<Row>,
276
277 opened: bool,
279 probe_exhausted: bool,
280
281 observed_probe_rows: u64,
284 observed_candidate_rows: u64,
285 observed_deferred_probe_rows: u64,
286 observed_deferred_output_rows: u64,
287 deferred_metrics_recorded: bool,
288}
289
290impl HashJoinOperator {
291 pub fn new(
301 left: Box<dyn Operator>,
302 right: Box<dyn Operator>,
303 join_type: JoinType,
304 left_key_indices: Vec<usize>,
305 right_key_indices: Vec<usize>,
306 build_side: JoinSide,
307 ) -> Self {
308 let mut schema = Vec::new();
311 if join_type.is_semi() || join_type.is_anti() {
312 schema.extend(left.schema().iter().cloned());
313 } else {
314 schema.extend(left.schema().iter().cloned());
315 schema.extend(right.schema().iter().cloned());
316 }
317
318 let left_col_count = left.schema().len();
319 let right_col_count = right.schema().len();
320
321 Self {
322 left,
323 right,
324 join_type,
325 build_side,
326 left_key_indices,
327 right_key_indices,
328 residual_filters: Vec::new(),
329 build_rows: CompactArc::new(Vec::new()),
330 hash_table: None,
331 hash_state_max_bytes: DEFAULT_JOIN_HASH_STATE_MAX_BYTES,
332 scan_fallback: false,
333 schema,
334 left_col_count,
335 right_col_count,
336 projection: None,
337 projection_columns: None,
338 current_probe_row: None,
339 current_probe_cursor: ProbeCursor::default(),
340 current_scan_idx: 0,
341 pending_build_idx: None,
342 probe_had_match: false,
343 build_matched: Vec::new(),
344 returning_unmatched_build: false,
345 unmatched_build_idx: 0,
346 is_self_join: false,
347 self_join_probe_idx: 0,
348 cached_null_build: None,
349 cached_null_probe: None,
350 opened: false,
351 probe_exhausted: false,
352 observed_probe_rows: 0,
353 observed_candidate_rows: 0,
354 observed_deferred_probe_rows: 0,
355 observed_deferred_output_rows: 0,
356 deferred_metrics_recorded: false,
357 }
358 }
359
360 pub fn with_prebuilt(
374 probe: Box<dyn Operator>,
375 hash_state: JoinHashState,
376 join_type: JoinType,
377 left_key_indices: Vec<usize>,
378 right_key_indices: Vec<usize>,
379 build_is_left: bool,
380 build_col_count: usize,
381 ) -> Result<Self> {
382 let build_key_indices = if build_is_left {
383 &left_key_indices
384 } else {
385 &right_key_indices
386 };
387 if !hash_state.matches(hash_state.build_rows(), build_key_indices) {
388 return Err(radixdb_core::Error::internal(
389 "pre-built join hash state key layout mismatch",
390 ));
391 }
392 let build_rows = CompactArc::clone(hash_state.build_rows());
393 let hash_table = std::sync::Arc::clone(hash_state.table());
394 let probe_col_count = probe.schema().len();
395
396 let total_cols = build_col_count + probe_col_count;
399 let mut schema = Vec::with_capacity(total_cols);
400 let (left_col_count, right_col_count) = if build_is_left {
401 for i in 0..build_col_count {
403 schema.push(ColumnInfo::new(get_build_column_name(i)));
404 }
405 schema.extend(probe.schema().iter().cloned());
406 (build_col_count, probe_col_count)
407 } else {
408 schema.extend(probe.schema().iter().cloned());
410 for i in 0..build_col_count {
411 schema.push(ColumnInfo::new(get_build_column_name(i)));
412 }
413 (probe_col_count, build_col_count)
414 };
415
416 let build_side = if build_is_left {
417 JoinSide::Left
418 } else {
419 JoinSide::Right
420 };
421
422 let build_matched = if matches!(join_type, JoinType::Full)
424 || (matches!(join_type, JoinType::Left) && build_is_left)
425 || (matches!(join_type, JoinType::Right) && !build_is_left)
426 {
427 vec![false; build_rows.len()]
428 } else {
429 Vec::new()
430 };
431
432 let (left, right) = if build_is_left {
434 (
436 Box::new(crate::operator::EmptyOperator::new()) as Box<dyn Operator>,
437 probe,
438 )
439 } else {
440 (
442 probe,
443 Box::new(crate::operator::EmptyOperator::new()) as Box<dyn Operator>,
444 )
445 };
446
447 Ok(Self {
448 left,
449 right,
450 join_type,
451 build_side,
452 left_key_indices,
453 right_key_indices,
454 residual_filters: Vec::new(),
455 build_rows,
456 hash_table: Some(hash_table),
457 hash_state_max_bytes: DEFAULT_JOIN_HASH_STATE_MAX_BYTES,
458 scan_fallback: false,
459 schema,
460 left_col_count,
461 right_col_count,
462 projection: None,
463 projection_columns: None,
464 current_probe_row: None,
465 current_probe_cursor: ProbeCursor::default(),
466 current_scan_idx: 0,
467 pending_build_idx: None,
468 probe_had_match: false,
469 build_matched,
470 returning_unmatched_build: false,
471 unmatched_build_idx: 0,
472 is_self_join: false,
473 self_join_probe_idx: 0,
474 cached_null_build: None,
475 cached_null_probe: None,
476 opened: false,
477 probe_exhausted: false,
478 observed_probe_rows: 0,
479 observed_candidate_rows: 0,
480 observed_deferred_probe_rows: 0,
481 observed_deferred_output_rows: 0,
482 deferred_metrics_recorded: false,
483 })
484 }
485
486 pub fn self_join(
491 input: Box<dyn Operator>,
492 join_type: JoinType,
493 left_key_indices: Vec<usize>,
494 right_key_indices: Vec<usize>,
495 ) -> Self {
496 let mut schema = Vec::new();
498 schema.extend(input.schema().iter().cloned());
499 schema.extend(input.schema().iter().cloned());
500
501 let col_count = input.schema().len();
502
503 Self {
505 left: input,
506 right: Box::new(crate::operator::EmptyOperator::new()),
507 join_type,
508 build_side: JoinSide::Left, left_key_indices,
510 right_key_indices,
511 residual_filters: Vec::new(),
512 build_rows: CompactArc::new(Vec::new()),
513 hash_table: None,
514 hash_state_max_bytes: DEFAULT_JOIN_HASH_STATE_MAX_BYTES,
515 scan_fallback: false,
516 schema,
517 left_col_count: col_count,
518 right_col_count: col_count,
519 projection: None,
520 projection_columns: None,
521 current_probe_row: None,
522 current_probe_cursor: ProbeCursor::default(),
523 current_scan_idx: 0,
524 pending_build_idx: None,
525 probe_had_match: false,
526 build_matched: Vec::new(),
527 returning_unmatched_build: false,
528 unmatched_build_idx: 0,
529 is_self_join: true,
530 self_join_probe_idx: 0,
531 cached_null_build: None,
532 cached_null_probe: None,
533 opened: false,
534 probe_exhausted: false,
535 observed_probe_rows: 0,
536 observed_candidate_rows: 0,
537 observed_deferred_probe_rows: 0,
538 observed_deferred_output_rows: 0,
539 deferred_metrics_recorded: false,
540 }
541 }
542
543 pub(crate) fn observed_probe_rows(&self) -> u64 {
544 self.observed_probe_rows
545 }
546
547 pub(crate) fn observed_candidate_rows(&self) -> u64 {
548 self.observed_candidate_rows
549 }
550
551 pub(crate) fn used_scan_fallback(&self) -> bool {
552 self.scan_fallback
553 }
554
555 pub(crate) fn with_hash_state_max_bytes(mut self, max_bytes: usize) -> Self {
556 self.hash_state_max_bytes = max_bytes;
557 self
558 }
559
560 fn publish_deferred_metrics(&mut self) {
561 if self.deferred_metrics_recorded {
562 return;
563 }
564 radixdb_storage::instrumentation::record_join_deferred_rows(
565 self.observed_deferred_output_rows,
566 self.observed_deferred_probe_rows,
567 );
568 self.deferred_metrics_recorded = true;
569 }
570
571 #[inline]
572 fn observe_output(&mut self, row: RowRef) -> RowRef {
573 if row.is_deferred() {
574 self.observed_deferred_output_rows =
575 self.observed_deferred_output_rows.saturating_add(1);
576 }
577 row
578 }
579
580 pub fn with_projection(
587 mut self,
588 columns: Vec<ColumnSource>,
589 projected_schema: Vec<ColumnInfo>,
590 ) -> Self {
591 self.projection_columns = Some(CompactArc::from(columns.clone()));
592 self.projection = Some(JoinProjection { columns });
593 self.schema = projected_schema;
594 self
595 }
596
597 pub fn with_residual_filters(mut self, filters: Vec<JoinFilter>) -> Self {
601 self.residual_filters = filters;
602 self
603 }
604
605 #[inline]
606 fn candidate_passes_residual(&self, probe_row: &RowRef, build_idx: usize) -> Result<bool> {
607 if self.residual_filters.is_empty() {
608 return Ok(true);
609 }
610 let build_row = RowRef::shared(CompactArc::clone(&self.build_rows), build_idx);
611 let (left, right) = match self.build_side {
612 JoinSide::Left => (&build_row, probe_row),
613 JoinSide::Right => (probe_row, &build_row),
614 };
615 for filter in &self.residual_filters {
616 if !filter.matches_row_refs_checked(left, right)? {
617 return Ok(false);
618 }
619 }
620 Ok(true)
621 }
622
623 fn build_key_indices(&self) -> &[usize] {
625 match self.build_side {
626 JoinSide::Left => &self.left_key_indices,
627 JoinSide::Right => &self.right_key_indices,
628 }
629 }
630
631 fn probe_key_indices(&self) -> &[usize] {
632 match self.build_side {
633 JoinSide::Left => &self.right_key_indices,
634 JoinSide::Right => &self.left_key_indices,
635 }
636 }
637
638 #[inline]
639 fn project_left_right_refs(&self, left: RowRef, right: RowRef) -> RowRef {
640 RowRef::projected(
641 left,
642 right,
643 CompactArc::clone(
644 self.projection_columns
645 .as_ref()
646 .expect("projection columns must exist when projection is enabled"),
647 ),
648 )
649 }
650
651 #[inline]
652 fn project_probe_build_match(&self, probe_row: RowRef, build_idx: usize) -> RowRef {
653 let build_row = RowRef::shared(CompactArc::clone(&self.build_rows), build_idx);
654 match self.build_side {
655 JoinSide::Left => self.project_left_right_refs(build_row, probe_row),
656 JoinSide::Right => self.project_left_right_refs(probe_row, build_row),
657 }
658 }
659
660 #[inline]
661 fn project_probe_without_build(&self, probe_row: RowRef, null_build: Row) -> RowRef {
662 let null_build = RowRef::owned(null_build);
663 match self.build_side {
664 JoinSide::Left => self.project_left_right_refs(null_build, probe_row),
665 JoinSide::Right => self.project_left_right_refs(probe_row, null_build),
666 }
667 }
668
669 #[inline]
670 fn project_build_without_probe(&self, build_idx: usize, null_probe: Row) -> RowRef {
671 let build_row = RowRef::shared(CompactArc::clone(&self.build_rows), build_idx);
672 let null_probe = RowRef::owned(null_probe);
673 match self.build_side {
674 JoinSide::Left => self.project_left_right_refs(build_row, null_probe),
675 JoinSide::Right => self.project_left_right_refs(null_probe, build_row),
676 }
677 }
678
679 #[inline]
682 fn null_build_row(&mut self) -> Row {
683 if let Some(ref row) = self.cached_null_build {
684 return row.clone();
685 }
686 let count = match self.build_side {
687 JoinSide::Left => self.left_col_count,
688 JoinSide::Right => self.right_col_count,
689 };
690 let row = Row::from_values(vec![NULL_VALUE; count]);
691 self.cached_null_build = Some(row.clone());
692 row
693 }
694
695 #[inline]
698 fn null_probe_row(&mut self) -> Row {
699 if let Some(ref row) = self.cached_null_probe {
700 return row.clone();
701 }
702 let count = match self.build_side {
703 JoinSide::Left => self.right_col_count,
704 JoinSide::Right => self.left_col_count,
705 };
706 let row = Row::from_values(vec![NULL_VALUE; count]);
707 self.cached_null_probe = Some(row.clone());
708 row
709 }
710
711 #[inline]
715 fn combine_rows_direct(&self, probe_row: RowRef, build_idx: usize) -> RowRef {
716 if self.projection.is_some() {
717 return self.project_probe_build_match(probe_row, build_idx);
718 }
719
720 let probe_is_left = matches!(self.build_side, JoinSide::Right);
724 RowRef::direct_build_composite(
725 probe_row.into_owned(),
726 CompactArc::clone(&self.build_rows),
727 build_idx,
728 probe_is_left,
729 )
730 }
731
732 #[inline]
736 fn combine_rows_ref(&self, probe_row: RowRef, build_row: Row) -> RowRef {
737 if self.projection.is_some() {
738 let build_row = RowRef::owned(build_row);
739 return match self.build_side {
740 JoinSide::Left => self.project_left_right_refs(build_row, probe_row),
741 JoinSide::Right => self.project_left_right_refs(probe_row, build_row),
742 };
743 }
744
745 let probe_row = probe_row.into_owned();
746
747 match self.build_side {
748 JoinSide::Left => {
749 RowRef::Composite(crate::operator::CompositeRow::new(build_row, probe_row))
752 }
753 JoinSide::Right => {
754 RowRef::Composite(crate::operator::CompositeRow::new(probe_row, build_row))
757 }
758 }
759 }
760
761 fn next_probe_row(&mut self) -> Result<Option<RowRef>> {
763 if self.is_self_join {
764 if self.self_join_probe_idx >= self.build_rows.len() {
766 return Ok(None);
767 }
768 let row = self.build_rows[self.self_join_probe_idx].clone();
769 self.self_join_probe_idx += 1;
770 Ok(Some(RowRef::owned(row)))
771 } else {
772 let probe_op = match self.build_side {
774 JoinSide::Left => &mut self.right,
775 JoinSide::Right => &mut self.left,
776 };
777
778 probe_op.next()
779 }
780 }
781
782 #[inline]
783 fn next_build_candidate(&mut self) -> Option<usize> {
784 if let Some(pending) = self.pending_build_idx.take() {
785 return Some(pending);
786 }
787 if self.scan_fallback {
788 if self.current_scan_idx >= self.build_rows.len() {
789 return None;
790 }
791 let candidate = self.current_scan_idx;
792 self.current_scan_idx += 1;
793 Some(candidate)
794 } else {
795 self.hash_table
796 .as_ref()
797 .expect("opened hash join must own a table")
798 .probe_next(&mut self.current_probe_cursor)
799 }
800 }
801
802 #[inline]
803 fn prefetch_build_candidate(&mut self) {
804 self.pending_build_idx = if self.scan_fallback {
805 if self.current_scan_idx < self.build_rows.len() {
806 let candidate = self.current_scan_idx;
807 self.current_scan_idx += 1;
808 Some(candidate)
809 } else {
810 None
811 }
812 } else {
813 self.hash_table
814 .as_ref()
815 .expect("opened hash join must own a table")
816 .probe_next(&mut self.current_probe_cursor)
817 };
818 }
819}
820
821impl Operator for HashJoinOperator {
822 fn open(&mut self) -> Result<()> {
823 self.observed_probe_rows = 0;
824 self.observed_candidate_rows = 0;
825 self.observed_deferred_probe_rows = 0;
826 self.observed_deferred_output_rows = 0;
827 self.deferred_metrics_recorded = false;
828 if let Some(projection) = &self.projection {
829 projection.validate(self.left_col_count, self.right_col_count, self.schema.len())?;
830 }
831 if self.hash_table.is_some() {
833 let probe_op = match self.build_side {
836 JoinSide::Left => &mut self.right,
837 JoinSide::Right => &mut self.left,
838 };
839 if let Err(error) = probe_op.open() {
840 let _ = probe_op.close();
841 return Err(error);
842 }
843 self.opened = true;
844 return Ok(());
845 }
846
847 if let Err(error) = self.left.open() {
849 let _ = self.left.close();
850 return Err(error);
851 }
852 if !self.is_self_join {
853 if let Err(error) = self.right.open() {
854 let _ = self.right.close();
855 let _ = self.left.close();
856 return Err(error);
857 }
858 }
859
860 let open_result = (|| {
861 check_current_query_cancelled()?;
862
863 let build_op = match self.build_side {
865 JoinSide::Left => &mut self.left,
866 JoinSide::Right => &mut self.right,
867 };
868
869 let mut build_rows = Vec::new();
871 while let Some(row_ref) = build_op.next()? {
872 if build_rows.len() & 0xff == 0 {
873 check_current_query_cancelled()?;
874 }
875 build_rows.push(row_ref.into_owned());
876 }
877
878 let build_key_indices = self.build_key_indices().to_vec();
882 let hash_table =
883 if JoinHashTable::fits_retained_budget(build_rows.len(), self.hash_state_max_bytes)
884 {
885 Some(std::sync::Arc::new(JoinHashTable::build(
886 &build_rows,
887 &build_key_indices,
888 )))
889 } else {
890 self.scan_fallback = true;
891 None
892 };
893
894 let needs_build_tracking = matches!(self.join_type, JoinType::Full)
899 || (matches!(self.join_type, JoinType::Left) && self.build_side == JoinSide::Left)
900 || (matches!(self.join_type, JoinType::Right)
901 && self.build_side == JoinSide::Right)
902 || (self.is_self_join && !matches!(self.join_type, JoinType::Inner));
903 if needs_build_tracking {
904 self.build_matched = vec![false; build_rows.len()];
905 }
906
907 self.build_rows = CompactArc::new(build_rows);
909 self.hash_table = hash_table;
910 self.opened = true;
911
912 Ok(())
913 })();
914 if let Err(error) = open_result {
915 let _ = self.close();
916 return Err(error);
917 }
918 Ok(())
919 }
920
921 fn next(&mut self) -> Result<Option<RowRef>> {
922 check_current_query_cancelled()?;
923 if !self.opened {
924 return Err(radixdb_core::Error::internal(
925 "HashJoinOperator::next called before open",
926 ));
927 }
928
929 if self.returning_unmatched_build {
931 while self.unmatched_build_idx < self.build_rows.len() {
932 if self.unmatched_build_idx & 0xff == 0 {
933 check_current_query_cancelled()?;
934 }
935 let idx = self.unmatched_build_idx;
936 self.unmatched_build_idx += 1;
937
938 if !self.build_matched[idx] {
939 if self.projection.is_some() {
940 let null_probe = self.null_probe_row();
941 let row = self.project_build_without_probe(idx, null_probe);
942 return Ok(Some(self.observe_output(row)));
943 }
944 let build_row = self.build_rows[idx].clone();
945 let null_probe = self.null_probe_row();
946 let row = self.combine_rows_ref(RowRef::owned(null_probe), build_row);
947 return Ok(Some(self.observe_output(row)));
948 }
949 }
950 return Ok(None);
951 }
952
953 let mut scanned_probe_rows = 0_usize;
954 loop {
955 while self.current_probe_row.is_some() {
959 if self.observed_candidate_rows & 0xff == 0 {
960 check_current_query_cancelled()?;
961 }
962 let Some(build_idx) = self.next_build_candidate() else {
963 break;
964 };
965 self.observed_candidate_rows = self.observed_candidate_rows.saturating_add(1);
966
967 let build_row = &self.build_rows[build_idx];
968
969 if verify_probe_build_key_equality(
971 self.current_probe_row.as_ref().unwrap(),
972 build_row,
973 self.probe_key_indices(),
974 self.build_key_indices(),
975 ) {
976 if !self.candidate_passes_residual(
977 self.current_probe_row.as_ref().unwrap(),
978 build_idx,
979 )? {
980 continue;
981 }
982 self.probe_had_match = true;
983
984 if self.join_type.is_semi() {
986 let probe_row = self.current_probe_row.take().unwrap();
987 self.pending_build_idx = None;
988 self.current_probe_cursor = ProbeCursor::default();
989 if self.projection.is_some() {
990 let row = self.project_probe_build_match(probe_row, build_idx);
991 return Ok(Some(self.observe_output(row)));
992 }
993 return Ok(Some(self.observe_output(probe_row)));
994 }
995
996 if self.join_type.is_anti() {
999 self.current_probe_row = None;
1000 self.pending_build_idx = None;
1001 self.current_probe_cursor = ProbeCursor::default();
1002 continue;
1003 }
1004
1005 if !self.build_matched.is_empty() {
1007 self.build_matched[build_idx] = true;
1008 }
1009
1010 self.prefetch_build_candidate();
1015 let probe_row = if self.pending_build_idx.is_none() {
1016 self.current_probe_row.take().unwrap()
1018 } else {
1019 self.current_probe_row.as_ref().unwrap().clone()
1021 };
1022 let row = self.combine_rows_direct(probe_row, build_idx);
1023 return Ok(Some(self.observe_output(row)));
1024 }
1025 }
1026
1027 if self.join_type.is_anti() && !self.probe_had_match {
1031 if let Some(probe_row) = self.current_probe_row.take() {
1032 if self.projection.is_some() {
1033 let null_build = self.null_build_row();
1034 let row = self.project_probe_without_build(probe_row, null_build);
1035 return Ok(Some(self.observe_output(row)));
1036 }
1037 return Ok(Some(self.observe_output(probe_row)));
1038 }
1039 }
1040
1041 let needs_unmatched_probe = matches!(self.join_type, JoinType::Full)
1047 || (matches!(self.join_type, JoinType::Right) && self.build_side == JoinSide::Left)
1048 || (matches!(self.join_type, JoinType::Left) && self.build_side == JoinSide::Right);
1049
1050 if needs_unmatched_probe && !self.probe_had_match {
1051 if let Some(probe_row) = self.current_probe_row.take() {
1052 if self.projection.is_some() {
1053 let null_build = self.null_build_row();
1054 let row = self.project_probe_without_build(probe_row, null_build);
1055 return Ok(Some(self.observe_output(row)));
1056 }
1057 let null_build = self.null_build_row();
1058 let row = self.combine_rows_ref(probe_row, null_build);
1059 return Ok(Some(self.observe_output(row)));
1060 }
1061 }
1062
1063 if scanned_probe_rows & 0xff == 0 {
1065 check_current_query_cancelled()?;
1066 }
1067 let next_probe = self.next_probe_row()?;
1068 scanned_probe_rows = scanned_probe_rows.saturating_add(1);
1069 match next_probe {
1070 Some(probe_row) => {
1071 self.observed_probe_rows = self.observed_probe_rows.saturating_add(1);
1072 if probe_row.is_deferred() {
1073 self.observed_deferred_probe_rows =
1074 self.observed_deferred_probe_rows.saturating_add(1);
1075 }
1076 if self.scan_fallback {
1077 self.current_scan_idx = 0;
1078 self.current_probe_cursor = ProbeCursor::default();
1079 } else {
1080 let probe_key_indices = self.probe_key_indices();
1082 let hash = hash_keys_with(probe_key_indices, |idx| probe_row.get(idx));
1083 self.current_probe_cursor = self
1084 .hash_table
1085 .as_ref()
1086 .expect("opened hash join must own a table")
1087 .probe_cursor(hash);
1088 }
1089 self.pending_build_idx = None;
1090
1091 self.current_probe_row = Some(probe_row);
1093 self.probe_had_match = false;
1094 }
1095 None => {
1096 self.probe_exhausted = true;
1098
1099 if !self.build_matched.is_empty() {
1105 self.returning_unmatched_build = true;
1106 self.unmatched_build_idx = 0;
1107 return self.next();
1109 }
1110
1111 return Ok(None);
1112 }
1113 }
1114 }
1115 }
1116
1117 fn close(&mut self) -> Result<()> {
1118 self.publish_deferred_metrics();
1119 let left = self.left.close();
1120 let right = if self.is_self_join {
1121 Ok(())
1122 } else {
1123 self.right.close()
1124 };
1125 left.and(right)
1126 }
1127
1128 fn schema(&self) -> &[ColumnInfo] {
1129 &self.schema
1130 }
1131
1132 fn estimated_rows(&self) -> Option<usize> {
1133 let left_est = self.left.estimated_rows()?;
1136 let right_est = self.right.estimated_rows()?;
1137
1138 Some(match self.join_type {
1139 JoinType::Inner => left_est.min(right_est),
1140 JoinType::Left => left_est,
1141 JoinType::Right => right_est,
1142 JoinType::Full => left_est + right_est,
1143 JoinType::Cross => left_est * right_est,
1144 JoinType::Semi => left_est.min(right_est), JoinType::Anti => left_est, })
1147 }
1148
1149 fn name(&self) -> &str {
1150 match self.join_type {
1151 JoinType::Inner => "HashJoin (INNER)",
1152 JoinType::Left => "HashJoin (LEFT)",
1153 JoinType::Right => "HashJoin (RIGHT)",
1154 JoinType::Full => "HashJoin (FULL)",
1155 JoinType::Cross => "HashJoin (CROSS)",
1156 JoinType::Semi => "HashJoin (SEMI)",
1157 JoinType::Anti => "HashJoin (ANTI)",
1158 }
1159 }
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164 use super::*;
1165 use crate::operator::MaterializedOperator;
1166 use radixdb_core::Value;
1167 use radixdb_storage::instrumentation;
1168
1169 fn make_rows(data: Vec<Vec<i64>>) -> Vec<Row> {
1170 data.into_iter()
1171 .map(|vals| Row::from_values(vals.into_iter().map(Value::integer).collect()))
1172 .collect()
1173 }
1174
1175 fn make_operator(data: Vec<Vec<i64>>, cols: Vec<&str>) -> Box<dyn Operator> {
1176 let rows = make_rows(data);
1177 let schema = cols.into_iter().map(ColumnInfo::new).collect();
1178 Box::new(MaterializedOperator::new(rows, schema))
1179 }
1180
1181 fn collect_results(op: &mut dyn Operator) -> Result<Vec<Row>> {
1182 let mut results = Vec::new();
1183 op.open()?;
1184 while let Some(row_ref) = op.next()? {
1185 results.push(row_ref.into_owned());
1186 }
1187 op.close()?;
1188 Ok(results)
1189 }
1190
1191 fn canonical_rows(rows: Vec<Row>) -> Vec<String> {
1192 let mut rows = rows
1193 .into_iter()
1194 .map(|row| format!("{row:?}"))
1195 .collect::<Vec<_>>();
1196 rows.sort_unstable();
1197 rows
1198 }
1199
1200 fn execute_budget_case(
1201 join_type: JoinType,
1202 build_side: JoinSide,
1203 max_bytes: usize,
1204 ) -> (Vec<String>, bool) {
1205 let left = make_operator(
1206 vec![vec![1, 10], vec![1, 11], vec![2, 20], vec![4, 40]],
1207 vec!["id", "left_value"],
1208 );
1209 let right = make_operator(
1210 vec![vec![1, 100], vec![1, 101], vec![3, 300]],
1211 vec!["id", "right_value"],
1212 );
1213 let mut join = HashJoinOperator::new(left, right, join_type, vec![0], vec![0], build_side)
1214 .with_hash_state_max_bytes(max_bytes);
1215 let rows = collect_results(&mut join).unwrap();
1216 (canonical_rows(rows), join.used_scan_fallback())
1217 }
1218
1219 #[test]
1220 fn bounded_scan_fallback_matches_hash_semantics_for_all_join_types() {
1221 let cases = [
1222 (JoinType::Inner, JoinSide::Right),
1223 (JoinType::Left, JoinSide::Right),
1224 (JoinType::Right, JoinSide::Left),
1225 (JoinType::Full, JoinSide::Right),
1226 (JoinType::Semi, JoinSide::Right),
1227 (JoinType::Anti, JoinSide::Right),
1228 ];
1229
1230 for (join_type, build_side) in cases {
1231 let (hashed, hash_fallback) = execute_budget_case(join_type, build_side, usize::MAX);
1232 let (scanned, scan_fallback) = execute_budget_case(join_type, build_side, 0);
1233 assert!(!hash_fallback, "{join_type:?} unexpectedly used fallback");
1234 assert!(scan_fallback, "{join_type:?} did not use fallback");
1235 assert_eq!(scanned, hashed, "{join_type:?} fallback changed results");
1236 }
1237 }
1238
1239 #[test]
1240 fn test_inner_join() {
1241 let left = make_operator(
1242 vec![vec![1, 10], vec![2, 20], vec![3, 30]],
1243 vec!["id", "value"],
1244 );
1245 let right = make_operator(vec![vec![1, 100], vec![3, 300]], vec!["id", "data"]);
1246
1247 let mut join = HashJoinOperator::new(
1248 left,
1249 right,
1250 JoinType::Inner,
1251 vec![0], vec![0], JoinSide::Right,
1254 );
1255
1256 let results = collect_results(&mut join).unwrap();
1257
1258 assert_eq!(results.len(), 2);
1260
1261 let row1 = &results[0];
1263 assert_eq!(row1.get(0), Some(&Value::integer(1)));
1264 assert_eq!(row1.get(1), Some(&Value::integer(10)));
1265 assert_eq!(row1.get(2), Some(&Value::integer(1)));
1266 assert_eq!(row1.get(3), Some(&Value::integer(100)));
1267 }
1268
1269 #[test]
1270 fn test_inner_join_projection_materializes_selected_columns_only() {
1271 let left = make_operator(
1272 vec![vec![1, 10, 1000], vec![2, 20, 2000], vec![3, 30, 3000]],
1273 vec!["id", "value", "unused_left"],
1274 );
1275 let right = make_operator(
1276 vec![vec![1, 100, 9000], vec![3, 300, 7000]],
1277 vec!["id", "data", "unused_right"],
1278 );
1279
1280 let projected_schema = vec![ColumnInfo::new("value"), ColumnInfo::new("data")];
1281 let mut join = HashJoinOperator::new(
1282 left,
1283 right,
1284 JoinType::Inner,
1285 vec![0],
1286 vec![0],
1287 JoinSide::Right,
1288 )
1289 .with_projection(
1290 vec![ColumnSource::Outer(1), ColumnSource::Inner(1)],
1291 projected_schema,
1292 );
1293
1294 assert_eq!(join.schema().len(), 2);
1295 let results = collect_results(&mut join).unwrap();
1296
1297 assert_eq!(results.len(), 2);
1298 assert!(results.iter().all(|row| row.len() == 2));
1299 assert_eq!(results[0].get(0), Some(&Value::integer(10)));
1300 assert_eq!(results[0].get(1), Some(&Value::integer(100)));
1301 assert_eq!(results[1].get(0), Some(&Value::integer(30)));
1302 assert_eq!(results[1].get(1), Some(&Value::integer(300)));
1303 }
1304
1305 #[test]
1306 fn projected_hash_chain_keeps_probe_rows_deferred_between_edges() {
1307 let first_left = make_operator(vec![vec![1, 10], vec![2, 20]], vec!["id", "payload"]);
1308 let first_right = make_operator(vec![vec![1, 100], vec![2, 200]], vec!["id", "dictionary"]);
1309 let first = HashJoinOperator::new(
1310 first_left,
1311 first_right,
1312 JoinType::Inner,
1313 vec![0],
1314 vec![0],
1315 JoinSide::Right,
1316 )
1317 .with_projection(
1318 vec![
1319 ColumnSource::Outer(0),
1320 ColumnSource::Outer(1),
1321 ColumnSource::Inner(1),
1322 ],
1323 vec![
1324 ColumnInfo::new("id"),
1325 ColumnInfo::new("payload"),
1326 ColumnInfo::new("dictionary"),
1327 ],
1328 );
1329
1330 let second_right = make_operator(vec![vec![1, 1000], vec![2, 2000]], vec!["id", "leaf"]);
1331 let mut second = HashJoinOperator::new(
1332 Box::new(first),
1333 second_right,
1334 JoinType::Inner,
1335 vec![0],
1336 vec![0],
1337 JoinSide::Right,
1338 )
1339 .with_projection(
1340 vec![
1341 ColumnSource::Outer(1),
1342 ColumnSource::Outer(2),
1343 ColumnSource::Inner(1),
1344 ],
1345 vec![
1346 ColumnInfo::new("payload"),
1347 ColumnInfo::new("dictionary"),
1348 ColumnInfo::new("leaf"),
1349 ],
1350 );
1351
1352 instrumentation::begin_join_execution_probe();
1353 second.open().unwrap();
1354 let first_output = second.next().unwrap().unwrap();
1355 assert!(first_output.is_deferred());
1356 assert_eq!(first_output.get(0), Some(&Value::integer(10)));
1357 assert_eq!(first_output.get(1), Some(&Value::integer(100)));
1358 assert_eq!(first_output.get(2), Some(&Value::integer(1000)));
1359 assert_eq!(first_output.into_owned().len(), 3);
1360
1361 let second_output = second.next().unwrap().unwrap();
1362 assert!(second_output.is_deferred());
1363 assert_eq!(second_output.get(0), Some(&Value::integer(20)));
1364 assert_eq!(second_output.get(1), Some(&Value::integer(200)));
1365 assert_eq!(second_output.get(2), Some(&Value::integer(2000)));
1366 assert!(second.next().unwrap().is_none());
1367 second.close().unwrap();
1368
1369 let probe = instrumentation::end_join_execution_probe();
1370 assert_eq!(probe.deferred_rows, 4);
1371 assert_eq!(probe.deferred_rows_consumed, 2);
1372 }
1373
1374 #[test]
1375 fn public_hash_join_rejects_invalid_projection_before_reading_rows() {
1376 let left = make_operator(vec![vec![1]], vec!["left_id"]);
1377 let right = make_operator(vec![vec![1]], vec!["right_id"]);
1378 let mut join = HashJoinOperator::new(
1379 left,
1380 right,
1381 JoinType::Inner,
1382 vec![0],
1383 vec![0],
1384 JoinSide::Right,
1385 )
1386 .with_projection(
1387 vec![ColumnSource::Inner(1)],
1388 vec![ColumnInfo::new("invalid")],
1389 );
1390
1391 assert!(join.open().is_err());
1392 }
1393
1394 #[test]
1395 fn test_left_join() {
1396 let left = make_operator(
1397 vec![vec![1, 10], vec![2, 20], vec![3, 30]],
1398 vec!["id", "value"],
1399 );
1400 let right = make_operator(vec![vec![1, 100]], vec!["id", "data"]);
1401
1402 let mut join = HashJoinOperator::new(
1403 left,
1404 right,
1405 JoinType::Left,
1406 vec![0],
1407 vec![0],
1408 JoinSide::Right,
1409 );
1410
1411 let results = collect_results(&mut join).unwrap();
1412
1413 assert_eq!(results.len(), 3);
1415
1416 let row2 = results
1418 .iter()
1419 .find(|r| r.get(0) == Some(&Value::integer(2)))
1420 .unwrap();
1421 assert!(row2.get(2).unwrap().is_null());
1422 assert!(row2.get(3).unwrap().is_null());
1423 }
1424
1425 #[test]
1426 fn test_left_join_projection_uses_sparse_null_build_side() {
1427 let left = make_operator(
1428 vec![vec![1, 10], vec![2, 20], vec![3, 30]],
1429 vec!["id", "value"],
1430 );
1431 let right = make_operator(vec![vec![1, 100]], vec!["id", "data"]);
1432
1433 let mut join = HashJoinOperator::new(
1434 left,
1435 right,
1436 JoinType::Left,
1437 vec![0],
1438 vec![0],
1439 JoinSide::Right,
1440 )
1441 .with_projection(
1442 vec![ColumnSource::Outer(1), ColumnSource::Inner(1)],
1443 vec![ColumnInfo::new("value"), ColumnInfo::new("data")],
1444 );
1445
1446 let results = collect_results(&mut join).unwrap();
1447
1448 assert_eq!(results.len(), 3);
1449 let row2 = results
1450 .iter()
1451 .find(|row| row.get(0) == Some(&Value::integer(20)))
1452 .unwrap();
1453 assert_eq!(row2.len(), 2);
1454 assert!(row2.get(1).unwrap().is_null());
1455 }
1456
1457 #[test]
1458 fn test_self_join() {
1459 let input = make_operator(
1460 vec![vec![1, 10], vec![2, 10], vec![3, 20]],
1461 vec!["id", "age"],
1462 );
1463
1464 let mut join = HashJoinOperator::self_join(
1466 input,
1467 JoinType::Inner,
1468 vec![1], vec![1], );
1471
1472 let results = collect_results(&mut join).unwrap();
1473
1474 assert_eq!(results.len(), 5);
1480 }
1481
1482 #[test]
1483 fn test_empty_build() {
1484 let left = make_operator(vec![vec![1, 10], vec![2, 20]], vec!["id", "value"]);
1485 let right = make_operator(vec![], vec!["id", "data"]);
1486
1487 let mut join = HashJoinOperator::new(
1488 left,
1489 right,
1490 JoinType::Inner,
1491 vec![0],
1492 vec![0],
1493 JoinSide::Right,
1494 );
1495
1496 let results = collect_results(&mut join).unwrap();
1497 assert_eq!(results.len(), 0);
1498 }
1499
1500 #[test]
1501 fn test_multi_key_join() {
1502 let left = make_operator(
1503 vec![vec![1, 10, 100], vec![1, 20, 200], vec![2, 10, 300]],
1504 vec!["a", "b", "val"],
1505 );
1506 let right = make_operator(
1507 vec![vec![1, 10, 1000], vec![1, 20, 2000]],
1508 vec!["a", "b", "data"],
1509 );
1510
1511 let mut join = HashJoinOperator::new(
1512 left,
1513 right,
1514 JoinType::Inner,
1515 vec![0, 1], vec![0, 1], JoinSide::Right,
1518 );
1519
1520 let results = collect_results(&mut join).unwrap();
1521
1522 assert_eq!(results.len(), 2);
1524 }
1525
1526 #[test]
1527 fn test_semi_join() {
1528 let left = make_operator(
1530 vec![vec![1, 100], vec![2, 200], vec![3, 300]],
1531 vec!["id", "value"],
1532 );
1533 let right = make_operator(
1535 vec![vec![1, 10], vec![1, 20], vec![3, 30]],
1536 vec!["user_id", "order_id"],
1537 );
1538
1539 let mut join = HashJoinOperator::new(
1540 left,
1541 right,
1542 JoinType::Semi,
1543 vec![0], vec![0], JoinSide::Right,
1546 );
1547
1548 let results = collect_results(&mut join).unwrap();
1549
1550 assert_eq!(results.len(), 2);
1555
1556 assert_eq!(join.schema().len(), 2);
1558
1559 let ids: Vec<i64> = results
1561 .iter()
1562 .map(|r| r.get(0).unwrap().as_int64().unwrap())
1563 .collect();
1564 assert!(ids.contains(&1));
1565 assert!(ids.contains(&3));
1566 assert!(!ids.contains(&2));
1567 }
1568
1569 #[test]
1570 fn test_semi_join_projection_returns_requested_probe_columns_only() {
1571 let left = make_operator(
1572 vec![vec![1, 100, 1000], vec![2, 200, 2000], vec![3, 300, 3000]],
1573 vec!["id", "value", "unused_left"],
1574 );
1575 let right = make_operator(
1576 vec![vec![1, 10], vec![1, 20], vec![3, 30]],
1577 vec!["user_id", "order_id"],
1578 );
1579
1580 let mut join = HashJoinOperator::new(
1581 left,
1582 right,
1583 JoinType::Semi,
1584 vec![0],
1585 vec![0],
1586 JoinSide::Right,
1587 )
1588 .with_projection(vec![ColumnSource::Outer(1)], vec![ColumnInfo::new("value")]);
1589
1590 let results = collect_results(&mut join).unwrap();
1591
1592 assert_eq!(join.schema().len(), 1);
1593 assert_eq!(results.len(), 2);
1594 assert!(results.iter().all(|row| row.len() == 1));
1595 let values: Vec<i64> = results
1596 .iter()
1597 .map(|row| row.get(0).unwrap().as_int64().unwrap())
1598 .collect();
1599 assert!(values.contains(&100));
1600 assert!(values.contains(&300));
1601 assert!(!values.contains(&200));
1602 }
1603
1604 #[test]
1605 fn test_anti_join() {
1606 let left = make_operator(
1608 vec![vec![1, 100], vec![2, 200], vec![3, 300]],
1609 vec!["id", "value"],
1610 );
1611 let right = make_operator(vec![vec![1, 10], vec![3, 30]], vec!["user_id", "order_id"]);
1613
1614 let mut join = HashJoinOperator::new(
1615 left,
1616 right,
1617 JoinType::Anti,
1618 vec![0], vec![0], JoinSide::Right,
1621 );
1622
1623 let results = collect_results(&mut join).unwrap();
1624
1625 assert_eq!(results.len(), 1);
1630
1631 assert_eq!(join.schema().len(), 2);
1633
1634 let row = &results[0];
1636 assert_eq!(row.get(0), Some(&Value::integer(2)));
1637 assert_eq!(row.get(1), Some(&Value::integer(200)));
1638 }
1639
1640 #[test]
1641 fn test_anti_join_projection_returns_requested_probe_columns_only() {
1642 let left = make_operator(
1643 vec![vec![1, 100, 1000], vec![2, 200, 2000], vec![3, 300, 3000]],
1644 vec!["id", "value", "unused_left"],
1645 );
1646 let right = make_operator(vec![vec![1, 10], vec![3, 30]], vec!["user_id", "order_id"]);
1647
1648 let mut join = HashJoinOperator::new(
1649 left,
1650 right,
1651 JoinType::Anti,
1652 vec![0],
1653 vec![0],
1654 JoinSide::Right,
1655 )
1656 .with_projection(vec![ColumnSource::Outer(1)], vec![ColumnInfo::new("value")]);
1657
1658 let results = collect_results(&mut join).unwrap();
1659
1660 assert_eq!(join.schema().len(), 1);
1661 assert_eq!(results.len(), 1);
1662 assert_eq!(results[0].len(), 1);
1663 assert_eq!(results[0].get(0), Some(&Value::integer(200)));
1664 }
1665
1666 #[test]
1667 fn test_anti_join_empty_right() {
1668 let left = make_operator(
1670 vec![vec![1, 100], vec![2, 200], vec![3, 300]],
1671 vec!["id", "value"],
1672 );
1673 let right = make_operator(vec![], vec!["user_id", "order_id"]);
1675
1676 let mut join = HashJoinOperator::new(
1677 left,
1678 right,
1679 JoinType::Anti,
1680 vec![0],
1681 vec![0],
1682 JoinSide::Right,
1683 );
1684
1685 let results = collect_results(&mut join).unwrap();
1686
1687 assert_eq!(results.len(), 3);
1689 }
1690
1691 #[test]
1692 fn test_semi_join_empty_right() {
1693 let left = make_operator(
1695 vec![vec![1, 100], vec![2, 200], vec![3, 300]],
1696 vec!["id", "value"],
1697 );
1698 let right = make_operator(vec![], vec!["user_id", "order_id"]);
1700
1701 let mut join = HashJoinOperator::new(
1702 left,
1703 right,
1704 JoinType::Semi,
1705 vec![0],
1706 vec![0],
1707 JoinSide::Right,
1708 );
1709
1710 let results = collect_results(&mut join).unwrap();
1711
1712 assert_eq!(results.len(), 0);
1714 }
1715}