1use crate::context::ExecutionContext;
95use crate::expression::{JoinFilter, RowFilter};
96use crate::hash_table::JoinHashState;
97use crate::operator::{ColumnInfo, MaterializedOperator, Operator, OrderingProperty, RowRef};
98use crate::operators::hash_join::{HashJoinOperator, JoinSide, JoinType};
99use crate::operators::merge_join::MergeJoinOperator;
100use crate::operators::nested_loop_join::NestedLoopJoinOperator;
101use crate::parallel::{
102 parallel_hash_join_cancellable, parallel_join_state_retained_bytes, ParallelConfig,
103 ParallelHashJoinOperator, DEFAULT_PARALLEL_JOIN_THRESHOLD,
104};
105use crate::result::OperatorExecutorResult;
106use crate::utils::{extract_join_keys_and_residual, JoinProjectionIndices, RetainedRowsBudget};
107use radixdb_core::value::NULL_VALUE;
108use radixdb_core::{CompactArc, CompactVec};
109use radixdb_core::{Result, Row, RowVec, Value};
110use radixdb_functions::global_registry;
111use radixdb_sql::ast::Expression;
112use radixdb_storage::instrumentation::{self, JoinExecutionKind, JoinExecutionRecord};
113use radixdb_storage::DeferredRow;
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum RuntimeJoinAlgorithm {
118 HashJoin,
120 MergeJoin,
122 NestedLoop,
124}
125
126#[derive(Debug, Clone)]
128pub struct RuntimeJoinDecision {
129 pub algorithm: RuntimeJoinAlgorithm,
131 pub swap_sides: bool,
133 pub explanation: String,
135}
136
137impl RuntimeJoinDecision {
138 pub fn use_hash_join(&self) -> bool {
140 self.algorithm == RuntimeJoinAlgorithm::HashJoin
141 }
142
143 pub fn use_merge_join(&self) -> bool {
145 self.algorithm == RuntimeJoinAlgorithm::MergeJoin
146 }
147
148 pub fn use_nested_loop(&self) -> bool {
150 self.algorithm == RuntimeJoinAlgorithm::NestedLoop
151 }
152}
153
154const STREAMING_LIMIT_THRESHOLD: u64 = 1000;
164
165#[derive(Debug)]
167pub struct JoinResult {
168 pub rows: JoinRows,
170 pub columns: Vec<String>,
172}
173
174#[derive(Debug)]
175pub enum JoinRows {
176 Owned(RowVec),
177 Deferred(Vec<DeferredRow>),
178}
179
180impl JoinRows {
181 #[inline]
182 pub fn len(&self) -> usize {
183 match self {
184 Self::Owned(rows) => rows.len(),
185 Self::Deferred(rows) => rows.len(),
186 }
187 }
188
189 pub fn is_empty(&self) -> bool {
191 self.len() == 0
192 }
193
194 #[inline]
195 fn owned_len(&self) -> usize {
196 match self {
197 Self::Owned(rows) => rows.len(),
198 Self::Deferred(_) => 0,
199 }
200 }
201
202 pub fn into_owned(self) -> RowVec {
203 match self {
204 Self::Owned(rows) => rows,
205 Self::Deferred(rows) => {
206 let mut owned = RowVec::with_capacity(rows.len());
207 for (row_id, row) in rows.into_iter().enumerate() {
208 owned.push((row_id as i64, row.into_owned()));
209 }
210 owned
211 }
212 }
213 }
214
215 pub fn into_deferred(self) -> Vec<DeferredRow> {
216 match self {
217 Self::Deferred(rows) => rows,
218 Self::Owned(mut rows) => rows
219 .drain_rows()
220 .map(DeferredRow::owned)
221 .collect::<Vec<_>>(),
222 }
223 }
224}
225
226#[derive(Debug, Clone)]
228pub struct JoinAnalysis {
229 pub left_key_indices: Vec<usize>,
231 pub right_key_indices: Vec<usize>,
233 pub residual_conditions: Vec<Expression>,
235 pub join_type: JoinType,
237 pub join_type_str: String,
239}
240
241#[derive(Debug, Clone)]
244struct JoinConfig {
245 algorithm: RuntimeJoinAlgorithm,
247 build_left: bool,
249}
250
251#[derive(Debug, Clone, Default)]
253pub struct JoinInputOrderings {
254 pub left: OrderingProperty,
255 pub right: OrderingProperty,
256}
257
258impl JoinInputOrderings {
259 pub fn new(left: OrderingProperty, right: OrderingProperty) -> Self {
260 Self { left, right }
261 }
262}
263
264struct MergeExecutionRequest<'a> {
265 left_rows: CompactArc<Vec<Row>>,
266 right_rows: CompactArc<Vec<Row>>,
267 columns: (&'a [String], &'a [String]),
268 analysis: &'a JoinAnalysis,
269 ordering: JoinInputOrderings,
270 limit: Option<u64>,
271 ctx: &'a ExecutionContext,
272}
273
274pub struct JoinRequest<'a> {
280 pub left_rows: CompactArc<Vec<Row>>,
282 pub right_rows: CompactArc<Vec<Row>>,
284 pub left_columns: &'a [String],
286 pub right_columns: &'a [String],
288 pub condition: Option<&'a Expression>,
290 pub join_type: &'a str,
292 pub limit: Option<u64>,
294 pub ctx: &'a ExecutionContext,
296 pub algorithm_hint: Option<&'a RuntimeJoinDecision>,
299 pub ordering: JoinInputOrderings,
301 pub projection: Option<&'a JoinProjectionIndices>,
307}
308
309pub struct StreamingJoinRequest<'a> {
327 pub build_rows: CompactArc<Vec<Row>>,
329 pub build_columns: &'a [String],
331 pub probe_source: Box<dyn Operator>,
333 pub probe_columns: Vec<String>,
335 pub condition: Option<&'a Expression>,
337 pub join_type: &'a str,
339 pub build_is_left: bool,
341 pub limit: Option<u64>,
343 pub ctx: &'a ExecutionContext,
345 pub pre_built_hash_state: Option<JoinHashState>,
348 pub projection: Option<&'a JoinProjectionIndices>,
353}
354
355pub type StreamingJoinResult = (
356 Box<dyn radixdb_storage::QueryResult>,
357 CompactArc<Vec<String>>,
358);
359
360struct PreparedStreamingHashJoin {
361 operator: HashJoinOperator,
362 columns: Vec<String>,
363 started: std::time::Instant,
364 build_row_count: u64,
365 build_is_left: bool,
366 left_width: u64,
367 right_width: u64,
368}
369
370struct ObservedStreamingHashJoin {
373 inner: HashJoinOperator,
374 started: std::time::Instant,
375 build_row_count: u64,
376 build_is_left: bool,
377 left_width: u64,
378 right_width: u64,
379 output_width: u64,
380 output_rows: u64,
381 recorded: bool,
382}
383
384struct ObservedParallelHashJoin {
386 inner: ParallelHashJoinOperator,
387 started: std::time::Instant,
388 build_row_count: u64,
389 build_is_left: bool,
390 left_width: u64,
391 right_width: u64,
392 output_width: u64,
393 output_rows: u64,
394 recorded: bool,
395}
396
397impl ObservedParallelHashJoin {
398 fn publish(&mut self) {
399 if self.recorded {
400 return;
401 }
402 let probe_rows = self.inner.observed_probe_rows();
403 let (left_rows, right_rows) = if self.build_is_left {
404 (self.build_row_count, probe_rows)
405 } else {
406 (probe_rows, self.build_row_count)
407 };
408 instrumentation::record_join_execution(
409 JoinExecutionKind::HashParallel,
410 JoinExecutionRecord {
411 left_rows,
412 right_rows,
413 output_rows: self.output_rows,
414 left_width: self.left_width,
415 right_width: self.right_width,
416 output_width: self.output_width,
417 candidate_pairs: self.inner.observed_candidate_rows(),
418 wall_nanos: self.started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
419 ..JoinExecutionRecord::default()
420 },
421 );
422 self.recorded = true;
423 }
424}
425
426impl Operator for ObservedParallelHashJoin {
427 fn open(&mut self) -> Result<()> {
428 self.inner.open()
429 }
430
431 fn next(&mut self) -> Result<Option<RowRef>> {
432 let row = self.inner.next()?;
433 if row.is_some() {
434 self.output_rows = self.output_rows.saturating_add(1);
435 }
436 Ok(row)
437 }
438
439 fn close(&mut self) -> Result<()> {
440 let result = self.inner.close();
441 self.publish();
442 result
443 }
444
445 fn schema(&self) -> &[ColumnInfo] {
446 self.inner.schema()
447 }
448
449 fn estimated_rows(&self) -> Option<usize> {
450 self.inner.estimated_rows()
451 }
452
453 fn ordering(&self) -> OrderingProperty {
454 self.inner.ordering()
455 }
456
457 fn name(&self) -> &str {
458 self.inner.name()
459 }
460}
461
462impl ObservedStreamingHashJoin {
463 fn new(plan: PreparedStreamingHashJoin) -> (Self, Vec<String>) {
464 let columns = plan.columns;
465 let output_width = columns.len() as u64;
466 (
467 Self {
468 inner: plan.operator,
469 started: plan.started,
470 build_row_count: plan.build_row_count,
471 build_is_left: plan.build_is_left,
472 left_width: plan.left_width,
473 right_width: plan.right_width,
474 output_width,
475 output_rows: 0,
476 recorded: false,
477 },
478 columns,
479 )
480 }
481
482 fn publish(&mut self) {
483 if self.recorded {
484 return;
485 }
486 let probe_rows = self.inner.observed_probe_rows();
487 let execution_kind = if self.inner.used_scan_fallback() {
488 JoinExecutionKind::NestedLoop
489 } else {
490 JoinExecutionKind::HashStreaming
491 };
492 let (left_rows, right_rows) = if self.build_is_left {
493 (self.build_row_count, probe_rows)
494 } else {
495 (probe_rows, self.build_row_count)
496 };
497 instrumentation::record_join_execution(
498 execution_kind,
499 JoinExecutionRecord {
500 left_rows,
501 right_rows,
502 output_rows: self.output_rows,
503 left_width: self.left_width,
504 right_width: self.right_width,
505 output_width: self.output_width,
506 candidate_pairs: self.inner.observed_candidate_rows(),
507 wall_nanos: self.started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
508 ..JoinExecutionRecord::default()
509 },
510 );
511 self.recorded = true;
512 }
513}
514
515impl Operator for ObservedStreamingHashJoin {
516 fn open(&mut self) -> Result<()> {
517 self.inner.open()
518 }
519
520 fn next(&mut self) -> Result<Option<RowRef>> {
521 let row = self.inner.next()?;
522 if row.is_some() {
523 self.output_rows = self.output_rows.saturating_add(1);
524 }
525 Ok(row)
526 }
527
528 fn close(&mut self) -> Result<()> {
529 let result = self.inner.close();
530 self.publish();
531 result
532 }
533
534 fn schema(&self) -> &[ColumnInfo] {
535 self.inner.schema()
536 }
537
538 fn estimated_rows(&self) -> Option<usize> {
539 self.inner.estimated_rows()
540 }
541
542 fn ordering(&self) -> OrderingProperty {
543 self.inner.ordering()
544 }
545
546 fn name(&self) -> &str {
547 self.inner.name()
548 }
549}
550
551pub struct JoinExecutor {}
558
559const HASH_BUILD_WIDTH_SAMPLE_ROWS: usize = 256;
564
565impl JoinExecutor {
566 pub fn new() -> Self {
568 Self {}
569 }
570
571 pub fn execute(&self, request: JoinRequest<'_>) -> Result<JoinResult> {
579 let started = std::time::Instant::now();
580 let left_rows = request.left_rows.len() as u64;
581 let right_rows = request.right_rows.len() as u64;
582 let left_width = request.left_columns.len() as u64;
583 let right_width = request.right_columns.len() as u64;
584 let mut all_columns = request.left_columns.to_vec();
586 all_columns.extend(request.right_columns.iter().cloned());
587
588 let analysis = self.analyze(
590 request.left_columns,
591 request.right_columns,
592 request.condition,
593 request.join_type,
594 );
595 let merge_ordering_certified = request
596 .ordering
597 .left
598 .proves_ascending_nulls_last(&analysis.left_key_indices)
599 && request
600 .ordering
601 .right
602 .proves_ascending_nulls_last(&analysis.right_key_indices);
603
604 let mut config = if let Some(hint) = request.algorithm_hint {
607 self.convert_runtime_decision(hint, &analysis)
608 } else {
609 self.select_algorithm(
610 &analysis,
611 &request.left_rows,
612 &request.right_rows,
613 merge_ordering_certified,
614 )
615 };
616
617 if config.algorithm == RuntimeJoinAlgorithm::MergeJoin
621 && (!analysis.residual_conditions.is_empty() || !merge_ordering_certified)
622 {
623 config.algorithm = RuntimeJoinAlgorithm::HashJoin;
624 config.build_left = match analysis.join_type {
625 JoinType::Left | JoinType::Full => false,
626 JoinType::Right => true,
627 _ => left_rows <= right_rows,
628 };
629 }
630 let mut merge_memory_reservation = None;
631 if config.algorithm == RuntimeJoinAlgorithm::MergeJoin {
632 let retained_bytes = MergeJoinOperator::matching_groups_retained_bytes(
633 &request.left_rows,
634 &request.right_rows,
635 &analysis.left_key_indices,
636 &analysis.right_key_indices,
637 RetainedRowsBudget::DEFAULT_MAX_ROWS,
638 request.ctx.join_hash_state_max_bytes(),
639 )?;
640 merge_memory_reservation =
641 retained_bytes.and_then(|bytes| request.ctx.reserve_join_memory(bytes));
642 if merge_memory_reservation.is_none() {
643 config.algorithm = RuntimeJoinAlgorithm::HashJoin;
644 config.build_left = match analysis.join_type {
645 JoinType::Left | JoinType::Full => false,
646 JoinType::Right => true,
647 _ => left_rows <= right_rows,
648 };
649 }
650 }
651 if config.algorithm == RuntimeJoinAlgorithm::HashJoin {
652 config.build_left = Self::choose_hash_build_side(
653 &analysis.join_type,
654 &request.left_rows,
655 &request.right_rows,
656 config.build_left,
657 );
658 }
659
660 let applied_projection = match config.algorithm {
661 RuntimeJoinAlgorithm::HashJoin | RuntimeJoinAlgorithm::NestedLoop => request.projection,
662 RuntimeJoinAlgorithm::MergeJoin => None,
663 };
664
665 let (rows, execution_kind) = match config.algorithm {
667 RuntimeJoinAlgorithm::HashJoin => self.execute_hash_join(
668 request.left_rows,
669 request.right_rows,
670 &analysis,
671 request.left_columns,
672 request.right_columns,
673 config.build_left,
674 request.limit,
675 request.ctx,
676 applied_projection,
677 )?,
678 RuntimeJoinAlgorithm::MergeJoin => {
679 let _memory_reservation = merge_memory_reservation
680 .take()
681 .expect("admitted merge plan must retain its memory reservation");
682 self.execute_merge_join(MergeExecutionRequest {
683 left_rows: request.left_rows,
684 right_rows: request.right_rows,
685 columns: (request.left_columns, request.right_columns),
686 analysis: &analysis,
687 ordering: request.ordering,
688 limit: request.limit,
689 ctx: request.ctx,
690 })
691 .map(|rows| (JoinRows::Owned(rows), JoinExecutionKind::Merge))?
692 }
693 RuntimeJoinAlgorithm::NestedLoop => self
694 .execute_nested_loop(
695 request.left_rows,
696 request.right_rows,
697 request.condition,
698 request.left_columns,
699 request.right_columns,
700 &analysis.join_type_str,
701 request.limit,
702 applied_projection,
703 request.ctx,
704 )
705 .map(|rows| (rows, JoinExecutionKind::NestedLoop))?,
706 };
707
708 let columns = applied_projection
709 .map(|proj| proj.output_columns.clone())
710 .unwrap_or(all_columns);
711
712 instrumentation::record_join_rows_constructed(rows.owned_len() as u64);
713 instrumentation::record_join_execution(
714 execution_kind,
715 JoinExecutionRecord {
716 left_rows,
717 right_rows,
718 output_rows: rows.len() as u64,
719 left_width,
720 right_width,
721 output_width: columns.len() as u64,
722 candidate_pairs: if execution_kind == JoinExecutionKind::NestedLoop {
723 left_rows.saturating_mul(right_rows)
724 } else {
725 0
726 },
727 wall_nanos: started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
728 ..JoinExecutionRecord::default()
729 },
730 );
731
732 Ok(JoinResult { rows, columns })
733 }
734
735 pub fn execute_streaming(&self, request: StreamingJoinRequest<'_>) -> Result<JoinResult> {
750 let limit = request.limit;
751 let preserve_deferred = request.projection.is_some();
752 let ctx = request.ctx;
753 let plan = self.prepare_streaming_hash_join(request)?;
754 let (mut join_op, columns) = ObservedStreamingHashJoin::new(plan);
755
756 let rows =
757 self.execute_operator_with_filter(&mut join_op, limit, &[], preserve_deferred, ctx)?;
758 instrumentation::record_join_rows_constructed(rows.owned_len() as u64);
759
760 Ok(JoinResult { rows, columns })
761 }
762
763 pub fn execute_streaming_result(
767 &self,
768 request: StreamingJoinRequest<'_>,
769 ) -> Result<StreamingJoinResult> {
770 let limit = request.limit;
771 let cancellation = request.ctx.cancellation_handle();
772
773 let (left_columns, right_columns) = if request.build_is_left {
779 (
780 request.build_columns.to_vec(),
781 request.probe_columns.clone(),
782 )
783 } else {
784 (
785 request.probe_columns.clone(),
786 request.build_columns.to_vec(),
787 )
788 };
789 let analysis = self.analyze(
790 &left_columns,
791 &right_columns,
792 request.condition,
793 request.join_type,
794 );
795 let config = ParallelConfig::default();
796 #[cfg(any(test, feature = "test-failpoints"))]
797 let force_serial = radixdb_storage::test_failpoints::force_serial_execution();
798 #[cfg(not(any(test, feature = "test-failpoints")))]
799 let force_serial = false;
800 #[cfg(any(test, feature = "test-failpoints"))]
801 let threshold_allows = radixdb_storage::test_failpoints::force_parallel_execution()
802 || config.should_parallel_join(request.build_rows.len());
803 #[cfg(not(any(test, feature = "test-failpoints")))]
804 let threshold_allows = config.should_parallel_join(request.build_rows.len());
805 let parallel_eligible = !force_serial
806 && request.pre_built_hash_state.is_none()
807 && threshold_allows
808 && limit.is_none_or(|limit| limit > STREAMING_LIMIT_THRESHOLD)
809 && !analysis.left_key_indices.is_empty()
810 && analysis.residual_conditions.is_empty()
811 && matches!(
812 analysis.join_type,
813 JoinType::Inner | JoinType::Left | JoinType::Right | JoinType::Full
814 );
815 if parallel_eligible {
816 let track_build_matches = analysis
817 .join_type
818 .needs_unmatched_build(request.build_is_left);
819 let state_bytes =
820 parallel_join_state_retained_bytes(request.build_rows.len(), track_build_matches)
821 .ok_or_else(|| {
822 radixdb_core::Error::invalid_argument(
823 "parallel join state exceeds its addressable memory range",
824 )
825 })?;
826 if let Some(state_reservation) = request.ctx.reserve_join_memory(state_bytes) {
827 if let Some(batch_reservation) = request
828 .ctx
829 .reserve_join_memory(config.join_output_batch_bytes)
830 {
831 let build_row_count = request.build_rows.len() as u64;
832 #[cfg(any(test, feature = "test-failpoints"))]
833 radixdb_storage::test_failpoints::record_execution_path(9);
834 let mut all_columns = left_columns.clone();
835 all_columns.extend(right_columns.iter().cloned());
836 let columns = request
837 .projection
838 .map(|projection| projection.output_columns.clone())
839 .unwrap_or(all_columns);
840 let (build_key_indices, probe_key_indices) = if request.build_is_left {
841 (
842 analysis.left_key_indices.clone(),
843 analysis.right_key_indices.clone(),
844 )
845 } else {
846 (
847 analysis.right_key_indices.clone(),
848 analysis.left_key_indices.clone(),
849 )
850 };
851 let operator = ParallelHashJoinOperator::new(
852 request.probe_source,
853 request.build_rows,
854 build_key_indices,
855 probe_key_indices,
856 analysis.join_type,
857 request.build_is_left,
858 left_columns.len(),
859 right_columns.len(),
860 columns.clone(),
861 request
862 .projection
863 .map(|projection| projection.columns.clone()),
864 config,
865 cancellation.clone(),
866 state_bytes,
867 state_reservation,
868 batch_reservation,
869 )?;
870 let operator = ObservedParallelHashJoin {
871 inner: operator,
872 started: std::time::Instant::now(),
873 build_row_count,
874 build_is_left: request.build_is_left,
875 left_width: left_columns.len() as u64,
876 right_width: right_columns.len() as u64,
877 output_width: columns.len() as u64,
878 output_rows: 0,
879 recorded: false,
880 };
881 let columns = CompactArc::new(columns);
882 let result = OperatorExecutorResult::open(
883 CompactArc::clone(&columns),
884 Box::new(operator),
885 cancellation,
886 limit,
887 )?;
888 return Ok((Box::new(result), columns));
889 }
890 }
891 }
892
893 #[cfg(any(test, feature = "test-failpoints"))]
894 radixdb_storage::test_failpoints::record_execution_path(8);
895 let plan = self.prepare_streaming_hash_join(request)?;
896 let (join_op, columns) = ObservedStreamingHashJoin::new(plan);
897 let columns = CompactArc::new(columns);
898 let result = OperatorExecutorResult::open(
899 CompactArc::clone(&columns),
900 Box::new(join_op),
901 cancellation,
902 limit,
903 )?;
904 Ok((Box::new(result), columns))
905 }
906
907 fn prepare_streaming_hash_join(
908 &self,
909 request: StreamingJoinRequest<'_>,
910 ) -> Result<PreparedStreamingHashJoin> {
911 let started = std::time::Instant::now();
912 let build_row_count = request.build_rows.len() as u64;
913 let (left_columns, right_columns) = if request.build_is_left {
915 (
916 request.build_columns.to_vec(),
917 request.probe_columns.clone(),
918 )
919 } else {
920 (
921 request.probe_columns.clone(),
922 request.build_columns.to_vec(),
923 )
924 };
925
926 let mut all_columns = left_columns.clone();
927 all_columns.extend(right_columns.iter().cloned());
928
929 let analysis = self.analyze(
931 &left_columns,
932 &right_columns,
933 request.condition,
934 request.join_type,
935 );
936
937 let probe_op = request.probe_source;
939
940 let build_side = if request.build_is_left {
941 JoinSide::Left
942 } else {
943 JoinSide::Right
944 };
945
946 let build_key_indices = if request.build_is_left {
947 &analysis.left_key_indices
948 } else {
949 &analysis.right_key_indices
950 };
951 let build_batch = request.build_rows;
952 let retain_hash_for_reuse = CompactArc::strong_count(&build_batch) > 1;
953 let hash_state = match request.pre_built_hash_state {
954 Some(state) => {
955 if !state.matches(&build_batch, build_key_indices) {
956 return Err(radixdb_core::Error::internal(
957 "pre-built join hash state does not match build rows and keys",
958 ));
959 }
960 Some(state)
961 }
962 None if !build_key_indices.is_empty() => request.ctx.join_hash_state_for(
963 CompactArc::clone(&build_batch),
964 build_key_indices,
965 retain_hash_for_reuse,
966 ),
967 None => None,
968 };
969
970 let mut join_op = if let Some(hash_state) = hash_state {
975 HashJoinOperator::with_prebuilt(
976 probe_op,
977 hash_state,
978 analysis.join_type,
979 analysis.left_key_indices.clone(),
980 analysis.right_key_indices.clone(),
981 request.build_is_left,
982 request.build_columns.len(),
983 )?
984 } else {
985 let build_schema: Vec<ColumnInfo> =
987 request.build_columns.iter().map(ColumnInfo::new).collect();
988 let build_rows_vec =
990 CompactArc::try_unwrap(build_batch).unwrap_or_else(|arc| (*arc).clone());
991 let build_op = Box::new(MaterializedOperator::new(build_rows_vec, build_schema));
992
993 let (left_op, right_op): (Box<dyn Operator>, Box<dyn Operator>) =
994 if request.build_is_left {
995 (build_op, probe_op)
996 } else {
997 (probe_op, build_op)
998 };
999
1000 HashJoinOperator::new(
1001 left_op,
1002 right_op,
1003 analysis.join_type,
1004 analysis.left_key_indices.clone(),
1005 analysis.right_key_indices.clone(),
1006 build_side,
1007 )
1008 .with_hash_state_max_bytes(0)
1012 };
1013
1014 let residual_filters = analysis
1015 .residual_conditions
1016 .iter()
1017 .map(|condition| {
1018 JoinFilter::new(condition, &left_columns, &right_columns, global_registry())
1019 .map(|filter| filter.with_context(request.ctx))
1020 })
1021 .collect::<Result<Vec<_>>>()?;
1022 join_op = join_op.with_residual_filters(residual_filters);
1023
1024 if let Some(proj) = request.projection {
1025 let projected_schema: Vec<ColumnInfo> =
1026 proj.output_columns.iter().map(ColumnInfo::new).collect();
1027 join_op = join_op.with_projection(proj.columns.clone(), projected_schema);
1028 }
1029
1030 let columns = if let Some(proj) = request.projection {
1031 proj.output_columns.clone()
1032 } else {
1033 all_columns
1034 };
1035
1036 Ok(PreparedStreamingHashJoin {
1037 operator: join_op,
1038 columns,
1039 started,
1040 build_row_count,
1041 build_is_left: request.build_is_left,
1042 left_width: left_columns.len() as u64,
1043 right_width: right_columns.len() as u64,
1044 })
1045 }
1046
1047 fn analyze(
1052 &self,
1053 left_columns: &[String],
1054 right_columns: &[String],
1055 condition: Option<&Expression>,
1056 join_type_str: &str,
1057 ) -> JoinAnalysis {
1058 let join_type = JoinType::parse(join_type_str);
1059
1060 let (left_key_indices, right_key_indices, residual_conditions) =
1062 if let Some(cond) = condition {
1063 extract_join_keys_and_residual(cond, left_columns, right_columns)
1064 } else {
1065 (Vec::new(), Vec::new(), Vec::new())
1066 };
1067
1068 JoinAnalysis {
1069 left_key_indices,
1070 right_key_indices,
1071 residual_conditions,
1072 join_type,
1073 join_type_str: join_type_str.to_uppercase(),
1074 }
1075 }
1076
1077 fn select_algorithm(
1083 &self,
1084 analysis: &JoinAnalysis,
1085 left_rows: &[Row],
1086 right_rows: &[Row],
1087 merge_ordering_certified: bool,
1088 ) -> JoinConfig {
1089 let has_equality_keys = !analysis.left_key_indices.is_empty();
1090
1091 if !has_equality_keys {
1093 return JoinConfig {
1094 algorithm: RuntimeJoinAlgorithm::NestedLoop,
1095 build_left: false,
1096 };
1097 }
1098
1099 if merge_ordering_certified {
1102 return JoinConfig {
1103 algorithm: RuntimeJoinAlgorithm::MergeJoin,
1104 build_left: false,
1105 };
1106 }
1107
1108 let join_type = &analysis.join_type_str;
1111 let build_left = if join_type.contains("LEFT") || join_type.contains("FULL") {
1112 false
1114 } else if join_type.contains("RIGHT") {
1115 true
1117 } else {
1118 left_rows.len() <= right_rows.len()
1120 };
1121
1122 JoinConfig {
1123 algorithm: RuntimeJoinAlgorithm::HashJoin,
1124 build_left,
1125 }
1126 }
1127
1128 fn convert_runtime_decision(
1133 &self,
1134 decision: &RuntimeJoinDecision,
1135 analysis: &JoinAnalysis,
1136 ) -> JoinConfig {
1137 let build_left = match decision.algorithm {
1138 RuntimeJoinAlgorithm::HashJoin => {
1139 let join_type = &analysis.join_type_str;
1141 if join_type.contains("LEFT") || join_type.contains("FULL") {
1142 false
1144 } else if join_type.contains("RIGHT") {
1145 true
1147 } else {
1148 !decision.swap_sides
1154 }
1155 }
1156 _ => false, };
1158
1159 JoinConfig {
1160 algorithm: decision.algorithm,
1161 build_left,
1162 }
1163 }
1164
1165 fn choose_hash_build_side(
1173 join_type: &JoinType,
1174 left_rows: &[Row],
1175 right_rows: &[Row],
1176 planner_build_left: bool,
1177 ) -> bool {
1178 match join_type {
1179 JoinType::Left | JoinType::Full => return false,
1180 JoinType::Right => return true,
1181 _ => {}
1182 }
1183
1184 let left_bytes = Self::estimate_post_pushdown_bytes(left_rows);
1185 let right_bytes = Self::estimate_post_pushdown_bytes(right_rows);
1186 match left_bytes.cmp(&right_bytes) {
1187 std::cmp::Ordering::Less => true,
1188 std::cmp::Ordering::Greater => false,
1189 std::cmp::Ordering::Equal => planner_build_left,
1190 }
1191 }
1192
1193 fn estimate_post_pushdown_bytes(rows: &[Row]) -> u128 {
1194 let sampled_rows = rows.len().min(HASH_BUILD_WIDTH_SAMPLE_ROWS);
1195 if sampled_rows == 0 {
1196 return 0;
1197 }
1198 let sampled_bytes = rows.iter().take(sampled_rows).fold(0_u128, |total, row| {
1199 total.saturating_add(RetainedRowsBudget::estimate_row_bytes(row) as u128)
1200 });
1201 sampled_bytes
1202 .saturating_mul(rows.len() as u128)
1203 .div_ceil(sampled_rows as u128)
1204 }
1205
1206 #[allow(clippy::too_many_arguments)]
1214 fn execute_hash_join(
1215 &self,
1216 left_rows: CompactArc<Vec<Row>>,
1217 right_rows: CompactArc<Vec<Row>>,
1218 analysis: &JoinAnalysis,
1219 left_columns: &[String],
1220 right_columns: &[String],
1221 build_left: bool,
1222 limit: Option<u64>,
1223 ctx: &ExecutionContext,
1224 projection: Option<&JoinProjectionIndices>,
1225 ) -> Result<(JoinRows, JoinExecutionKind)> {
1226 let left_col_count = left_columns.len();
1228 let right_col_count = right_columns.len();
1229
1230 let mut all_columns = left_columns.to_vec();
1232 all_columns.extend(right_columns.iter().cloned());
1233
1234 let build_row_count = if build_left {
1239 left_rows.len()
1240 } else {
1241 right_rows.len()
1242 };
1243 #[cfg(any(test, feature = "test-failpoints"))]
1244 let force_serial = radixdb_storage::test_failpoints::force_serial_execution();
1245 #[cfg(not(any(test, feature = "test-failpoints")))]
1246 let force_serial = false;
1247 #[cfg(any(test, feature = "test-failpoints"))]
1248 let threshold_allows = radixdb_storage::test_failpoints::force_parallel_execution()
1249 || build_row_count >= DEFAULT_PARALLEL_JOIN_THRESHOLD;
1250 #[cfg(not(any(test, feature = "test-failpoints")))]
1251 let threshold_allows = build_row_count >= DEFAULT_PARALLEL_JOIN_THRESHOLD;
1252 let parallel_eligible = !force_serial
1253 && analysis.residual_conditions.is_empty()
1254 && threshold_allows
1255 && limit.is_none_or(|l| l > STREAMING_LIMIT_THRESHOLD);
1256 let parallel_memory_reservation = parallel_eligible
1257 .then(|| {
1258 parallel_join_state_retained_bytes(
1259 build_row_count,
1260 analysis.join_type.needs_unmatched_build(build_left),
1261 )
1262 .and_then(|bytes| ctx.reserve_join_memory(bytes))
1263 })
1264 .flatten();
1265
1266 if let Some(_memory_reservation) = parallel_memory_reservation {
1267 #[cfg(any(test, feature = "test-failpoints"))]
1268 radixdb_storage::test_failpoints::record_execution_path(9);
1269 self.execute_hash_join_parallel(
1271 left_rows,
1272 right_rows,
1273 analysis,
1274 left_col_count,
1275 right_col_count,
1276 &all_columns,
1277 build_left,
1278 limit,
1279 projection,
1280 ctx,
1281 )
1282 .map(|rows| (JoinRows::Owned(rows), JoinExecutionKind::HashParallel))
1283 } else {
1284 #[cfg(any(test, feature = "test-failpoints"))]
1285 radixdb_storage::test_failpoints::record_execution_path(8);
1286 let build_rows_reusable = if build_left {
1289 CompactArc::strong_count(&left_rows) > 1
1290 } else {
1291 CompactArc::strong_count(&right_rows) > 1
1292 };
1293 let prebuilt_hash_state = if build_left {
1294 ctx.join_hash_state_for(
1295 CompactArc::clone(&left_rows),
1296 &analysis.left_key_indices,
1297 build_rows_reusable,
1298 )
1299 } else {
1300 ctx.join_hash_state_for(
1301 CompactArc::clone(&right_rows),
1302 &analysis.right_key_indices,
1303 build_rows_reusable,
1304 )
1305 };
1306 self.execute_hash_join_streaming(
1308 left_rows,
1309 right_rows,
1310 analysis,
1311 left_columns,
1312 right_columns,
1313 build_left,
1314 limit,
1315 ctx,
1316 projection,
1317 prebuilt_hash_state,
1318 )
1319 .map(|(rows, fallback)| {
1320 let kind = if fallback {
1321 JoinExecutionKind::NestedLoop
1322 } else {
1323 JoinExecutionKind::HashStreaming
1324 };
1325 (rows, kind)
1326 })
1327 }
1328 }
1329
1330 #[allow(clippy::too_many_arguments)]
1335 fn execute_hash_join_parallel(
1336 &self,
1337 left_rows: CompactArc<Vec<Row>>,
1338 right_rows: CompactArc<Vec<Row>>,
1339 analysis: &JoinAnalysis,
1340 left_col_count: usize,
1341 right_col_count: usize,
1342 all_columns: &[String],
1343 build_left: bool,
1344 limit: Option<u64>,
1345 projection: Option<&JoinProjectionIndices>,
1346 ctx: &ExecutionContext,
1347 ) -> Result<RowVec> {
1348 let config = ParallelConfig::default();
1349
1350 let (probe_slice, build_slice, probe_key_indices, build_key_indices, swapped) =
1352 if build_left {
1353 (
1355 right_rows.as_slice(),
1356 left_rows.as_slice(),
1357 &analysis.right_key_indices,
1358 &analysis.left_key_indices,
1359 true, )
1361 } else {
1362 (
1364 left_rows.as_slice(),
1365 right_rows.as_slice(),
1366 &analysis.left_key_indices,
1367 &analysis.right_key_indices,
1368 false, )
1370 };
1371
1372 let (probe_col_count, build_col_count) = if swapped {
1373 (right_col_count, left_col_count)
1374 } else {
1375 (left_col_count, right_col_count)
1376 };
1377
1378 let cancellation = ctx.cancellation_handle();
1380 let result = parallel_hash_join_cancellable(
1381 probe_slice,
1382 build_slice,
1383 probe_key_indices,
1384 build_key_indices,
1385 analysis.join_type,
1386 probe_col_count,
1387 build_col_count,
1388 swapped,
1389 projection.map(|projection| projection.columns.as_slice()),
1390 &config,
1391 &cancellation,
1392 ctx.join_hash_state_max_bytes(),
1393 )?;
1394
1395 let mut rows: RowVec = result
1397 .rows
1398 .into_iter()
1399 .enumerate()
1400 .map(|(i, row)| (i as i64, row))
1401 .collect();
1402
1403 let is_inner = !analysis.join_type_str.contains("LEFT")
1406 && !analysis.join_type_str.contains("RIGHT")
1407 && !analysis.join_type_str.contains("FULL");
1408
1409 if !analysis.residual_conditions.is_empty() {
1410 if is_inner {
1411 for cond in &analysis.residual_conditions {
1413 let filter = RowFilter::new(cond, all_columns)?.with_context(ctx);
1414 filter.retain_checked(&mut rows)?;
1415 }
1416 } else {
1417 rows = self.apply_residual_post_join(
1419 rows,
1420 &analysis.residual_conditions,
1421 all_columns,
1422 &analysis.join_type_str,
1423 left_col_count,
1424 right_col_count,
1425 ctx,
1426 )?;
1427 }
1428 }
1429
1430 if let Some(max) = limit {
1432 rows.truncate(max as usize);
1433 }
1434
1435 Ok(rows)
1436 }
1437
1438 #[allow(clippy::too_many_arguments)]
1443 fn execute_hash_join_streaming(
1444 &self,
1445 left_rows: CompactArc<Vec<Row>>,
1446 right_rows: CompactArc<Vec<Row>>,
1447 analysis: &JoinAnalysis,
1448 left_columns: &[String],
1449 right_columns: &[String],
1450 build_left: bool,
1451 limit: Option<u64>,
1452 ctx: &ExecutionContext,
1453 projection: Option<&JoinProjectionIndices>,
1454 prebuilt_hash_state: Option<JoinHashState>,
1455 ) -> Result<(JoinRows, bool)> {
1456 let left_schema: Vec<ColumnInfo> = left_columns.iter().map(ColumnInfo::new).collect();
1458 let right_schema: Vec<ColumnInfo> = right_columns.iter().map(ColumnInfo::new).collect();
1459
1460 let mut join_op = if let Some(hash_state) = prebuilt_hash_state {
1461 let (probe_rows, probe_schema, build_col_count) = if build_left {
1462 (right_rows, right_schema, left_columns.len())
1463 } else {
1464 (left_rows, left_schema, right_columns.len())
1465 };
1466 HashJoinOperator::with_prebuilt(
1467 Box::new(MaterializedOperator::from_arc(probe_rows, probe_schema)),
1468 hash_state,
1469 analysis.join_type,
1470 analysis.left_key_indices.clone(),
1471 analysis.right_key_indices.clone(),
1472 build_left,
1473 build_col_count,
1474 )?
1475 } else {
1476 let left_op = Box::new(MaterializedOperator::from_arc(left_rows, left_schema));
1478 let right_op = Box::new(MaterializedOperator::from_arc(right_rows, right_schema));
1479 let build_side = if build_left {
1480 JoinSide::Left
1481 } else {
1482 JoinSide::Right
1483 };
1484 HashJoinOperator::new(
1485 left_op,
1486 right_op,
1487 analysis.join_type,
1488 analysis.left_key_indices.clone(),
1489 analysis.right_key_indices.clone(),
1490 build_side,
1491 )
1492 .with_hash_state_max_bytes(0)
1496 };
1497
1498 let residual_filters = analysis
1499 .residual_conditions
1500 .iter()
1501 .map(|condition| {
1502 JoinFilter::new(condition, left_columns, right_columns, global_registry())
1503 .map(|filter| filter.with_context(ctx))
1504 })
1505 .collect::<Result<Vec<_>>>()?;
1506 join_op = join_op.with_residual_filters(residual_filters);
1507
1508 if let Some(proj) = projection {
1509 let projected_schema: Vec<ColumnInfo> =
1510 proj.output_columns.iter().map(ColumnInfo::new).collect();
1511 join_op = join_op.with_projection(proj.columns.clone(), projected_schema);
1512 }
1513
1514 let rows =
1516 self.execute_operator_with_filter(&mut join_op, limit, &[], projection.is_some(), ctx)?;
1517 Ok((rows, join_op.used_scan_fallback()))
1518 }
1519
1520 fn execute_merge_join(&self, request: MergeExecutionRequest<'_>) -> Result<RowVec> {
1522 let (left_columns, right_columns) = request.columns;
1523 let left_schema: Vec<ColumnInfo> = left_columns.iter().map(ColumnInfo::new).collect();
1525 let right_schema: Vec<ColumnInfo> = right_columns.iter().map(ColumnInfo::new).collect();
1526
1527 let left_vec =
1529 CompactArc::try_unwrap(request.left_rows).unwrap_or_else(|arc| (*arc).clone());
1530 let right_vec =
1531 CompactArc::try_unwrap(request.right_rows).unwrap_or_else(|arc| (*arc).clone());
1532
1533 let left_op = Box::new(
1535 MaterializedOperator::new(left_vec, left_schema).with_ordering(request.ordering.left),
1536 );
1537 let right_op = Box::new(
1538 MaterializedOperator::new(right_vec, right_schema)
1539 .with_ordering(request.ordering.right),
1540 );
1541
1542 let mut merge_op = MergeJoinOperator::new(
1544 left_op,
1545 right_op,
1546 request.analysis.join_type,
1547 request.analysis.left_key_indices.clone(),
1548 request.analysis.right_key_indices.clone(),
1549 )
1550 .with_group_budget(
1551 RetainedRowsBudget::DEFAULT_MAX_ROWS,
1552 request.ctx.join_hash_state_max_bytes(),
1553 );
1554
1555 self.execute_operator_with_filter(&mut merge_op, request.limit, &[], false, request.ctx)
1557 .map(JoinRows::into_owned)
1558 }
1559
1560 #[allow(clippy::too_many_arguments)]
1562 fn execute_nested_loop(
1563 &self,
1564 left_rows: CompactArc<Vec<Row>>,
1565 right_rows: CompactArc<Vec<Row>>,
1566 condition: Option<&Expression>,
1567 left_columns: &[String],
1568 right_columns: &[String],
1569 join_type_str: &str,
1570 limit: Option<u64>,
1571 projection: Option<&JoinProjectionIndices>,
1572 ctx: &ExecutionContext,
1573 ) -> Result<JoinRows> {
1574 let left_schema: Vec<ColumnInfo> = left_columns.iter().map(ColumnInfo::new).collect();
1576 let right_schema: Vec<ColumnInfo> = right_columns.iter().map(ColumnInfo::new).collect();
1577
1578 let left_vec = CompactArc::try_unwrap(left_rows).unwrap_or_else(|arc| (*arc).clone());
1580 let right_vec = CompactArc::try_unwrap(right_rows).unwrap_or_else(|arc| (*arc).clone());
1581
1582 let left_op = Box::new(MaterializedOperator::new(left_vec, left_schema));
1584 let right_op = Box::new(MaterializedOperator::new(right_vec, right_schema));
1585
1586 let join_type = if join_type_str.contains("CROSS") {
1588 JoinType::Cross
1589 } else if join_type_str.contains("FULL") {
1590 JoinType::Full
1591 } else if join_type_str.contains("RIGHT") {
1592 JoinType::Right
1593 } else if join_type_str.contains("LEFT") {
1594 JoinType::Left
1595 } else {
1596 JoinType::Inner
1597 };
1598
1599 let mut nl_op =
1601 NestedLoopJoinOperator::new(left_op, right_op, join_type, condition.cloned());
1602 if let Some(proj) = projection {
1603 let projected_schema: Vec<ColumnInfo> =
1604 proj.output_columns.iter().map(ColumnInfo::new).collect();
1605 nl_op = nl_op.with_projection(proj.columns.clone(), projected_schema);
1606 }
1607
1608 self.execute_operator_with_filter(&mut nl_op, limit, &[], projection.is_some(), ctx)
1610 }
1611
1612 fn execute_operator_with_filter(
1614 &self,
1615 op: &mut dyn Operator,
1616 limit: Option<u64>,
1617 residual_filters: &[RowFilter],
1618 preserve_deferred: bool,
1619 ctx: &ExecutionContext,
1620 ) -> Result<JoinRows> {
1621 ctx.check_cancelled()?;
1622 if let Err(error) = op.open() {
1623 let _ = op.close();
1624 return Err(error);
1625 }
1626 let execution_result = (|| {
1627 let max_rows = limit.map(|l| l as usize).unwrap_or(usize::MAX);
1628 let mut rows = RowVec::with_capacity(max_rows.min(1000));
1629 let mut deferred_rows =
1630 preserve_deferred.then(|| Vec::with_capacity(max_rows.min(1000)));
1631 let mut row_id = 0i64;
1632 let mut visited_rows = 0_usize;
1633 let has_filters = !residual_filters.is_empty();
1634
1635 loop {
1636 if visited_rows & 0xff == 0 {
1637 ctx.check_cancelled()?;
1638 }
1639 let Some(row_ref) = op.next()? else {
1640 break;
1641 };
1642 visited_rows = visited_rows.saturating_add(1);
1643 if has_filters {
1645 let pass = match residual_filters.len() {
1646 1 => residual_filters[0].matches_row_ref_checked(&row_ref)?,
1647 2 => {
1648 residual_filters[0].matches_row_ref_checked(&row_ref)?
1649 && residual_filters[1].matches_row_ref_checked(&row_ref)?
1650 }
1651 3 => {
1652 residual_filters[0].matches_row_ref_checked(&row_ref)?
1653 && residual_filters[1].matches_row_ref_checked(&row_ref)?
1654 && residual_filters[2].matches_row_ref_checked(&row_ref)?
1655 }
1656 4 => {
1657 residual_filters[0].matches_row_ref_checked(&row_ref)?
1658 && residual_filters[1].matches_row_ref_checked(&row_ref)?
1659 && residual_filters[2].matches_row_ref_checked(&row_ref)?
1660 && residual_filters[3].matches_row_ref_checked(&row_ref)?
1661 }
1662 _ => {
1663 let mut all_pass = true;
1664 for filter in residual_filters {
1665 if !filter.matches_row_ref_checked(&row_ref)? {
1666 all_pass = false;
1667 break;
1668 }
1669 }
1670 all_pass
1671 }
1672 };
1673 if !pass {
1674 continue;
1675 }
1676 }
1677
1678 if let Some(deferred_rows) = deferred_rows.as_mut() {
1679 deferred_rows.push(row_ref.into_deferred());
1680 if deferred_rows.len() >= max_rows {
1681 break;
1682 }
1683 continue;
1684 }
1685
1686 let row = row_ref.into_owned();
1687
1688 rows.push((row_id, row));
1689 row_id += 1;
1690
1691 if rows.len() >= max_rows {
1693 break;
1694 }
1695 }
1696
1697 Ok(deferred_rows.map_or(JoinRows::Owned(rows), JoinRows::Deferred))
1698 })();
1699 let close_result = op.close();
1700 match (execution_result, close_result) {
1701 (Ok(rows), Ok(())) => Ok(rows),
1702 (Err(error), _) | (Ok(_), Err(error)) => Err(error),
1703 }
1704 }
1705
1706 #[allow(clippy::too_many_arguments)]
1711 fn apply_residual_post_join(
1712 &self,
1713 mut rows: RowVec,
1714 residual: &[Expression],
1715 all_columns: &[String],
1716 join_type: &str,
1717 left_col_count: usize,
1718 right_col_count: usize,
1719 ctx: &ExecutionContext,
1720 ) -> Result<RowVec> {
1721 let is_left_outer = join_type.contains("LEFT");
1722 let is_right_outer = join_type.contains("RIGHT");
1723 let is_full_outer = join_type.contains("FULL");
1724
1725 for cond in residual {
1726 let filter = RowFilter::new(cond, all_columns)?.with_context(ctx);
1727
1728 if is_left_outer || is_right_outer || is_full_outer {
1729 let mut new_rows = RowVec::with_capacity(rows.len());
1731 for (row_id, row) in rows {
1732 if filter.matches_checked(&row)? {
1733 new_rows.push((row_id, row));
1734 } else {
1735 if is_left_outer {
1737 let mut new_values: CompactVec<Value> =
1739 CompactVec::with_capacity(left_col_count + right_col_count);
1740 new_values.extend(row.iter().take(left_col_count).cloned());
1741 new_values.extend(std::iter::repeat_n(NULL_VALUE, right_col_count));
1742 new_rows.push((row_id, Row::from_compact_vec(new_values)));
1743 } else if is_right_outer {
1744 let mut new_values: CompactVec<Value> =
1746 CompactVec::with_capacity(left_col_count + right_col_count);
1747 new_values.extend(std::iter::repeat_n(NULL_VALUE, left_col_count));
1748 new_values.extend(row.iter().skip(left_col_count).cloned());
1749 new_rows.push((row_id, Row::from_compact_vec(new_values)));
1750 } else {
1751 new_rows.push((row_id, row));
1753 }
1754 }
1755 }
1756 rows = new_rows;
1757 } else {
1758 filter.retain_checked(&mut rows)?;
1760 }
1761 }
1762
1763 Ok(rows)
1764 }
1765}
1766
1767impl Default for JoinExecutor {
1768 fn default() -> Self {
1769 Self::new()
1770 }
1771}
1772
1773#[cfg(test)]
1774mod tests {
1775 use super::*;
1776 use crate::operator::{ColumnSource, MaterializedOperator};
1777 use std::sync::atomic::{AtomicUsize, Ordering};
1778 use std::sync::Arc;
1779
1780 struct CountingOperator {
1781 rows: Vec<Row>,
1782 schema: Vec<ColumnInfo>,
1783 next_row: usize,
1784 next_calls: Arc<AtomicUsize>,
1785 opened: bool,
1786 }
1787
1788 impl CountingOperator {
1789 fn new(rows: Vec<Row>, columns: &[String], next_calls: Arc<AtomicUsize>) -> Self {
1790 Self {
1791 rows,
1792 schema: columns.iter().map(ColumnInfo::new).collect(),
1793 next_row: 0,
1794 next_calls,
1795 opened: false,
1796 }
1797 }
1798 }
1799
1800 impl Operator for CountingOperator {
1801 fn open(&mut self) -> Result<()> {
1802 self.opened = true;
1803 Ok(())
1804 }
1805
1806 fn next(&mut self) -> Result<Option<RowRef>> {
1807 assert!(self.opened);
1808 self.next_calls.fetch_add(1, Ordering::Relaxed);
1809 let Some(row) = self.rows.get(self.next_row).cloned() else {
1810 return Ok(None);
1811 };
1812 self.next_row += 1;
1813 Ok(Some(RowRef::owned(row)))
1814 }
1815
1816 fn close(&mut self) -> Result<()> {
1817 self.opened = false;
1818 Ok(())
1819 }
1820
1821 fn schema(&self) -> &[ColumnInfo] {
1822 &self.schema
1823 }
1824
1825 fn estimated_rows(&self) -> Option<usize> {
1826 Some(self.rows.len())
1827 }
1828
1829 fn name(&self) -> &str {
1830 "Counting"
1831 }
1832 }
1833
1834 fn make_rows(data: Vec<Vec<i64>>) -> Vec<Row> {
1835 data.into_iter()
1836 .map(|vals| Row::from_values(vals.into_iter().map(Value::integer).collect()))
1837 .collect()
1838 }
1839
1840 #[test]
1841 fn streaming_result_does_not_collect_probe_before_downstream_pull() {
1842 use radixdb_sql::ast::{Identifier, InfixExpression};
1843 use radixdb_sql::token::{Position, Token, TokenType};
1844
1845 let executor = JoinExecutor::new();
1846 let ctx = ExecutionContext::new();
1847 let left_columns = vec!["a.id".to_string(), "a.value".to_string()];
1848 let right_columns = vec!["b.id".to_string(), "b.value".to_string()];
1849 let condition = Expression::Infix(InfixExpression::new(
1850 Token::new(TokenType::Operator, "=", Position::default()),
1851 Box::new(Expression::Identifier(Identifier::new(
1852 Token::new(TokenType::Identifier, "a.id", Position::default()),
1853 "a.id".to_string(),
1854 ))),
1855 "=".to_string(),
1856 Box::new(Expression::Identifier(Identifier::new(
1857 Token::new(TokenType::Identifier, "b.id", Position::default()),
1858 "b.id".to_string(),
1859 ))),
1860 ));
1861 let projection = JoinProjectionIndices {
1862 columns: vec![ColumnSource::Outer(1), ColumnSource::Inner(1)],
1863 output_columns: vec!["value".to_string(), "dictionary".to_string()],
1864 };
1865 let next_calls = Arc::new(AtomicUsize::new(0));
1866
1867 radixdb_storage::instrumentation::begin_join_execution_probe();
1868 let (mut result, columns) = executor
1869 .execute_streaming_result(StreamingJoinRequest {
1870 build_rows: CompactArc::new(make_rows(vec![vec![1, 100], vec![2, 200]])),
1871 build_columns: &right_columns,
1872 probe_source: Box::new(CountingOperator::new(
1873 make_rows(vec![vec![1, 10], vec![2, 20]]),
1874 &left_columns,
1875 Arc::clone(&next_calls),
1876 )),
1877 probe_columns: left_columns,
1878 condition: Some(&condition),
1879 join_type: "INNER",
1880 build_is_left: false,
1881 limit: None,
1882 ctx: &ctx,
1883 pre_built_hash_state: None,
1884 projection: Some(&projection),
1885 })
1886 .unwrap();
1887
1888 assert_eq!(&*columns, &["value".to_string(), "dictionary".to_string()]);
1889 assert_eq!(next_calls.load(Ordering::Relaxed), 0);
1890 assert!(result.next());
1891 assert_eq!(next_calls.load(Ordering::Relaxed), 1);
1892 let row = result.take_deferred_row();
1893 assert!(row.is_deferred());
1894 assert_eq!(row.get(0), Some(&Value::integer(10)));
1895 assert_eq!(row.get(1), Some(&Value::integer(100)));
1896 drop(result);
1897 assert_eq!(ctx.retained_join_memory_bytes(), 0);
1898 let probe = radixdb_storage::instrumentation::end_join_execution_probe();
1899 assert_eq!(probe.hash_streaming_calls, 1);
1900 assert_eq!(probe.output_rows, 1);
1901 }
1902
1903 #[test]
1904 fn hash_build_side_uses_bounded_post_pushdown_bytes_not_only_row_count() {
1905 let wide_left = vec![Row::from_values(vec![
1906 Value::integer(1),
1907 Value::text("x".repeat(32 * 1024)),
1908 ])];
1909 let narrow_right = make_rows(vec![vec![1], vec![2], vec![3], vec![4]]);
1910
1911 assert!(!JoinExecutor::choose_hash_build_side(
1912 &JoinType::Inner,
1913 &wide_left,
1914 &narrow_right,
1915 true,
1916 ));
1917
1918 let narrow_left = make_rows(vec![vec![1], vec![2]]);
1919 let wide_right = vec![Row::from_values(vec![
1920 Value::integer(1),
1921 Value::text("y".repeat(32 * 1024)),
1922 ])];
1923 assert!(JoinExecutor::choose_hash_build_side(
1924 &JoinType::Inner,
1925 &narrow_left,
1926 &wide_right,
1927 false,
1928 ));
1929 }
1930
1931 #[test]
1932 fn hash_build_side_keeps_outer_orientation() {
1933 let wide = vec![Row::from_values(vec![Value::text("z".repeat(32 * 1024))])];
1934 let narrow = make_rows(vec![vec![1]]);
1935
1936 assert!(!JoinExecutor::choose_hash_build_side(
1937 &JoinType::Left,
1938 &wide,
1939 &narrow,
1940 true,
1941 ));
1942 assert!(JoinExecutor::choose_hash_build_side(
1943 &JoinType::Right,
1944 &narrow,
1945 &wide,
1946 false,
1947 ));
1948 assert!(!JoinExecutor::choose_hash_build_side(
1949 &JoinType::Full,
1950 &wide,
1951 &narrow,
1952 true,
1953 ));
1954 }
1955
1956 #[test]
1957 fn test_inner_join() {
1958 let executor = JoinExecutor::new();
1959 let ctx = ExecutionContext::new();
1960
1961 let left = make_rows(vec![vec![1, 10], vec![2, 20], vec![3, 30]]);
1962 let right = make_rows(vec![vec![1, 100], vec![3, 300]]);
1963
1964 let left_cols = vec!["a.id".to_string(), "a.val".to_string()];
1965 let right_cols = vec!["b.id".to_string(), "b.data".to_string()];
1966
1967 use radixdb_sql::ast::{Identifier, InfixExpression};
1969 use radixdb_sql::token::{Position, Token, TokenType};
1970
1971 let cond = Expression::Infix(InfixExpression::new(
1972 Token::new(TokenType::Operator, "=", Position::default()),
1973 Box::new(Expression::Identifier(Identifier::new(
1974 Token::new(TokenType::Identifier, "a.id", Position::default()),
1975 "a.id".to_string(),
1976 ))),
1977 "=".to_string(),
1978 Box::new(Expression::Identifier(Identifier::new(
1979 Token::new(TokenType::Identifier, "b.id", Position::default()),
1980 "b.id".to_string(),
1981 ))),
1982 ));
1983
1984 let request = JoinRequest {
1985 left_rows: CompactArc::new(left),
1986 right_rows: CompactArc::new(right),
1987 left_columns: &left_cols,
1988 right_columns: &right_cols,
1989 condition: Some(&cond),
1990 join_type: "INNER",
1991 limit: None,
1992 ctx: &ctx,
1993 algorithm_hint: None,
1994 ordering: JoinInputOrderings::default(),
1995 projection: None,
1996 };
1997
1998 let before = radixdb_storage::instrumentation::snapshot();
1999 let result = executor.execute(request).unwrap();
2000 let after = radixdb_storage::instrumentation::snapshot();
2001
2002 assert_eq!(result.rows.len(), 2);
2003 assert_eq!(result.columns.len(), 4);
2004 assert!(
2005 after.join_rows_constructed >= before.join_rows_constructed.saturating_add(2),
2006 "materialized join rows must reach the engine instrumentation owner"
2007 );
2008 assert!(
2009 after.join_operator_calls >= before.join_operator_calls.saturating_add(1),
2010 "one physical JOIN execution must be published"
2011 );
2012 assert!(after.join_left_input_rows >= before.join_left_input_rows.saturating_add(3));
2013 assert!(after.join_right_input_rows >= before.join_right_input_rows.saturating_add(2));
2014 assert!(after.join_output_rows >= before.join_output_rows.saturating_add(2));
2015 assert!(after.join_max_output_width >= 4);
2016 }
2017
2018 #[test]
2019 fn merge_requires_physical_ordering_certificates_without_row_rescan() {
2020 use radixdb_sql::ast::{Identifier, InfixExpression};
2021 use radixdb_sql::token::{Position, Token, TokenType};
2022
2023 let executor = JoinExecutor::new();
2024 let ctx = ExecutionContext::new();
2025 let left = make_rows(vec![vec![1, 10], vec![2, 20], vec![3, 30]]);
2026 let right = make_rows(vec![vec![1, 100], vec![3, 300]]);
2027 let left_cols = vec!["a.id".to_string(), "a.val".to_string()];
2028 let right_cols = vec!["b.id".to_string(), "b.data".to_string()];
2029 let condition = Expression::Infix(InfixExpression::new(
2030 Token::new(TokenType::Operator, "=", Position::default()),
2031 Box::new(Expression::Identifier(Identifier::new(
2032 Token::new(TokenType::Identifier, "a.id", Position::default()),
2033 "a.id".to_string(),
2034 ))),
2035 "=".to_string(),
2036 Box::new(Expression::Identifier(Identifier::new(
2037 Token::new(TokenType::Identifier, "b.id", Position::default()),
2038 "b.id".to_string(),
2039 ))),
2040 ));
2041
2042 radixdb_storage::instrumentation::begin_join_execution_probe();
2043 let unknown_result = executor
2044 .execute(JoinRequest {
2045 left_rows: CompactArc::new(left.clone()),
2046 right_rows: CompactArc::new(right.clone()),
2047 left_columns: &left_cols,
2048 right_columns: &right_cols,
2049 condition: Some(&condition),
2050 join_type: "INNER",
2051 limit: None,
2052 ctx: &ctx,
2053 algorithm_hint: None,
2054 ordering: JoinInputOrderings::default(),
2055 projection: None,
2056 })
2057 .unwrap();
2058 let unknown_probe = radixdb_storage::instrumentation::end_join_execution_probe();
2059 assert_eq!(unknown_result.rows.len(), 2);
2060 assert_eq!(unknown_probe.merge_calls, 0);
2061 assert_eq!(unknown_probe.hash_streaming_calls, 1);
2062
2063 radixdb_storage::instrumentation::begin_join_execution_probe();
2064 let certified_result = executor
2065 .execute(JoinRequest {
2066 left_rows: CompactArc::new(left),
2067 right_rows: CompactArc::new(right),
2068 left_columns: &left_cols,
2069 right_columns: &right_cols,
2070 condition: Some(&condition),
2071 join_type: "INNER",
2072 limit: None,
2073 ctx: &ctx,
2074 algorithm_hint: None,
2075 ordering: JoinInputOrderings::new(
2076 OrderingProperty::ascending_nulls_last(vec![0]),
2077 OrderingProperty::ascending_nulls_last(vec![0]),
2078 ),
2079 projection: None,
2080 })
2081 .unwrap();
2082 let certified_probe = radixdb_storage::instrumentation::end_join_execution_probe();
2083 assert_eq!(certified_result.rows.len(), 2);
2084 assert_eq!(certified_probe.merge_calls, 1);
2085 assert_eq!(certified_probe.hash_streaming_calls, 0);
2086 }
2087
2088 #[test]
2089 fn merge_hint_cannot_bypass_missing_ordering_certificate() {
2090 use radixdb_sql::ast::{Identifier, InfixExpression};
2091 use radixdb_sql::token::{Position, Token, TokenType};
2092
2093 let executor = JoinExecutor::new();
2094 let ctx = ExecutionContext::new();
2095 let left_cols = vec!["a.id".to_string()];
2096 let right_cols = vec!["b.id".to_string()];
2097 let condition = Expression::Infix(InfixExpression::new(
2098 Token::new(TokenType::Operator, "=", Position::default()),
2099 Box::new(Expression::Identifier(Identifier::new(
2100 Token::new(TokenType::Identifier, "a.id", Position::default()),
2101 "a.id".to_string(),
2102 ))),
2103 "=".to_string(),
2104 Box::new(Expression::Identifier(Identifier::new(
2105 Token::new(TokenType::Identifier, "b.id", Position::default()),
2106 "b.id".to_string(),
2107 ))),
2108 ));
2109 let hint = RuntimeJoinDecision {
2110 algorithm: RuntimeJoinAlgorithm::MergeJoin,
2111 swap_sides: false,
2112 explanation: "test untrusted merge hint".to_string(),
2113 };
2114
2115 radixdb_storage::instrumentation::begin_join_execution_probe();
2116 executor
2117 .execute(JoinRequest {
2118 left_rows: CompactArc::new(make_rows(vec![vec![1], vec![2]])),
2119 right_rows: CompactArc::new(make_rows(vec![vec![1], vec![2]])),
2120 left_columns: &left_cols,
2121 right_columns: &right_cols,
2122 condition: Some(&condition),
2123 join_type: "INNER",
2124 limit: None,
2125 ctx: &ctx,
2126 algorithm_hint: Some(&hint),
2127 ordering: JoinInputOrderings::default(),
2128 projection: None,
2129 })
2130 .unwrap();
2131 let probe = radixdb_storage::instrumentation::end_join_execution_probe();
2132 assert_eq!(probe.merge_calls, 0);
2133 assert_eq!(probe.hash_streaming_calls, 1);
2134 }
2135
2136 #[test]
2137 fn oversized_merge_duplicate_group_falls_back_to_bounded_hash() {
2138 use radixdb_sql::ast::{Identifier, InfixExpression};
2139 use radixdb_sql::token::{Position, Token, TokenType};
2140
2141 let executor = JoinExecutor::new();
2142 let hash_budget = crate::hash_table::JoinHashTable::estimated_retained_bytes(10).unwrap();
2143 let ctx = crate::context::ExecutionContextBuilder::new()
2144 .join_hash_state_max_bytes(hash_budget)
2145 .build();
2146 let left_cols = vec!["a.id".to_string()];
2147 let right_cols = vec!["b.id".to_string()];
2148 let condition = Expression::Infix(InfixExpression::new(
2149 Token::new(TokenType::Operator, "=", Position::default()),
2150 Box::new(Expression::Identifier(Identifier::new(
2151 Token::new(TokenType::Identifier, "a.id", Position::default()),
2152 "a.id".to_string(),
2153 ))),
2154 "=".to_string(),
2155 Box::new(Expression::Identifier(Identifier::new(
2156 Token::new(TokenType::Identifier, "b.id", Position::default()),
2157 "b.id".to_string(),
2158 ))),
2159 ));
2160 let hint = RuntimeJoinDecision {
2161 algorithm: RuntimeJoinAlgorithm::MergeJoin,
2162 swap_sides: false,
2163 explanation: "test pathological duplicate group".to_string(),
2164 };
2165 let ordering = JoinInputOrderings::new(
2166 OrderingProperty::ascending_nulls_last(vec![0]),
2167 OrderingProperty::ascending_nulls_last(vec![0]),
2168 );
2169 let duplicates = || make_rows((0..10).map(|_| vec![1]).collect());
2170
2171 radixdb_storage::instrumentation::begin_join_execution_probe();
2172 let result = executor
2173 .execute(JoinRequest {
2174 left_rows: CompactArc::new(duplicates()),
2175 right_rows: CompactArc::new(duplicates()),
2176 left_columns: &left_cols,
2177 right_columns: &right_cols,
2178 condition: Some(&condition),
2179 join_type: "INNER",
2180 limit: None,
2181 ctx: &ctx,
2182 algorithm_hint: Some(&hint),
2183 ordering,
2184 projection: None,
2185 })
2186 .unwrap();
2187 let probe = radixdb_storage::instrumentation::end_join_execution_probe();
2188
2189 assert_eq!(result.rows.len(), 100);
2190 assert_eq!(probe.merge_calls, 0);
2191 assert_eq!(probe.hash_streaming_calls, 1);
2192 }
2193
2194 #[test]
2195 fn test_left_join() {
2196 let executor = JoinExecutor::new();
2197 let ctx = ExecutionContext::new();
2198
2199 let left = make_rows(vec![vec![1, 10], vec![2, 20], vec![3, 30]]);
2200 let right = make_rows(vec![vec![1, 100]]);
2201
2202 let left_cols = vec!["a.id".to_string(), "a.val".to_string()];
2203 let right_cols = vec!["b.id".to_string(), "b.data".to_string()];
2204
2205 use radixdb_sql::ast::{Identifier, InfixExpression};
2206 use radixdb_sql::token::{Position, Token, TokenType};
2207
2208 let cond = Expression::Infix(InfixExpression::new(
2209 Token::new(TokenType::Operator, "=", Position::default()),
2210 Box::new(Expression::Identifier(Identifier::new(
2211 Token::new(TokenType::Identifier, "a.id", Position::default()),
2212 "a.id".to_string(),
2213 ))),
2214 "=".to_string(),
2215 Box::new(Expression::Identifier(Identifier::new(
2216 Token::new(TokenType::Identifier, "b.id", Position::default()),
2217 "b.id".to_string(),
2218 ))),
2219 ));
2220
2221 let request = JoinRequest {
2222 left_rows: CompactArc::new(left),
2223 right_rows: CompactArc::new(right),
2224 left_columns: &left_cols,
2225 right_columns: &right_cols,
2226 condition: Some(&cond),
2227 join_type: "LEFT",
2228 limit: None,
2229 ctx: &ctx,
2230 algorithm_hint: None,
2231 ordering: JoinInputOrderings::default(),
2232 projection: None,
2233 };
2234
2235 let result = executor.execute(request).unwrap();
2236
2237 assert_eq!(result.rows.len(), 3);
2239 }
2240
2241 #[test]
2242 fn test_early_termination() {
2243 let executor = JoinExecutor::new();
2244 let ctx = ExecutionContext::new();
2245
2246 let left = make_rows(vec![vec![1], vec![2], vec![3]]);
2247 let right = make_rows(vec![vec![1], vec![2], vec![3]]);
2248
2249 let left_cols = vec!["a.id".to_string()];
2250 let right_cols = vec!["b.id".to_string()];
2251
2252 use radixdb_sql::ast::{Identifier, InfixExpression};
2253 use radixdb_sql::token::{Position, Token, TokenType};
2254
2255 let cond = Expression::Infix(InfixExpression::new(
2256 Token::new(TokenType::Operator, "=", Position::default()),
2257 Box::new(Expression::Identifier(Identifier::new(
2258 Token::new(TokenType::Identifier, "a.id", Position::default()),
2259 "a.id".to_string(),
2260 ))),
2261 "=".to_string(),
2262 Box::new(Expression::Identifier(Identifier::new(
2263 Token::new(TokenType::Identifier, "b.id", Position::default()),
2264 "b.id".to_string(),
2265 ))),
2266 ));
2267
2268 let request = JoinRequest {
2269 left_rows: CompactArc::new(left),
2270 right_rows: CompactArc::new(right),
2271 left_columns: &left_cols,
2272 right_columns: &right_cols,
2273 condition: Some(&cond),
2274 join_type: "INNER",
2275 limit: Some(1), ctx: &ctx,
2277 algorithm_hint: None,
2278 ordering: JoinInputOrderings::default(),
2279 projection: None,
2280 };
2281
2282 let result = executor.execute(request).unwrap();
2283
2284 assert_eq!(result.rows.len(), 1);
2286 }
2287
2288 #[test]
2289 fn test_streaming_hash_join_applies_projection_boundary() {
2290 let executor = JoinExecutor::new();
2291 let ctx = ExecutionContext::new();
2292
2293 let left = make_rows(vec![
2294 vec![1, 10, 1000],
2295 vec![2, 20, 2000],
2296 vec![3, 30, 3000],
2297 ]);
2298 let right = make_rows(vec![vec![1, 100, 9000], vec![3, 300, 7000]]);
2299
2300 let left_cols = vec![
2301 "a.id".to_string(),
2302 "a.val".to_string(),
2303 "a.unused".to_string(),
2304 ];
2305 let right_cols = vec![
2306 "b.id".to_string(),
2307 "b.data".to_string(),
2308 "b.unused".to_string(),
2309 ];
2310
2311 use radixdb_sql::ast::{Identifier, InfixExpression};
2312 use radixdb_sql::token::{Position, Token, TokenType};
2313
2314 let cond = Expression::Infix(InfixExpression::new(
2315 Token::new(TokenType::Operator, "=", Position::default()),
2316 Box::new(Expression::Identifier(Identifier::new(
2317 Token::new(TokenType::Identifier, "a.id", Position::default()),
2318 "a.id".to_string(),
2319 ))),
2320 "=".to_string(),
2321 Box::new(Expression::Identifier(Identifier::new(
2322 Token::new(TokenType::Identifier, "b.id", Position::default()),
2323 "b.id".to_string(),
2324 ))),
2325 ));
2326
2327 let projection = JoinProjectionIndices {
2328 columns: vec![ColumnSource::Outer(1), ColumnSource::Inner(1)],
2329 output_columns: vec!["val".to_string(), "data".to_string()],
2330 };
2331
2332 let left_schema = left_cols.iter().map(ColumnInfo::new).collect();
2333 let request = StreamingJoinRequest {
2334 build_rows: CompactArc::new(right),
2335 build_columns: &right_cols,
2336 probe_source: Box::new(MaterializedOperator::new(left, left_schema)),
2337 probe_columns: left_cols.clone(),
2338 condition: Some(&cond),
2339 join_type: "INNER",
2340 build_is_left: false,
2341 limit: Some(10),
2342 ctx: &ctx,
2343 pre_built_hash_state: None,
2344 projection: Some(&projection),
2345 };
2346
2347 let result = executor.execute_streaming(request).unwrap();
2348
2349 assert_eq!(result.columns, vec!["val".to_string(), "data".to_string()]);
2350 let rows = result.rows.into_owned();
2351 assert_eq!(rows.len(), 2);
2352 assert!(rows.iter().all(|(_, row)| row.len() == 2));
2353 assert_eq!(rows[0].1.get(0), Some(&Value::integer(10)));
2354 assert_eq!(rows[0].1.get(1), Some(&Value::integer(100)));
2355 assert_eq!(rows[1].1.get(0), Some(&Value::integer(30)));
2356 assert_eq!(rows[1].1.get(1), Some(&Value::integer(300)));
2357 }
2358
2359 #[test]
2360 fn streaming_hash_state_keeps_empty_build_schema_for_left_join() {
2361 let executor = JoinExecutor::new();
2362 let ctx = ExecutionContext::new();
2363 let left = make_rows(vec![vec![1, 10], vec![2, 20]]);
2364 let right = CompactArc::new(Vec::new());
2365 let left_cols = vec!["a.id".to_string(), "a.value".to_string()];
2366 let right_cols = vec!["b.id".to_string(), "b.value".to_string()];
2367
2368 use radixdb_sql::ast::{Identifier, InfixExpression};
2369 use radixdb_sql::token::{Position, Token, TokenType};
2370 let condition = Expression::Infix(InfixExpression::new(
2371 Token::new(TokenType::Operator, "=", Position::default()),
2372 Box::new(Expression::Identifier(Identifier::new(
2373 Token::new(TokenType::Identifier, "a.id", Position::default()),
2374 "a.id".to_string(),
2375 ))),
2376 "=".to_string(),
2377 Box::new(Expression::Identifier(Identifier::new(
2378 Token::new(TokenType::Identifier, "b.id", Position::default()),
2379 "b.id".to_string(),
2380 ))),
2381 ));
2382 let left_schema = left_cols.iter().map(ColumnInfo::new).collect();
2383
2384 let result = executor
2385 .execute_streaming(StreamingJoinRequest {
2386 build_rows: right,
2387 build_columns: &right_cols,
2388 probe_source: Box::new(MaterializedOperator::new(left, left_schema)),
2389 probe_columns: left_cols,
2390 condition: Some(&condition),
2391 join_type: "LEFT",
2392 build_is_left: false,
2393 limit: None,
2394 ctx: &ctx,
2395 pre_built_hash_state: None,
2396 projection: None,
2397 })
2398 .unwrap();
2399
2400 assert_eq!(result.columns.len(), 4);
2401 let rows = result.rows.into_owned();
2402 assert_eq!(rows.len(), 2);
2403 assert!(rows.iter().all(|(_, row)| row.len() == 4));
2404 assert!(rows.iter().all(|(_, row)| row.get(2).unwrap().is_null()));
2405 assert!(rows.iter().all(|(_, row)| row.get(3).unwrap().is_null()));
2406 }
2407
2408 #[test]
2409 fn streaming_hash_state_rejects_different_physical_batch() {
2410 let executor = JoinExecutor::new();
2411 let ctx = ExecutionContext::new();
2412 let build_rows = CompactArc::new(make_rows(vec![vec![1], vec![2]]));
2413 let unrelated_rows = CompactArc::new((*build_rows).clone());
2414 let state = JoinHashState::build(build_rows, &[0]);
2415 let left_cols = vec!["a.id".to_string()];
2416 let right_cols = vec!["b.id".to_string()];
2417
2418 use radixdb_sql::ast::{Identifier, InfixExpression};
2419 use radixdb_sql::token::{Position, Token, TokenType};
2420 let condition = Expression::Infix(InfixExpression::new(
2421 Token::new(TokenType::Operator, "=", Position::default()),
2422 Box::new(Expression::Identifier(Identifier::new(
2423 Token::new(TokenType::Identifier, "a.id", Position::default()),
2424 "a.id".to_string(),
2425 ))),
2426 "=".to_string(),
2427 Box::new(Expression::Identifier(Identifier::new(
2428 Token::new(TokenType::Identifier, "b.id", Position::default()),
2429 "b.id".to_string(),
2430 ))),
2431 ));
2432 let left_schema = left_cols.iter().map(ColumnInfo::new).collect();
2433
2434 let error = executor
2435 .execute_streaming(StreamingJoinRequest {
2436 build_rows: unrelated_rows,
2437 build_columns: &right_cols,
2438 probe_source: Box::new(MaterializedOperator::new(
2439 make_rows(vec![vec![1]]),
2440 left_schema,
2441 )),
2442 probe_columns: left_cols,
2443 condition: Some(&condition),
2444 join_type: "INNER",
2445 build_is_left: false,
2446 limit: None,
2447 ctx: &ctx,
2448 pre_built_hash_state: Some(state),
2449 projection: None,
2450 })
2451 .unwrap_err();
2452
2453 assert!(error
2454 .to_string()
2455 .contains("does not match build rows and keys"));
2456 }
2457
2458 #[test]
2459 fn request_local_hash_state_is_built_once_for_one_shared_relation() {
2460 let executor = JoinExecutor::new();
2461 let ctx = ExecutionContext::new();
2462 let shared_build = CompactArc::new(make_rows(vec![vec![1, 100], vec![2, 200]]));
2463 let left_cols = vec!["a.id".to_string()];
2464 let right_cols = vec!["b.id".to_string(), "b.value".to_string()];
2465
2466 use radixdb_sql::ast::{Identifier, InfixExpression};
2467 use radixdb_sql::token::{Position, Token, TokenType};
2468 let condition = Expression::Infix(InfixExpression::new(
2469 Token::new(TokenType::Operator, "=", Position::default()),
2470 Box::new(Expression::Identifier(Identifier::new(
2471 Token::new(TokenType::Identifier, "a.id", Position::default()),
2472 "a.id".to_string(),
2473 ))),
2474 "=".to_string(),
2475 Box::new(Expression::Identifier(Identifier::new(
2476 Token::new(TokenType::Identifier, "b.id", Position::default()),
2477 "b.id".to_string(),
2478 ))),
2479 ));
2480
2481 radixdb_storage::instrumentation::begin_join_execution_probe();
2482 for _ in 0..2 {
2483 let result = executor
2484 .execute_streaming(StreamingJoinRequest {
2485 build_rows: CompactArc::clone(&shared_build),
2486 build_columns: &right_cols,
2487 probe_source: Box::new(MaterializedOperator::new(
2488 make_rows(vec![vec![1], vec![2]]),
2489 left_cols.iter().map(ColumnInfo::new).collect(),
2490 )),
2491 probe_columns: left_cols.clone(),
2492 condition: Some(&condition),
2493 join_type: "INNER",
2494 build_is_left: false,
2495 limit: None,
2496 ctx: &ctx,
2497 pre_built_hash_state: None,
2498 projection: None,
2499 })
2500 .unwrap();
2501 assert_eq!(result.rows.len(), 2);
2502 }
2503 let probe = radixdb_storage::instrumentation::end_join_execution_probe();
2504 assert_eq!(probe.hash_state_builds, 1);
2505 assert_eq!(probe.hash_state_reuses, 1);
2506 }
2507
2508 #[test]
2509 fn streaming_hash_budget_falls_back_without_changing_left_join_results() {
2510 let executor = JoinExecutor::new();
2511 let ctx = crate::context::ExecutionContextBuilder::new()
2512 .join_hash_state_max_bytes(0)
2513 .build();
2514 let left = make_rows(vec![vec![1, 10], vec![2, 20], vec![3, 30]]);
2515 let right = CompactArc::new(make_rows(vec![vec![1, 100], vec![3, 300]]));
2516 let left_cols = vec!["a.id".to_string(), "a.value".to_string()];
2517 let right_cols = vec!["b.id".to_string(), "b.value".to_string()];
2518
2519 use radixdb_sql::ast::{Identifier, InfixExpression};
2520 use radixdb_sql::token::{Position, Token, TokenType};
2521 let condition = Expression::Infix(InfixExpression::new(
2522 Token::new(TokenType::Operator, "=", Position::default()),
2523 Box::new(Expression::Identifier(Identifier::new(
2524 Token::new(TokenType::Identifier, "a.id", Position::default()),
2525 "a.id".to_string(),
2526 ))),
2527 "=".to_string(),
2528 Box::new(Expression::Identifier(Identifier::new(
2529 Token::new(TokenType::Identifier, "b.id", Position::default()),
2530 "b.id".to_string(),
2531 ))),
2532 ));
2533 let left_schema = left_cols.iter().map(ColumnInfo::new).collect();
2534
2535 radixdb_storage::instrumentation::begin_join_execution_probe();
2536 let result = executor
2537 .execute_streaming(StreamingJoinRequest {
2538 build_rows: right,
2539 build_columns: &right_cols,
2540 probe_source: Box::new(MaterializedOperator::new(left, left_schema)),
2541 probe_columns: left_cols,
2542 condition: Some(&condition),
2543 join_type: "LEFT",
2544 build_is_left: false,
2545 limit: None,
2546 ctx: &ctx,
2547 pre_built_hash_state: None,
2548 projection: None,
2549 })
2550 .unwrap();
2551 let probe = radixdb_storage::instrumentation::end_join_execution_probe();
2552
2553 let rows = result.rows.into_owned();
2554 assert_eq!(rows.len(), 3);
2555 assert!(rows
2556 .iter()
2557 .find(|(_, row)| row.get(0) == Some(&Value::integer(2)))
2558 .unwrap()
2559 .1
2560 .get(2)
2561 .unwrap()
2562 .is_null());
2563 assert_eq!(probe.hash_streaming_calls, 0);
2564 assert_eq!(probe.nested_loop_calls, 1);
2565 assert_eq!(probe.candidate_pairs, 6);
2566 }
2567
2568 #[test]
2569 fn test_materialized_hash_join_applies_projection_boundary() {
2570 let _path = radixdb_storage::test_failpoints::ExecutionPathControlGuard::install(
2571 radixdb_storage::test_failpoints::ExecutionPathMode::ForceParallel,
2572 );
2573 let executor = JoinExecutor::new();
2574 let ctx = ExecutionContext::new();
2575
2576 let left = make_rows(vec![
2577 vec![2, 20, 2000],
2578 vec![1, 10, 1000],
2579 vec![3, 30, 3000],
2580 ]);
2581 let right = make_rows(vec![vec![1, 100, 9000], vec![3, 300, 7000]]);
2582
2583 let left_cols = vec![
2584 "a.id".to_string(),
2585 "a.val".to_string(),
2586 "a.unused".to_string(),
2587 ];
2588 let right_cols = vec![
2589 "b.id".to_string(),
2590 "b.data".to_string(),
2591 "b.unused".to_string(),
2592 ];
2593
2594 use radixdb_sql::ast::{Identifier, InfixExpression};
2595 use radixdb_sql::token::{Position, Token, TokenType};
2596
2597 let cond = Expression::Infix(InfixExpression::new(
2598 Token::new(TokenType::Operator, "=", Position::default()),
2599 Box::new(Expression::Identifier(Identifier::new(
2600 Token::new(TokenType::Identifier, "a.id", Position::default()),
2601 "a.id".to_string(),
2602 ))),
2603 "=".to_string(),
2604 Box::new(Expression::Identifier(Identifier::new(
2605 Token::new(TokenType::Identifier, "b.id", Position::default()),
2606 "b.id".to_string(),
2607 ))),
2608 ));
2609
2610 let projection = JoinProjectionIndices {
2611 columns: vec![ColumnSource::Outer(1), ColumnSource::Inner(1)],
2612 output_columns: vec!["val".to_string(), "data".to_string()],
2613 };
2614 let decision = RuntimeJoinDecision {
2615 algorithm: RuntimeJoinAlgorithm::HashJoin,
2616 swap_sides: false,
2617 explanation: "test forces hash join".to_string(),
2618 };
2619
2620 let request = JoinRequest {
2621 left_rows: CompactArc::new(left),
2622 right_rows: CompactArc::new(right),
2623 left_columns: &left_cols,
2624 right_columns: &right_cols,
2625 condition: Some(&cond),
2626 join_type: "INNER",
2627 limit: None,
2628 ctx: &ctx,
2629 algorithm_hint: Some(&decision),
2630 ordering: JoinInputOrderings::default(),
2631 projection: Some(&projection),
2632 };
2633
2634 radixdb_storage::instrumentation::begin_join_execution_probe();
2635 let result = executor.execute(request).unwrap();
2636 let probe = radixdb_storage::instrumentation::end_join_execution_probe();
2637
2638 assert_eq!(result.columns, vec!["val".to_string(), "data".to_string()]);
2639 let rows = result.rows.into_owned();
2640 assert_eq!(rows.len(), 2);
2641 assert!(rows.iter().all(|(_, row)| row.len() == 2));
2642 assert_eq!(probe.hash_parallel_calls, 1);
2643 assert_eq!(probe.hash_streaming_calls, 0);
2644 }
2645
2646 #[test]
2647 fn test_materialized_hash_join_filters_residual_before_projecting_deferred_row() {
2648 let executor = JoinExecutor::new();
2649 let ctx = ExecutionContext::new();
2650
2651 let left = make_rows(vec![vec![1, 10, 1000], vec![2, 20, 2000]]);
2652 let right = make_rows(vec![vec![1, 100, 9000], vec![2, 15, 8000]]);
2653
2654 let left_cols = vec![
2655 "a.id".to_string(),
2656 "a.val".to_string(),
2657 "a.unused".to_string(),
2658 ];
2659 let right_cols = vec![
2660 "b.id".to_string(),
2661 "b.data".to_string(),
2662 "b.unused".to_string(),
2663 ];
2664
2665 use radixdb_sql::ast::{Identifier, InfixExpression};
2666 use radixdb_sql::token::{Position, Token, TokenType};
2667
2668 let eq = Expression::Infix(InfixExpression::new(
2669 Token::new(TokenType::Operator, "=", Position::default()),
2670 Box::new(Expression::Identifier(Identifier::new(
2671 Token::new(TokenType::Identifier, "a.id", Position::default()),
2672 "a.id".to_string(),
2673 ))),
2674 "=".to_string(),
2675 Box::new(Expression::Identifier(Identifier::new(
2676 Token::new(TokenType::Identifier, "b.id", Position::default()),
2677 "b.id".to_string(),
2678 ))),
2679 ));
2680 let residual = Expression::Infix(InfixExpression::new(
2681 Token::new(TokenType::Operator, "<", Position::default()),
2682 Box::new(Expression::Identifier(Identifier::new(
2683 Token::new(TokenType::Identifier, "a.val", Position::default()),
2684 "a.val".to_string(),
2685 ))),
2686 "<".to_string(),
2687 Box::new(Expression::Identifier(Identifier::new(
2688 Token::new(TokenType::Identifier, "b.data", Position::default()),
2689 "b.data".to_string(),
2690 ))),
2691 ));
2692 let cond = Expression::Infix(InfixExpression::new(
2693 Token::new(TokenType::Keyword, "AND", Position::default()),
2694 Box::new(eq),
2695 "AND".to_string(),
2696 Box::new(residual),
2697 ));
2698
2699 let projection = JoinProjectionIndices {
2700 columns: vec![ColumnSource::Outer(1), ColumnSource::Inner(1)],
2701 output_columns: vec!["val".to_string(), "data".to_string()],
2702 };
2703 let decision = RuntimeJoinDecision {
2704 algorithm: RuntimeJoinAlgorithm::HashJoin,
2705 swap_sides: false,
2706 explanation: "test forces hash join".to_string(),
2707 };
2708
2709 let request = JoinRequest {
2710 left_rows: CompactArc::new(left),
2711 right_rows: CompactArc::new(right),
2712 left_columns: &left_cols,
2713 right_columns: &right_cols,
2714 condition: Some(&cond),
2715 join_type: "INNER",
2716 limit: None,
2717 ctx: &ctx,
2718 algorithm_hint: Some(&decision),
2719 ordering: JoinInputOrderings::default(),
2720 projection: Some(&projection),
2721 };
2722
2723 let result = executor.execute(request).unwrap();
2724
2725 assert_eq!(result.columns, vec!["val".to_string(), "data".to_string()]);
2726 assert!(matches!(result.rows, JoinRows::Deferred(_)));
2727 let rows = result.rows.into_owned();
2728 assert_eq!(rows.len(), 1);
2729 assert_eq!(rows[0].1.len(), 2);
2730 assert_eq!(rows[0].1.get(0), Some(&Value::integer(10)));
2731 assert_eq!(rows[0].1.get(1), Some(&Value::integer(100)));
2732 }
2733
2734 #[test]
2735 fn inner_residual_merge_candidate_uses_hash_instead_of_quadratic_fallback() {
2736 let executor = JoinExecutor::new();
2737 let ctx = ExecutionContext::new();
2738 let left = make_rows(vec![vec![1, 10], vec![2, 20]]);
2739 let right = make_rows(vec![vec![1, 100], vec![2, 15]]);
2740 let left_cols = vec!["a.id".to_string(), "a.val".to_string()];
2741 let right_cols = vec!["b.id".to_string(), "b.data".to_string()];
2742
2743 use radixdb_sql::ast::{Identifier, InfixExpression};
2744 use radixdb_sql::token::{Position, Token, TokenType};
2745 let column = |name: &str| {
2746 Expression::Identifier(Identifier::new(
2747 Token::new(TokenType::Identifier, name, Position::default()),
2748 name.to_string(),
2749 ))
2750 };
2751 let infix = |left: Expression, op: &str, right: Expression| {
2752 Expression::Infix(InfixExpression::new(
2753 Token::new(TokenType::Operator, op, Position::default()),
2754 Box::new(left),
2755 op.to_string(),
2756 Box::new(right),
2757 ))
2758 };
2759 let condition = infix(
2760 infix(column("a.id"), "=", column("b.id")),
2761 "AND",
2762 infix(column("a.val"), "<", column("b.data")),
2763 );
2764 let decision = RuntimeJoinDecision {
2765 algorithm: RuntimeJoinAlgorithm::MergeJoin,
2766 swap_sides: false,
2767 explanation: "test sorted-input merge candidate".to_string(),
2768 };
2769
2770 radixdb_storage::instrumentation::begin_join_execution_probe();
2771 let result = executor
2772 .execute(JoinRequest {
2773 left_rows: CompactArc::new(left),
2774 right_rows: CompactArc::new(right),
2775 left_columns: &left_cols,
2776 right_columns: &right_cols,
2777 condition: Some(&condition),
2778 join_type: "INNER",
2779 limit: None,
2780 ctx: &ctx,
2781 algorithm_hint: Some(&decision),
2782 ordering: JoinInputOrderings::default(),
2783 projection: None,
2784 })
2785 .unwrap();
2786 let probe = radixdb_storage::instrumentation::end_join_execution_probe();
2787
2788 assert_eq!(result.rows.len(), 1);
2789 assert_eq!(probe.hash_streaming_calls, 1);
2790 assert_eq!(probe.nested_loop_calls, 0);
2791 }
2792
2793 #[test]
2794 fn test_materialized_nested_loop_applies_projection_boundary() {
2795 let executor = JoinExecutor::new();
2796 let ctx = ExecutionContext::new();
2797
2798 let left = make_rows(vec![vec![1, 10], vec![2, 20]]);
2799 let right = make_rows(vec![vec![100, 1000], vec![200, 2000]]);
2800
2801 let left_cols = vec!["a.id".to_string(), "a.val".to_string()];
2802 let right_cols = vec!["b.id".to_string(), "b.data".to_string()];
2803
2804 let projection = JoinProjectionIndices {
2805 columns: vec![ColumnSource::Inner(1), ColumnSource::Outer(1)],
2806 output_columns: vec!["data".to_string(), "val".to_string()],
2807 };
2808
2809 let request = JoinRequest {
2810 left_rows: CompactArc::new(left),
2811 right_rows: CompactArc::new(right),
2812 left_columns: &left_cols,
2813 right_columns: &right_cols,
2814 condition: None,
2815 join_type: "CROSS",
2816 limit: None,
2817 ctx: &ctx,
2818 algorithm_hint: None,
2819 ordering: JoinInputOrderings::default(),
2820 projection: Some(&projection),
2821 };
2822
2823 let result = executor.execute(request).unwrap();
2824
2825 assert_eq!(result.columns, vec!["data".to_string(), "val".to_string()]);
2826 let rows = result.rows.into_owned();
2827 assert_eq!(rows.len(), 4);
2828 assert!(rows.iter().all(|(_, row)| row.len() == 2));
2829 assert_eq!(rows[0].1.get(0), Some(&Value::integer(1000)));
2830 assert_eq!(rows[0].1.get(1), Some(&Value::integer(10)));
2831 }
2832
2833 #[test]
2834 fn test_cross_join() {
2835 let executor = JoinExecutor::new();
2836 let ctx = ExecutionContext::new();
2837
2838 let left = make_rows(vec![vec![1], vec![2]]);
2839 let right = make_rows(vec![vec![10], vec![20]]);
2840
2841 let left_cols = vec!["a.id".to_string()];
2842 let right_cols = vec!["b.val".to_string()];
2843
2844 let request = JoinRequest {
2845 left_rows: CompactArc::new(left),
2846 right_rows: CompactArc::new(right),
2847 left_columns: &left_cols,
2848 right_columns: &right_cols,
2849 condition: None,
2850 join_type: "CROSS",
2851 limit: None,
2852 ctx: &ctx,
2853 algorithm_hint: None,
2854 ordering: JoinInputOrderings::default(),
2855 projection: None,
2856 };
2857
2858 let result = executor.execute(request).unwrap();
2859
2860 assert_eq!(result.rows.len(), 4);
2862 }
2863}